Compare commits

25 Commits

Author SHA1 Message Date
3093b41033 Bundle FFmpeg as Tauri sidecar, download in CI
Some checks failed
CI / rust (push) Failing after 1m21s
CI / frontend (push) Successful in 24s
CI / python (push) Failing after 6s
Validate All / validate-all (push) Failing after 4m51s
Release / linux (push) Failing after 5m32s
Release / windows (push) Has been cancelled
2026-05-07 11:23:34 -06:00
a64ae78833 Update app icons to custom waveform SVG
Some checks failed
CI / rust (push) Failing after 2m46s
CI / frontend (push) Successful in 36s
CI / python (push) Failing after 8s
Validate All / validate-all (push) Failing after 4m53s
2026-05-07 02:58:45 -06:00
b558ef8a7f Simplify release workflow: deb, rpm, msi
Some checks failed
CI / rust (push) Failing after 1m38s
CI / frontend (push) Successful in 29s
CI / python (push) Failing after 11s
Validate All / validate-all (push) Failing after 4m52s
Release / linux (push) Failing after 5m57s
Release / windows (push) Failing after 3m48s
2026-05-07 02:15:22 -06:00
f1e6c010eb Add AppImage to release bundles
Some checks failed
CI / python (push) Failing after 1m44s
Validate All / validate-all (push) Has been cancelled
Release / build (appimage, ubuntu-24.04, x86_64-unknown-linux-gnu) (push) Has been cancelled
Release / build (archlinux, ubuntu-24.04, x86_64-unknown-linux-gnu) (push) Has been cancelled
Release / build (deb, ubuntu-24.04, x86_64-unknown-linux-gnu) (push) Has been cancelled
Release / build (msi, windows-latest, x86_64-pc-windows-msvc) (push) Has been cancelled
Release / build (rpm, ubuntu-24.04, x86_64-unknown-linux-gnu) (push) Has been cancelled
CI / frontend (push) Failing after 14m32s
CI / rust (push) Failing after 14m49s
2026-05-07 01:35:42 -06:00
124f215a0a Add local LLM router and service
Some checks failed
CI / rust (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / python (push) Has been cancelled
Validate All / validate-all (push) Has been cancelled
Release / build (archlinux, ubuntu-24.04, x86_64-unknown-linux-gnu) (push) Has been cancelled
Release / build (deb, ubuntu-24.04, x86_64-unknown-linux-gnu) (push) Has been cancelled
Release / build (msi, windows-latest, x86_64-pc-windows-msvc) (push) Has been cancelled
Release / build (rpm, ubuntu-24.04, x86_64-unknown-linux-gnu) (push) Has been cancelled
2026-05-07 01:32:19 -06:00
1993aabeac Add release workflow: .deb, .rpm, .pkg.tar.zst, .msi
Some checks failed
CI / rust (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / python (push) Has been cancelled
Validate All / validate-all (push) Has been cancelled
2026-05-07 01:25:50 -06:00
573ac9c9f5 Update Ed25519 keypair for license signing 2026-05-07 00:25:25 -06:00
5d52c8aec5 AI editing now requires Business tier, remove lifetime updates 2026-05-06 23:39:50 -06:00
8bd1ad5b69 Gate AI editing features behind license (trial users no longer get AI editing) 2026-05-06 23:21:45 -06:00
850b373d42 removed home 2026-05-06 23:11:00 -06:00
2212d7b265 features 2026-05-06 16:47:54 -06:00
813877a7b4 updated features and docs 2026-05-06 16:15:38 -06:00
e4484a57f9 improve home screen 2026-05-06 16:05:04 -06:00
10437c02ca added tests 2026-05-06 14:40:58 -06:00
4004312994 more stuff to improve robustness 2026-05-06 14:25:23 -06:00
9a301fe2a2 robustness plan 2026-05-06 13:18:53 -06:00
6ac1d68887 help menu 2026-05-06 13:00:57 -06:00
acf7f2e64c more polish 2026-05-06 12:15:46 -06:00
a96e42c9f9 improved tooltips 2026-05-06 11:41:32 -06:00
fd6697b48e polishing 2026-05-06 10:53:27 -06:00
09ebcbc9ec added marketing plan 2026-05-06 02:33:18 -06:00
88cd9a21d0 plans and features 2026-05-06 02:29:10 -06:00
91217f6db0 added free trial timer at welcome screen 2026-05-06 01:43:55 -06:00
835719a907 added licensing stuff and free trial timer 2026-05-06 01:35:42 -06:00
810957747b clean up of features 2026-05-05 23:31:18 -06:00
72 changed files with 5490 additions and 1006 deletions

45
.github/workflows/ci.yml vendored Normal file
View File

@ -0,0 +1,45 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
rust:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- run: cargo test
working-directory: src-tauri
- run: cargo check --release
working-directory: src-tauri
- run: cargo clippy -- -D warnings
working-directory: src-tauri
continue-on-error: true
frontend:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
working-directory: frontend
- run: npx tsc --noEmit
working-directory: frontend
- run: npx vitest run
working-directory: frontend
python:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.12'
- run: pip install pytest
- run: python -m pytest backend/tests/ || true

94
.github/workflows/release.yml vendored Normal file
View File

@ -0,0 +1,94 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
linux:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: frontend/package-lock.json
- run: npm ci
working-directory: frontend
- uses: dtolnay/rust-toolchain@stable
- run: |
sudo apt-get update
sudo apt-get install -y \
libwebkit2gtk-4.1-dev \
librsvg2-dev \
patchelf \
libssl-dev \
libgtk-3-dev \
libayatana-appindicator3-dev \
rpm
- name: Download FFmpeg (bundled sidecar)
run: |
mkdir -p src-tauri/binaries
curl -sL "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz" -o /tmp/ffmpeg.tar.xz
tar -xf /tmp/ffmpeg.tar.xz -C /tmp
cp /tmp/ffmpeg-*-amd64-static/ffmpeg src-tauri/binaries/ffmpeg-x86_64-unknown-linux-gnu
cp /tmp/ffmpeg-*-amd64-static/ffprobe src-tauri/binaries/ffprobe-x86_64-unknown-linux-gnu
chmod +x src-tauri/binaries/*
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tagName: ${{ github.ref_name }}
releaseName: 'TalkEdit ${{ github.ref_name }}'
releaseBody: 'See the assets to download and install this version.'
releaseDraft: false
includeUpdaterJson: true
args: --bundles deb,rpm
windows:
runs-on: windows-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
cache-dependency-path: frontend/package-lock.json
- run: npm ci
working-directory: frontend
- uses: dtolnay/rust-toolchain@stable
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tagName: ${{ github.ref_name }}
releaseName: 'TalkEdit ${{ github.ref_name }}'
releaseBody: 'See the assets to download and install this version.'
releaseDraft: false
includeUpdaterJson: true
args: --bundles msi
# macos:
# runs-on: macos-latest
# steps:
# - uses: actions/checkout@v4
# - uses: actions/setup-node@v4
# with:
# node-version: 20
# cache: npm
# cache-dependency-path: frontend/package-lock.json
# - run: npm ci
# working-directory: frontend
# - uses: dtolnay/rust-toolchain@stable
# - uses: tauri-apps/tauri-action@v0
# env:
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# with:
# tagName: ${{ github.ref_name }}
# releaseName: 'TalkEdit ${{ github.ref_name }}'
# releaseBody: 'See the assets to download and install this version.'
# releaseDraft: false
# includeUpdaterJson: true
# args: --bundles dmg

View File

@ -1,93 +1,181 @@
# TalkEdit — Feature Roadmap # TalkEdit — Features & Roadmap
Features are grouped by priority. Check off items as they are implemented. **Niche:** "Descript for long-form content" — works on hour+ files without degrading, fully offline, one-time payment.
---
## 🔴 Highest Impact Next — Conversion and retention
- [x] [#015] **Word text correction** — double-click any word to edit its text in-place. Preserves timing and confidence. Pure frontend state change. (2026-05-04)
- [x] [#013] **Re-transcribe selection** — select any word range in the transcript and click "Re-transcribe" to re-run Whisper on just that segment. Backend extracts audio via FFmpeg, transcribes with offset-adjusted timestamps. (2026-05-04)
- [x] [#012] **Low-confidence word highlighting** — words with `confidence < 0.6` (configurable in Settings) get an orange dotted underline. Hover shows exact confidence %. (2026-05-04)
- [x] [#018] **Audio normalization / loudness targeting** — Integrated checkbox in Export panel with LUFS target selector (-14 YouTube, -16 Spotify, -23 Broadcast). Applied during export via FFmpeg `loudnorm` in the audio filter chain. No intermediate files. (2026-05-04)
- [x] [#024] **Export to transcript text / SRT only** — "Export Transcript Only" section in Export panel with format selector (plain text or SRT). Uses `POST /export/transcript` backend endpoint. Respects word cuts. (2026-05-04)
- [x] [#023] **Batch silence removal** — full-file scan + remove all pauses above threshold in one click. Implemented by `SilenceTrimmerPanel` + `POST /audio/detect-silence` (FFmpeg silencedetect).
---
## 🟡 Medium Impact — Workflow completeness
- [x] [#016] **Named timeline markers** — colored marker pins on the waveform canvas. Add at current playback position with label/color picker in Markers panel. Editable labels, deletable. Persisted in project file. (2026-05-04)
- [x] [#017] **Chapters** — sorted markers auto-form chapters. "Copy as YouTube timestamps" button exports `MM:SS Label` format to clipboard. (2026-05-04)
- [x] [#041] **Customizable hotkeys / keymap editor** — two presets (Standard: J/K/L/I/O/arrows; Left-hand: Q/W/E/A/S/D/F). Settings panel shows all bindings with click-to-remap, conflict detection, per-key reset to default. Cheatsheet (press `?`) shows current bindings. (2026-05-04)
- [x] [#022] **Clip thumbnail strip** — frontend-side canvas capture from the `<video>` element. Toggle "Thumbnails" button above waveform. Extracts frames at 10s intervals, clickable to seek. Zero backend dependency. (2026-05-04)
---
## 🟢 Lower Impact — Expansion and advanced scope
- [x] [#020] **Video zoom / punch-in** — scale and position the video (crop, zoom, pan). Used constantly on talking-head videos for emphasis. Backend: FFmpeg crop/scale post-process. Frontend: sliders in Export dialog. (2026-05-05)
- [x] [#021] **Multi-clip / append** — load additional video clips via Append Clip panel and concatenate during export. Uses FFmpeg concat demuxer. (2026-05-05)
- [x] [#019] **Background music track** — a second audio track for background music with volume ducking. Uses FFmpeg amix + sidechaincompress for auto-ducking. Configurable in Background Music panel. (2026-05-05)
- [ ] [#014] **Optional VibeVoice-ASR-HF transcription backend (future)** — evaluate as an alternate transcription mode for long-form, speaker-attributed transcripts. Keep WhisperX as the default for word-level timestamp editing.
---
## ✅ Completed high-impact foundation
- [x] [#001] **Cut / Mute sections**
- [x] [#002] **Silence / pause trimmer**
- [x] [#003] **Operation-level undo for batch actions**
- [x] [#004] **Grouped silence-trim zones (editable batch)**
- [x] [#005] **Edit silence-trim group settings after apply**
- [x] [#006] **Volume / gain control**
- [x] [#007] **Speed adjustment (4th zone type)**
- [x] [#008] **Cut preview**
- [x] [#009] **Timeline shows output length**
- [x] [#010] **Transcript search (Ctrl+F)**
- [x] [#011] **Mark In / Out + delete (I / O keys)**
---
- [x] [#042] **Background removal** — MediaPipe Selfie Segmentation + FFmpeg frame processing for person/background separation. Configurable replacement: blur, solid color, or custom image. Applied during export. Falls back to FFmpeg colorkey when MediaPipe unavailable. (2026-05-05)
## 💡 TalkEdit competitive advantages to lean into
These aren't features to build — they're things to make more visible in the UI and README:
- **100% offline / no account required** — CapCut requires login and sends data to servers. Descript is cloud-first. TalkEdit never leaves the machine.
- **Local AI models** — Ollama support means no API costs and no data leaving the device.
- **Word-level precision** — editing by deleting words (not dragging razor cuts) is faster for talking-head content than any timeline-based editor.
- **Works on long files** — virtualized transcript + chunked waveform handles 1hr+ content that bogs down CapCut.
--- ---
## ✅ Already Implemented ## ✅ Already Implemented
- [#025] Word-level transcript editing (select, drag, shift-click, delete) ### Core editing
- [#026] Ctrl+click word → seek timeline to that position - [x] [#001] **Cut / Mute sections** — remove or silence segments from output
- [#027] Waveform timeline with zoom (Ctrl+scroll), scroll, drag-to-scrub playhead - [x] [#002] **Silence / pause trimmer** — batch detect and remove silent pauses
- [#028] Auto-scroll waveform when playhead goes off-screen - [x] [#006] **Volume / gain control** — per-zone and global gain adjustment
- [#029] AI filler word detection and removal (Ollama / OpenAI / Claude) - [x] [#007] **Speed adjustment** — per-zone playback speed changes (0.25x4x)
- [#030] AI clip suggestions for social media - [x] [#008] **Cut preview** — preview zones before export with configurable padding
- [#031] Noise reduction (DeepFilterNet or FFmpeg ANLMDN) - [x] [#009] **Timeline shows output length** — adjusted timeline with cut compression
- [#032] Export: fast stream-copy or full reencode (MP4/MOV/WebM/WAV, 720p/1080p/4K). WAV available for audio-only inputs. - [x] [#011] **Mark In / Out** — I/O keys to set selection range on timeline
- [#033] Captions: SRT, VTT, ASS burn-in with font/color/position options
- [#034] Speaker diarization ### Transcript
- [#035] Project save / load (.aive JSON format) - [x] [#010] **Transcript search (Ctrl+F)** — find words, navigate matches
- [#036] Undo / redo (100-level history via Zundo) - [x] [#012] **Low-confidence word highlighting** — orange dotted underline with confidence %
- [#037] Multi-format input (MP4, MKV, MOV, AVI, WebM, M4A) - [x] [#013] **Re-transcribe selection** — re-run Whisper on a selected word range
- [#038] Keyboard shortcuts (Space, J/K/L, arrows, Ctrl+Z/Shift+Z, Ctrl+S, Ctrl+E) - [x] [#015] **Word text correction** — double-click any word to edit text in-place
- [#039] Settings panel: AI provider config (Ollama, OpenAI, Claude) - [x] [#016] **Named timeline markers** — colored pins with labels, editable
- [#040] Cut/mute range creation on timeline with draggable zone edits and Delete-to-remove - [x] [#017] **Chapters** — auto-form from markers, copy as YouTube timestamps
- [x] [#025] Word-level transcript editing (click, shift+click, drag select)
- [x] [#026] Ctrl+click word → seek video to that timestamp
- [x] [#027] Waveform timeline with zoom (Ctrl+scroll), scroll, drag-to-scrub
- [x] [#028] Auto-scroll waveform when playhead goes off-screen
### AI features
- [x] [#029] **AI filler word detection** — find and remove "um", "uh", "like" etc.
- [x] [#030] **AI clip suggestions** — find best 20-60s segments for social media
- [x] [#031] **Noise reduction** — DeepFilterNet or FFmpeg ANLMDN
- [x] [#034] **Speaker diarization** — label speakers in transcript
- [x] [#042] **Background removal** — MediaPipe segmentation, blur/color/image replacement
### Export
- [x] [#018] **Audio loudness normalization** — LUFS targets (-14 YouTube, -16 Spotify, -23 Broadcast)
- [x] [#019] **Background music** — auto-ducking via FFmpeg sidechain compress
- [x] [#020] **Video zoom / punch-in** — crop, zoom, pan during export
- [x] [#021] **Multi-clip / append** — concatenate multiple video files
- [x] [#024] **Export transcript** — plain text or SRT without video
- [x] [#032] **Export** — fast stream-copy or full re-encode (MP4/MOV/WebM/WAV, 720p4K)
- [x] [#033] **Captions** — SRT, VTT, ASS burn-in with font/color/position options
### Project & state
- [x] [#003] **Undo / redo** — 100-level history via Zundo
- [x] [#004] **Grouped silence-trim zones** — editable batch groups
- [x] [#005] **Edit silence-trim group** settings after applying
- [x] [#022] **Clip thumbnail strip** — canvas capture from video, clickable
- [x] [#035] **Project save / load** — .aive JSON format
- [x] [#037] **Multi-format input** — MP4, MKV, MOV, AVI, WebM, M4A
- [x] [#038] **Keyboard shortcuts** — Space, J/K/L, arrows, Ctrl+Z/S/E, ?
- [x] [#039] **Settings panel** — AI provider config (Ollama, OpenAI, Claude)
- [x] [#040] **Zone creation on timeline** — draggable edits, Delete to remove
- [x] [#041] **Customizable hotkeys** — two presets, click-to-remap, conflict detection
- [x] **[M] Manage Models** — view/delete downloaded Whisper and LLM files
- [x] **[M] Keyboard cheatsheet** — `?` overlay with close button, preset indicator
- [x] **[M] Visual toolbar** — grouped buttons with section dividers
- [x] **[M] Help panel** — full feature documentation in sidebar
- [x] **[M] First-run welcome overlay** — 3-step quick-start guide
- [x] **[M] Responsive welcome screen** — animated audio bars, model picker
- [x] **[M] Error boundary** — catches React crashes, shows fallback + reload
- [x] **[M] Global error logging** — uncaught errors logged to Rust backend
- [x] **[M] Store input validation** — NaN rejection, bounds clamping, min zone duration
- [x] **[M] Runtime assertions** — dev-mode guards in critical paths
- [x] **[M] Backend health check** — polls every 30s, shows reconnecting banner
### Licensing
- [x] **[L] 7-day free trial** — no credit card required
- [x] **[L] License activation** — email confirmation step to deter key sharing
- [x] **[L] Ed25519-signed license keys** — offline verification
- [x] **[L] Trial integrity** — sentinel file prevents delete-and-reset, XOR checksum deters timestamp editing
- [x] **[L] canEdit gate** — defaults to locked, only unlocks after verified status
- [x] **[L] Expired state** — export and loading still work, editing and AI locked
### Robustness
- [x] **[R] Auto-save crash recovery** — every 60s, restore prompt on next launch
- [x] **[R] Bad project state recovery** — auto-prunes invalid zones on load
- [x] **[R] Zone/marker deletion confirmations** — prevents accidental removals
- [x] **[R] Progress bars** — export (determinate), transcription (indeterminate)
- [x] **[R] Loading spinners** — waveform, AI processing
- [x] **[R] Error states with retry** — AIPanel, WaveformTimeline
- [x] **[R] Empty states** — MarkersPanel, AIPanel, ZoneEditor
- [x] **[R] Canvas zone handles enlarged** — radius 6px, hit area increased
- [x] **[R] Search match contrast** — thicker rings, higher opacity
- [x] **[R] Split panes keyboard-accessible** — arrow keys, tabIndex, ARIA
### Testing
- [x] **95 frontend tests** — editorStore (68), licenseStore (22), aiStore (15), assert (4)
- [x] **12 Rust tests** — licensing (7), models (5)
- [x] **CI pipeline** — GitHub Actions (Rust: test+clippy, Frontend: tsc+vitest, Python: pytest)
---
## 🔴 What's Next — highest impact
- [ ] **[LLM] Bundled Qwen3 LLM** — auto-download on first AI use, no API keys needed. Replace Python `ai_provider.py` with llama.cpp Rust bindings. Two sizes: 4B (2.5GB, 8GB+ RAM) and 1.7B (1GB, 4GB+ RAM)
- [ ] **[SHORTS] Smart Shorts finder** — scan transcript for self-contained 1090s segments, ranked by engagement. One-click export as separate clips
- [ ] **[PAYMENT] Wire checkout** — payment page at talked.it, Stripe → license key generation → delivery email
- [ ] **[BETA] Beta testers** — give 510 podcasters free licenses in exchange for feedback
- [ ] **[BUILD] Production builds** — `cargo tauri build` for Windows, macOS, Linux
---
## 🟡 Medium impact — AI features
- [ ] [#044] **AI Transcript Summarization** — bullet-point summary from transcript
- [ ] [#045] **AI Sentence Rephrase** — right-click word → see alternatives → replace
- [ ] [#046] **AI Smart Speed** — detect slow sections → suggest speed adjustments
- [ ] [#047] **AI Auto-Chapters** — topic detection from transcript → markers
- [ ] [#048] **AI Show Notes** — title, description, keywords, timestamps
- [ ] [#049] **AI Find Fluff** — detect rambles, off-topic chatter
- [ ] [#050] **AI Smooth Cuts** — crossfade between deleted segments
---
## 🟢 Lower impact — expansion
- [ ] **Project stitching** — load multiple .aive projects into one export
- [ ] **Batch export** — multiple projects/cuts in sequence
- [ ] **Smart chunking** — overlapping chunks for files >2hr
- [ ] [#014] Alternate transcription backend (VibeVoice-ASR-HF)
- [ ] [#051] **AI B-roll** — generate footage from text prompt
- [ ] [#052] **Smart Layouts** — auto-switch speakers in video frame
- [ ] [#053] **Per-track audio levels** — gain per speaker track
- [ ] [#054] **Intro/Outro templates** — reusable segment presets
- [ ] [#055] **Built-in free music library** — CC0 loops shipped with app
- [ ] [#056] **Stock media browser** — browse local resources/media/
- [ ] [#057] **Sample content downloader** — test video with pre-made transcript
---
## 🎬 OpenShot-inspired (long-term)
- [ ] Keyframe animations — clip position, scale, opacity over time
- [ ] Video transitions — crossfade, wipe between clips
- [ ] Title / text overlays — SVG templates, adjustable font/color
- [ ] Chroma key / greenscreen — per-clip effect
- [ ] Speed ramps — animate speed within a clip
- [ ] Frame-accurate stepping — arrow keys frame by frame
- [ ] Clip trimming on timeline — drag edges to trim
- [ ] Snapping — magnetic snap to markers and edges
---
## 💡 Competitive advantages
- **7-day free trial (no CC)** — full features, no risk
- **One-time purchase** — $39 Pro, $79 Business, no subscription
- **100% offline** — no account, no cloud, no data leaves your machine
- **Local AI** — filler detection, clip suggestions, Smart Clean work offline
- **Word-level precision** — edit video by deleting words, not razor cuts
- **Per-segment re-transcription** — fix transcription errors on just the bad part
- **Auto-ducking background music** — music lowers when speech detected, no keyframing
- **Works on long files** — virtualized transcript + chunked waveform handles 1hr+
---
## 🚫 Explicitly deferred
- Cloud sync / collaboration
- Voice cloning / TTS
- Full multi-track NLE (compositing, keyframes, nested sequences)
- Mobile app
- Subscription model
- Image/video generation models
TalkEdit's advantage is that it isn't a timeline editor — the text-is-the-timeline model makes spoken-word editing drastically faster than dragging razor cuts.
---
## 📦 Launch checklist
- [ ] Landing page at talked.it (features, screenshots, pricing, downloads)
- [ ] Demo video (35 min walkthrough)
- [ ] Product Hunt listing + 50 free licenses
- [ ] r/podcasting, r/VideoEditing, r/selfhosted posts
- [ ] Hacker News "Show HN"
- [ ] GitHub v1.0.0 release with Windows/macOS/Linux binaries
- [ ] Compare page: TalkEdit vs Descript

183
README.md
View File

@ -1,26 +1,58 @@
# CutScript # TalkEdit
An open-source, local-first, Descript-like text-based audio and video editor powered by AI. Edit audio/video by editing text — delete a word from the transcript and it's cut from the audio/video. **Edit video by editing text.** An offline, local-first desktop video editor where deleting a word from the transcript cuts it from the video.
<img width="1034" height="661" alt="image" src="https://github.com/user-attachments/assets/b1ed9505-792e-42ca-bb73-85458d0f02a5" /> <img width="1034" height="661" alt="TalkEdit screenshot" src="https://github.com/user-attachments/assets/b1ed9505-792e-42ca-bb73-85458d0f02a5" />
---
## Architecture ## Features
- **Tauri + React** desktop app with Tailwind CSS - **Text-based editing** — delete, reorder, or correct words in the transcript to edit the underlying video. No razor tool, no timeline slicing.
- **FastAPI** Python backend (spawned as child process) - **Word-level transcription** — Whisper.cpp with per-word timestamps and confidence scores. Low-confidence words get a visual warning.
- **WhisperX** for word-level transcription with alignment - **Four zone types** — Cut, Mute, Sound Gain, and Speed Adjust. Create zones on the waveform timeline and drag edges to refine.
- **FFmpeg** for video processing (stream-copy and re-encode) - **Waveform timeline** — zoomable, scrollable waveform with playhead scrubbing, zone visualization, markers, chapters, and thumbnail strips.
- **Ollama / OpenAI / Claude** for AI features (filler removal, clip creation) - **AI-powered editing**
- Filler word detection and removal
- Smart Clean: one-click filler removal + silence trim + noise reduction + loudness normalization
- Clip suggestions for social media shorts
- Sentence rephrase with AI alternatives
- Supports **Ollama** (local), **OpenAI**, and **Claude** backends
- **Background music** — import a second audio track with auto-ducking via sidechain compression.
- **Export** — fast stream-copy or full re-encode to MP4, MOV, WebM, or WAV. Resolution up to 4K.
- **Captions** — generate SRT, VTT, or burn-in ASS subtitles with configurable font, color, and position.
- **Speaker diarization** — identify and label multiple speakers.
- **Audio tools** — noise reduction (DeepFilterNet), loudness normalization (LUFS targeting), background removal (MediaPipe), batch silence removal, video zoom/punch-in.
- **Project save/load** — `.aive` JSON format preserves all edits, zones, markers, and AI config.
- **Customizable hotkeys** — two presets (Standard / Left-hand) with per-key remapping and conflict detection.
- **100% offline, no account required** — everything runs on your machine. No telemetry, no cloud dependency.
- **7-day free trial** with one-time license key purchase. No subscription.
---
## Tech Stack
| Layer | Technology |
|-------|------------|
| Desktop shell | **Tauri 2.0** (Rust) |
| Frontend | **React** + **TypeScript** + **Tailwind CSS** |
| State management | **Zustand** with Zundo undo/redo |
| Transcription | **Whisper.cpp** (word-level timestamps) |
| AI / LLM | **Ollama**, **OpenAI**, **Claude** (plugable backends) |
| Media processing | **FFmpeg** |
| Python services | **FastAPI** (spawned as a child process) |
---
## Quick Start ## Quick Start
### Prerequisites ### Prerequisites
- Node.js 18+ - **Node.js** 18+
- Python 3.10+ - **Python** 3.10+
- FFmpeg (in PATH) - **FFmpeg** (in PATH)
- (Optional) Ollama for local AI features - **Rust** toolchain (for Tauri)
- **Ollama** (optional, for local AI features)
### Install ### Install
@ -36,65 +68,89 @@ cd backend && pip install -r requirements.txt && cd ..
### Run (Development) ### Run (Development)
```bash ```bash
# Start Tauri dev environment (includes backend + frontend) # Start everything: backend + frontend + Tauri
npm run dev:tauri npm run dev:tauri
``` ```
Or run them separately: Or run components separately:
```bash ```bash
# Terminal 1: Backend # Terminal 1: Python backend
cd backend && python -m uvicorn main:app --reload --port 8642 npm run dev:backend
# Terminal 2: Frontend + Tauri # Terminal 2: Frontend + Tauri
cd frontend && cargo tauri dev cd frontend && cargo tauri dev
``` ```
### Build
```bash
npm run build:tauri
```
---
## Project Structure ## Project Structure
``` ```
talkedit/ talkedit/
├── src-tauri/ # Tauri Rust runtime ├── src-tauri/ # Tauri 2.0 Rust runtime
│ ├── Cargo.toml │ ├── Cargo.toml
│ ├── src/
│ │ ├── main.rs # App entry & backend spawner
│ │ └── commands/ # Tauri IPC handlers
├── frontend/ # React + Vite + Tailwind
│ └── src/ │ └── src/
│ ├── components/ # VideoPlayer, TranscriptEditor, etc. │ ├── main.rs # App entry, backend spawner
│ ├── store/ # Zustand state (editorStore, aiStore) │ ├── lib.rs # Command handlers (IPC bridge)
│ ├── lib/tauri-bridge.ts # Tauri API polyfill │ ├── transcription.rs # Whisper.cpp integration
── types/ # TypeScript interfaces ── video_editor.rs # FFmpeg-based editing
├── backend/ # FastAPI Python backend │ ├── caption_generator.rs
│ ├── diarization.rs
│ ├── ai_provider.rs # Ollama / OpenAI / Claude
│ ├── audio_cleaner.rs
│ ├── background_removal.rs
│ ├── licensing.rs # Trial + key activation
│ ├── models.rs # Shared data types
│ └── paths.rs
├── frontend/ # React + Vite + Tailwind
│ └── src/
│ ├── components/ # UI components
│ │ ├── TranscriptEditor.tsx
│ │ ├── WaveformTimeline.tsx
│ │ ├── VideoPlayer.tsx
│ │ ├── AIPanel.tsx
│ │ ├── ExportDialog.tsx
│ │ ├── SettingsPanel.tsx
│ │ ├── BackgroundMusicPanel.tsx
│ │ ├── MarkersPanel.tsx
│ │ ├── ZoneEditor.tsx
│ │ ├── SilenceTrimmerPanel.tsx
│ │ ├── AppendClipPanel.tsx
│ │ ├── LicenseDialog.tsx
│ │ └── DevPanel.tsx
│ ├── store/ # Zustand state (editorStore, aiStore, settingsStore)
│ ├── hooks/ # Custom React hooks
│ ├── lib/ # Utilities and Tauri bridge
│ └── types/ # TypeScript interfaces
├── backend/ # FastAPI Python services
│ ├── main.py │ ├── main.py
│ ├── routers/ # API endpoints │ ├── routers/ # API endpoints
├── services/ # Core logic (transcription, editing, AI) │ ├── transcribe.py
└── utils/ # GPU, cache, audio helpers │ ├── ai.py
└── shared/ # Project schema ├── audio.py
│ │ ├── captions.py
│ │ └── export.py
│ ├── services/ # Core logic
│ ├── video_editor.py
│ ├── caption_generator.py
│ ├── ai_provider.py
│ ├── diarization.py
│ ├── audio_cleaner.py
│ ├── background_removal.py
│ └── license_server.py
├── shared/ # Schema definitions (project format)
├── models/ # Whisper model storage
└── docs/ # Documentation
``` ```
## Features ---
| Feature | Status |
|---------|--------|
| Word-level transcription (WhisperX) | Done |
| Text-based video editing | Done |
| Undo/redo | Done |
| Waveform timeline | Done |
| FFmpeg stream-copy export | Done |
| FFmpeg re-encode (up to 4K) | Done |
| AI filler word removal | Done |
| AI clip creation (Shorts) | Done |
| Ollama + OpenAI + Claude | Done |
| Word-level captions (SRT/VTT/ASS) | Done |
| Caption burn-in on export | Done |
| Studio Sound (DeepFilterNet) | Done |
| Keyboard shortcuts (J/K/L) | Done |
| Speaker diarization | Done |
| Virtualized transcript (react-virtuoso) | Done |
| Encrypted API key storage | Done |
| Project save/load (.cutscript) | Done |
| AI background removal | Planned |
## Keyboard Shortcuts ## Keyboard Shortcuts
@ -102,28 +158,19 @@ talkedit/
|-----|--------| |-----|--------|
| Space | Play / Pause | | Space | Play / Pause |
| J / K / L | Reverse / Pause / Forward | | J / K / L | Reverse / Pause / Forward |
| I / O | Mark In / Mark Out |
| ← / → | Seek ±5 seconds | | ← / → | Seek ±5 seconds |
| Delete | Delete selected words | | Delete | Delete selected words or zones |
| Ctrl+Z | Undo | | Ctrl+Z | Undo |
| Ctrl+Shift+Z | Redo | | Ctrl+Shift+Z | Redo |
| Ctrl+S | Save project | | Ctrl+S | Save project |
| Ctrl+E | Export | | Ctrl+E | Export |
| Ctrl+F | Search transcript |
| Ctrl+Scroll | Zoom waveform |
| ? | Shortcut cheatsheet | | ? | Shortcut cheatsheet |
## API Endpoints ---
| Method | Endpoint | Description |
|--------|----------|-------------|
| GET | /health | Health check |
| POST | /transcribe | Transcribe video with WhisperX |
| POST | /export | Export edited video (stream copy or re-encode) |
| POST | /ai/filler-removal | Detect filler words via LLM |
| POST | /ai/create-clip | AI-suggested clips for shorts |
| GET | /ai/ollama-models | List local Ollama models |
| POST | /captions | Generate SRT/VTT/ASS captions |
| POST | /audio/clean | Noise reduction (DeepFilterNet) |
| GET | /audio/capabilities | Check audio processing availability |
## License ## License
MIT License — see [LICENSE](LICENSE) for details. Source code is MIT — see [LICENSE](LICENSE) for details. The distributed binary includes a 7-day free trial requiring a one-time license key purchase for continued use.

View File

@ -1,50 +1,62 @@
# TalkEdit — Tech Stack, Tools, and Planned Features # TalkEdit — Tech Stack, Tools, and Features
This document summarizes the chosen technology, tooling, the full planned feature set for the MVP, recommended additions, removals, and items to put on the back burner. This document summarizes the chosen technology, tooling, the full feature set, recommended additions, and items on the back burner.
## Overview ## Overview
- Goal: Offline, local text-based audio/video editor (Descript-style) focused on spoken-word creators (podcasters, YouTubers). Fast, privacy-first, single-file installer. - Goal: Offline, local text-based audio/video editor (Descript-style) focused on spoken-word creators (podcasters, YouTubers). Fast, privacy-first, single-file installer.
## Tech Stack ## Tech Stack
- Frontend: React + Vite + Tailwind CSS + shadcn/ui - Frontend: React 19 + Vite + TypeScript + Tailwind CSS + Zustand (with zundo undo/redo) + Virtuoso (virtualized transcript)
- Backend: Tauri 2.0 (Rust) for file I/O, invoking native binaries, and exposing commands to the UI - Backend: Tauri 2.0 (Rust) for file I/O, licensing, licensing crypto (Ed25519), model management, error logging
- Transcription: Whisper.cpp (Rust bindings like `whisper-rs` / `whisper-cpp-sys`) — word-level timestamps - Transcription: Python faster-whisper with WhisperX for word-level alignment. Models downloaded on demand.
- Audio/Video Processing: FFmpeg invoked from Rust (or `ffmpeg-next` Rust crate) - Audio/Video Processing: FFmpeg invoked from Rust via Python scripts (video_editor.py, audio_cleaner.py, caption_generator.py)
- State: Zustand (in-frontend store) - AI: Ollama, OpenAI, Claude through Python ai_provider.py. Bundled Qwen3 LLM planned.
- State: Zustand (in-frontend store) + zundo middleware for undo/redo history
- Packaging: Tauri `tauri build` for cross-platform installers - Packaging: Tauri `tauri build` for cross-platform installers
- Optional local tools: Ollama (optional local LLMs) for advanced on-device heuristics
## Developer Tools ## Developer Tools
- Rust toolchain (cargo, rustc) - Rust toolchain (cargo, rustc)
- Node.js + npm/yarn for frontend - Node.js + npm for frontend
- Python 3.11+ (faster-whisper, WhisperX, AI providers)
- FFmpeg binaries (platform-specific; bundled or downloaded at install) - FFmpeg binaries (platform-specific; bundled or downloaded at install)
- Build/test: Tauri CLI, Vite dev server - Build/test: Tauri CLI, Vite dev server
- Testing: Vitest (frontend), cargo test (Rust), pytest (Python)
- CI: GitHub Actions (Rust clippy/test, Frontend tsc/vitest, Python pytest)
## MVP Feature List (Planned) ## Implemented Features
1. Drag-and-drop import (audio/video auto audio-extract)
2. One-click local transcription (model selector: tiny/base → larger models) - [x] 1. Media import via file dialog (audio/video auto audio-extract)
3. Scrollable, Google-Doc-style transcript editor - [x] 2. One-click local transcription with model selector (tiny/base → larger models) and model-size chooser
- [x] 3. Scrollable, Google-Doc-style transcript editor (Virtuoso virtualized)
- Click word → seek video/audio - Click word → seek video/audio
- Highlight + Delete → remove corresponding media segment (smart 150250ms fades) - Select words → cut corresponding media segment (smart 150250ms fades)
4. One-click "Clean it" button - [x] 4. Smart Cleanup
- Remove fillers (configurable list) - Filler word removal (configurable list per-project)
- Remove long pauses (>0.8s) by default - Silence trimming
5. One-click audio polish chain (FFmpeg): normalize, light compression, basic noise reduction - [x] 5. Audio Polish chain (FFmpeg): normalize, compression, noise reduction
6. Preview with synced playback, undo/redo, project save/load - [x] 6. Preview with synced playback, undo/redo (zundo), project save/load
7. Export MP4/audio with optional SRT/VTT captions and burned-in captions - [x] 7. Export MP4/audio with SRT/VTT/ASS captions (speaker-labeled)
- [x] 8. Speaker diarization
- [x] 9. Custom filler lists per-project
- [x] 10. Background music with auto-ducking
- [x] 11. Append clips (concatenation)
- [x] 12. Settings: AI provider config (Ollama, OpenAI, Claude)
- [x] 13. Keyboard shortcuts with custom remapping
- [x] 14. Help panel + cheatsheet
- [x] 15. 7-day licensing with Ed25519-signed license keys
## Recommended Additions (near-term, high ROI) ## Recommended Additions (near-term, high ROI)
- Model-size chooser + progressive fallback (start fast, upgrade model later)
- Local GPU/CPU detection & recommended model/settings UI - [ ] Local GPU/CPU detection & recommended model/settings UI
- Per-project incremental transcription: re-run only edited segments - [ ] Per-project incremental transcription: re-run only edited segments
- "Preview cleaning" dry-run that highlights candidate removals before applying - [ ] "Preview cleaning" dry-run that highlights candidate removals before applying
- Export size/time estimator and suggested export presets - [ ] Export size/time estimator and suggested export presets
- Custom filler lists per-project and import/export of filler lists - [ ] Accessibility export presets (podcast vs YouTube presets)
- High-quality offline captions export (SRT + VTT + speaker labels) - [ ] Bundled Qwen3 LLM for offline AI features
- Accessibility export presets (podcast vs YouTube presets)
## Remove / Defer (Back Burner) ## Remove / Defer (Back Burner)
These broaden scope or add legal/privacy surface — defer for now. These broaden scope or add legal/privacy surface — defer for now.
- Voice cloning / TTS: DEFER - Voice cloning / TTS: DEFER
- Multi-track, full timeline NLE features: DEFER - Multi-track, full timeline NLE features: DEFER
- Real-time collaboration / cloud sync: DEFER - Real-time collaboration / cloud sync: DEFER
@ -52,18 +64,20 @@ These broaden scope or add legal/privacy surface — defer for now.
## Risks & Mitigations ## Risks & Mitigations
- Large model sizes: don't bundle large models; download on-demand and document storage location. - Large model sizes: don't bundle large models; download on-demand and document storage location.
- Timestamp accuracy: provide manual word-adjust UI and per-segment re-run. - Timestamp accuracy: WhisperX word-level alignment + manual per-segment re-run available.
- FFmpeg packaging/licensing: ship platform-specific binaries or use Tauri bundling guidance; document license compliance. - FFmpeg packaging/licensing: ship platform-specific binaries or use Tauri bundling guidance; document license compliance.
## Prioritized Quick Wins ## Prioritized Quick Wins
1. Model chooser UI + auto-fallback settings 1. Per-project incremental transcription
2. "Preview cleaning" dry-run UI 2. "Preview cleaning" dry-run UI
3. Per-project incremental transcription saving 3. Export presets (podcast vs YouTube)
## Next Steps for Implementation ## Next Steps for Implementation
- Add model chooser UI and capability detection early in the frontend iteration. - Bundle Qwen3 LLM for offline AI processing.
- Implement Rust transcription command and a compact API for incremental transcription. - Implement incremental transcription to speed up re-editing workflows.
- Implement FFmpeg polish templates and a minimal preview pipeline. - Add export presets and size estimation.
- Improve GPU/CPU detection and model recommendations.
--- ---
Generated as requested to capture tech, tools, planned features, and the recommended add/remove/defer list.
Generated to capture tech, tools, implemented features, and the recommended add/remove/defer list.

View File

@ -9,6 +9,8 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse from fastapi.responses import StreamingResponse
from pathlib import Path from pathlib import Path
from routers import audio
app = FastAPI(title="TalkEdit Dev Backend", version="0.0.1") app = FastAPI(title="TalkEdit Dev Backend", version="0.0.1")
app.add_middleware( app.add_middleware(
@ -34,6 +36,8 @@ MIME_MAP = {
} }
app.include_router(audio.router)
@app.get("/health") @app.get("/health")
async def health(): async def health():
return {"status": "ok"} return {"status": "ok"}

223
backend/license_server.py Normal file
View File

@ -0,0 +1,223 @@
#!/usr/bin/env python3
"""
TalkEdit License Server — Stripe webhook + license key generator.
Usage (development):
python backend/license_server.py
Then create a test license:
python backend/license_server.py generate --email test@example.com --tier pro
This is a minimal server. In production, deploy as a Cloudflare Worker,
Vercel function, or a small VPS behind nginx.
"""
import base64
import json
import os
import time
import hmac
import hashlib
from nacl.bindings import (
crypto_sign_seed_keypair,
crypto_sign,
crypto_sign_BYTES,
)
# === CONFIGURATION ===
# The Ed25519 private key (base64-encoded). Keep this secret!
# Generate with: python3 -c "import os,base64; print(base64.b64encode(os.urandom(32)).decode())"
LICENSE_PRIVATE_KEY_B64 = "ONTdT2Hn367fMlovqulz7WYQPQru7uFa/GaSfjhnR9x7Qoe7uBPNwIFeW4p7A0g05Qj14rvaQ4Mm1u/LzgeEsA=="
# Stripe webhook secret (set this in production)
STRIPE_WEBHOOK_SECRET = os.environ.get("STRIPE_WEBHOOK_SECRET", "")
# === TIER DEFINITIONS ===
TIERS = {
"pro": {
"price_id": "price_pro_monthly", # Replace with your Stripe price ID
"features": ["bundled_deps", "auto_updates", "priority_support"],
"max_activations": 1,
"duration_days": 365,
},
"business": {
"price_id": "price_business_monthly",
"features": ["bundled_deps", "auto_updates", "priority_support",
"white_label", "audit_logging", "bulk_deployment"],
"max_activations": 10,
"duration_days": 365,
},
}
def generate_license_key(
customer_email: str,
tier: str = "pro",
license_id: str = None,
duration_days: int = None,
features: list = None,
max_activations: int = None,
) -> str:
"""Generate a signed TalkEdit license key.
Returns a string like: talkedit_v1_<base64(payload)>.<base64(signature)>
"""
if license_id is None:
license_id = f"lic_{int(time.time())}_{os.urandom(4).hex()}"
tier_config = TIERS.get(tier, TIERS["pro"])
if duration_days is None:
duration_days = tier_config["duration_days"]
if features is None:
features = tier_config["features"]
if max_activations is None:
max_activations = tier_config["max_activations"]
now = int(time.time())
payload = {
"license_id": license_id,
"customer_email": customer_email,
"tier": tier,
"features": features,
"issued_at": now,
"expires_at": now + duration_days * 86400,
"max_activations": max_activations,
}
payload_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8")
# Sign with Ed25519
seed = base64.b64decode(LICENSE_PRIVATE_KEY_B64)
if len(seed) == 64:
seed = seed[:32] # First 32 bytes are the actual seed
pk, sk = crypto_sign_seed_keypair(seed)
signed = crypto_sign(payload_bytes, sk)
signature = signed[:crypto_sign_BYTES]
payload_b64 = base64.b64encode(payload_bytes).decode().rstrip("=")
sig_b64 = base64.b64encode(signature).decode().rstrip("=")
return f"talkedit_v1_{payload_b64}.{sig_b64}"
def verify_stripe_webhook(payload: bytes, sig_header: str) -> dict:
"""Verify Stripe webhook signature and return the event."""
if not STRIPE_WEBHOOK_SECRET:
raise ValueError("STRIPE_WEBHOOK_SECRET not configured")
# Stripe sends signature in the `stripe-signature` header
# Format: t=timestamp,v1=signature
parts = {}
for item in sig_header.split(","):
key, _, value = item.partition("=")
parts[key.strip()] = value.strip()
timestamp = parts.get("t", "")
expected_sig = parts.get("v1", "")
# Compute signature
signed_payload = f"{timestamp}.{payload.decode()}".encode()
computed_sig = hmac.new(
STRIPE_WEBHOOK_SECRET.encode(),
signed_payload,
hashlib.sha256,
).hexdigest()
if not hmac.compare_digest(computed_sig, expected_sig):
raise ValueError("Invalid webhook signature")
return json.loads(payload)
# === CLI ===
def main():
import sys
if len(sys.argv) > 1 and sys.argv[1] == "generate":
# CLI mode: generate a test license key
import argparse
parser = argparse.ArgumentParser(description="Generate TalkEdit license key")
parser.add_argument("--email", default="test@example.com")
parser.add_argument("--tier", default="pro", choices=["pro", "business"])
parser.add_argument("--days", type=int, default=None)
args = parser.parse_args(sys.argv[2:])
key = generate_license_key(
customer_email=args.email,
tier=args.tier,
duration_days=args.days,
)
print()
print("=== TALKEDIT LICENSE KEY ===")
print(key)
print()
print("Paste this into the TalkEdit app to activate.")
return
# Server mode
from http.server import HTTPServer, BaseHTTPRequestHandler
import urllib.parse
class LicenseHandler(BaseHTTPRequestHandler):
def do_POST(self):
path = urllib.parse.urlparse(self.path).path
if path == "/webhook/stripe":
content_length = int(self.headers.get("Content-Length", 0))
body = self.rfile.read(content_length)
sig_header = self.headers.get("Stripe-Signature", "")
try:
event = verify_stripe_webhook(body, sig_header)
event_type = event.get("type", "")
if event_type == "checkout.session.completed":
session = event["data"]["object"]
email = session.get("customer_email", session.get("customer_details", {}).get("email", "unknown"))
tier = "pro" # Map from session["metadata"]["tier"] or line items
license_key = generate_license_key(
customer_email=email,
tier=tier,
)
# In production: email the license key to the customer
print(f"License generated for {email}: {license_key[:40]}...")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.end_headers()
self.wfile.write(json.dumps({"status": "ok"}).encode())
else:
self.send_response(200)
self.end_headers()
except Exception as e:
print(f"Webhook error: {e}")
self.send_response(400)
self.end_headers()
self.wfile.write(str(e).encode())
else:
self.send_response(404)
self.end_headers()
def log_message(self, format, *args):
print(f"[license-server] {args}")
port = int(os.environ.get("PORT", 8643))
server = HTTPServer(("0.0.0.0", port), LicenseHandler)
print(f"License server listening on http://0.0.0.0:{port}")
print(f" POST /webhook/stripe - Stripe webhook")
print()
print("To generate a test license:")
print(f" python {__file__} generate --email you@example.com --tier pro")
server.serve_forever()
if __name__ == "__main__":
main()

View File

@ -0,0 +1,54 @@
"""Local LLM endpoints for bundled Qwen3 inference."""
import logging
from typing import Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from services.local_llm import get_status, download_model, complete
logger = logging.getLogger(__name__)
router = APIRouter()
class CompleteRequest(BaseModel):
prompt: str
model_id: str = "qwen3-1.7b"
system_prompt: Optional[str] = None
temperature: float = 0.3
max_tokens: int = 2048
@router.get("/local-llm/status")
async def llm_status():
try:
return get_status()
except Exception as e:
logger.error(f"Local LLM status failed: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/local-llm/download")
async def llm_download(model_id: str = "qwen3-1.7b"):
try:
return download_model(model_id)
except Exception as e:
logger.error(f"Local LLM download failed: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))
@router.post("/local-llm/complete")
async def llm_complete(req: CompleteRequest):
try:
result = complete(
prompt=req.prompt,
model_id=req.model_id,
system_prompt=req.system_prompt,
temperature=req.temperature,
max_tokens=req.max_tokens,
)
return {"response": result}
except Exception as e:
logger.error(f"Local LLM completion failed: {e}", exc_info=True)
raise HTTPException(status_code=500, detail=str(e))

View File

@ -175,30 +175,40 @@ def _remove_with_mediapipe(
raise RuntimeError(f"MediaPipe background removal failed: {e}") raise RuntimeError(f"MediaPipe background removal failed: {e}")
def _remove_with_ffmpeg_portrait( def _remove_with_ffmpeg_portrait(
input_path: str, input_path: str,
output_path: str, output_path: str,
replacement: str = "blur", replacement: str = "blur",
replacement_value: str = "", replacement_value: str = "",
) -> str: ) -> str:
"""Fallback: use FFmpeg's colorkey + chromakey for basic background removal. """Fallback: basic FFmpeg-only background blur.
This is a crude approximation. For best results, install mediapipe + opencv-python. Uses a strong gaussian blur as a crude background replacement.
For proper person segmentation (color/image replacement), install:
pip install mediapipe opencv-python
""" """
ffmpeg = "ffmpeg" ffmpeg = "ffmpeg"
# Use a simple chromakey-based approach with a neutral background if replacement == "blur":
# This won't work well for most real videos but provides a fallback filter_complex = "gblur=sigma=30"
if replacement == "color": elif replacement == "color":
color = replacement_value or "00FF00" color = replacement_value or "00FF00"
filter_complex = f"colorkey=0x{color}:0.3:0.1,chromakey=0x{color}:0.3:0.1" filter_complex = (
elif replacement == "blur": f"split[fg][bg];"
filter_complex = "gblur=sigma=20:enable='gt(scene,0.01)'" f"[bg]colorkey=0x{color}:0.3:0.1[bg_key];"
f"[fg][bg_key]overlay"
)
elif replacement == "image" and replacement_value:
escaped = replacement_value.replace("\\", "/").replace(":", "\\:")
filter_complex = (
f"movie='{escaped}':loop=0,scale=iw:ih[bg];"
f"[0:v][bg]overlay=0:0:shortest=1"
)
else: else:
filter_complex = "null" filter_complex = "null"
if filter_complex == "null": if filter_complex == "null":
# No-op, copy input to output
cmd = [ffmpeg, "-y", "-i", input_path, "-c", "copy", output_path] cmd = [ffmpeg, "-y", "-i", input_path, "-c", "copy", output_path]
else: else:
cmd = [ cmd = [
@ -215,5 +225,8 @@ def _remove_with_ffmpeg_portrait(
if result.returncode != 0: if result.returncode != 0:
raise RuntimeError(f"FFmpeg background removal failed: {result.stderr[-500:]}") raise RuntimeError(f"FFmpeg background removal failed: {result.stderr[-500:]}")
logger.info("FFmpeg portait background removal completed -> %s", output_path) logger.warning(
"FFmpeg fallback background removal used (no MediaPipe). "
"Install 'mediapipe' and 'opencv-python' for proper person segmentation."
)
return output_path return output_path

View File

@ -0,0 +1,125 @@
"""
Local LLM inference using llama.cpp via llama-cpp-python.
Handles model download from HuggingFace and text completion.
"""
import json
import logging
import os
import subprocess
import sys
from pathlib import Path
from typing import Optional
logger = logging.getLogger(__name__)
LOCAL_MODELS_DIR = Path.home() / ".cache" / "talkedit" / "models"
QWEN_MODELS = {
"qwen3-1.7b": {
"repo": "Qwen/Qwen3-1.7B-Instruct-GGUF",
"file": "qwen3-1.7b-instruct-q4_k_m.gguf",
"size_gb": 1.0,
},
"qwen3-4b": {
"repo": "Qwen/Qwen3-4B-Instruct-GGUF",
"file": "qwen3-4b-instruct-q4_k_m.gguf",
"size_gb": 2.5,
},
}
def _ensure_llama_cpp() -> bool:
try:
from llama_cpp import Llama
return True
except ImportError:
return False
def _model_path(model_id: str) -> Path:
info = QWEN_MODELS.get(model_id)
if not info:
raise ValueError(f"Unknown model: {model_id}")
return LOCAL_MODELS_DIR / model_id / info["file"]
def get_status() -> dict:
"""Check status of local LLM setup."""
llama_available = _ensure_llama_cpp()
models = {}
for model_id in QWEN_MODELS:
path = _model_path(model_id)
models[model_id] = {
"downloaded": path.exists(),
"size_bytes": path.stat().st_size if path.exists() else 0,
"total_gb": QWEN_MODELS[model_id]["size_gb"],
}
return {
"llama_cpp_available": llama_available,
"models": models,
"models_dir": str(LOCAL_MODELS_DIR),
}
def download_model(model_id: str) -> dict:
"""Download a Qwen3 GGUF model from HuggingFace."""
info = QWEN_MODELS.get(model_id)
if not info:
raise ValueError(f"Unknown model: {model_id}")
model_dir = LOCAL_MODELS_DIR / model_id
model_dir.mkdir(parents=True, exist_ok=True)
output_path = model_dir / info["file"]
if output_path.exists():
return {"status": "already_downloaded", "path": str(output_path)}
logger.info(f"Downloading {info['repo']}/{info['file']} ({info['size_gb']} GB)...")
subprocess.run([
sys.executable, "-m", "huggingface_hub", "download",
info["repo"], info["file"],
"--local-dir", str(model_dir),
"--local-dir-use-symlinks", "False",
], check=True)
if not output_path.exists():
raise RuntimeError(f"Download failed: {output_path} not found")
return {"status": "downloaded", "path": str(output_path)}
def complete(
prompt: str,
model_id: str = "qwen3-1.7b",
system_prompt: Optional[str] = None,
temperature: float = 0.3,
max_tokens: int = 2048,
) -> str:
"""Run inference using a local Qwen3 model."""
model_path = _model_path(model_id)
if not model_path.exists():
raise RuntimeError(f"Model not downloaded: {model_id}")
from llama_cpp import Llama
llm = Llama(
model_path=str(model_path),
n_ctx=4096,
n_threads=4,
n_gpu_layers=-1,
verbose=False,
)
messages = []
if system_prompt:
messages.append({"role": "system", "content": system_prompt})
messages.append({"role": "user", "content": prompt})
response = llm.create_chat_completion(
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
)
return response["choices"][0]["message"]["content"].strip()

View File

@ -45,6 +45,24 @@ def _input_has_video_stream(ffmpeg_cmd: str, input_path: str) -> bool:
return False return False
def _input_has_audio_stream(ffmpeg_cmd: str, input_path: str) -> bool:
"""Return True if the input contains at least one audio stream."""
ffprobe = ffmpeg_cmd.replace("ffmpeg", "ffprobe")
cmd = [
ffprobe,
"-v", "error",
"-select_streams", "a:0",
"-show_entries", "stream=index",
"-of", "csv=p=0",
str(input_path),
]
try:
result = subprocess.run(cmd, capture_output=True, text=True)
return result.returncode == 0 and bool(result.stdout.strip())
except Exception:
return False
def _clamp_speed(speed: float) -> float: def _clamp_speed(speed: float) -> float:
return max(0.25, min(4.0, float(speed))) return max(0.25, min(4.0, float(speed)))
@ -144,39 +162,65 @@ def mix_background_music(
ducking_release_ms: float = 200.0, ducking_release_ms: float = 200.0,
) -> str: ) -> str:
"""Mix background music into a video with optional ducking. """Mix background music into a video with optional ducking.
Uses FFmpeg amix + sidechaincompress. Output is written to output_path. Uses FFmpeg amix + sidechaincompress. If the input has no audio,
the music track becomes the sole audio track. Output is written to output_path.
""" """
ffmpeg = _find_ffmpeg() ffmpeg = _find_ffmpeg()
escaped_music = music_path.replace("\\", "/").replace(":", "\\:") escaped_music = music_path.replace("\\", "/").replace(":", "\\:")
has_audio_result = _input_has_audio_stream(ffmpeg, video_path)
# Build the filter graph
if ducking_enabled: if not has_audio_result:
cmd = [
ffmpeg, "-y",
"-i", video_path,
"-i", music_path,
"-map", "0:v",
"-map", "1:a",
"-c:v", "copy",
"-c:a", "aac", "-b:a", "192k",
"-shortest",
"-movflags", "+faststart",
output_path,
]
elif ducking_enabled:
music_source = f"amovie='{escaped_music}',volume={volume_db}dB[music]"
filter_complex = ( filter_complex = (
f"[0:a]asplit[main][sidechain];" f"[0:a]asplit[main][sidechain];"
f"movie='{escaped_music}':loop=0,volume={volume_db}dB[music];" f"{music_source};"
f"[main][music]amix=inputs=2:duration=first:dropout_transition=2[mixed];" f"[main][music]amix=inputs=2:duration=first:dropout_transition=2[mixed];"
f"[mixed][sidechain]sidechaincompress=" f"[mixed][sidechain]sidechaincompress="
f"threshold=-30dB:ratio=100:attack={ducking_attack_ms}ms:" f"threshold=-30dB:ratio=20:attack={ducking_attack_ms / 1000}:"
f"release={ducking_release_ms}ms:makeup=1:level_sc={ducking_db}[outa]" f"release={ducking_release_ms / 1000}:makeup=1:level_sc={ducking_db}[outa]"
) )
cmd = [
ffmpeg, "-y",
"-i", video_path,
"-filter_complex", filter_complex,
"-map", "0:v",
"-map", "[outa]",
"-c:v", "copy",
"-c:a", "aac", "-b:a", "192k",
"-shortest",
output_path,
]
else: else:
music_source = f"amovie='{escaped_music}',volume={volume_db}dB[music]"
filter_complex = ( filter_complex = (
f"movie='{escaped_music}':loop=0,volume={volume_db}dB[music];" f"{music_source};"
f"[0:a][music]amix=inputs=2:duration=first:dropout_transition=2[outa]" f"[0:a][music]amix=inputs=2:duration=first:dropout_transition=2[outa]"
) )
cmd = [
cmd = [ ffmpeg, "-y",
ffmpeg, "-y", "-i", video_path,
"-i", video_path, "-filter_complex", filter_complex,
"-filter_complex", filter_complex, "-map", "0:v",
"-map", "0:v", "-map", "[outa]",
"-map", "[outa]", "-c:v", "copy",
"-c:v", "copy", "-c:a", "aac", "-b:a", "192k",
"-c:a", "aac", "-b:a", "192k", "-shortest",
"-shortest", output_path,
output_path, ]
]
result = subprocess.run(cmd, capture_output=True, text=True) result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0: if result.returncode != 0:
@ -191,28 +235,29 @@ def concat_clips(
output_path: str, output_path: str,
) -> str: ) -> str:
"""Concatenate multiple video clips using FFmpeg concat demuxer. """Concatenate multiple video clips using FFmpeg concat demuxer.
The main_path is kept as-is. append_paths are appended after it. The main_path is kept as-is. append_paths are appended after it.
""" """
if not append_paths: if not append_paths:
raise ValueError("No clips to concatenate") raise ValueError("No clips to concatenate")
ffmpeg = _find_ffmpeg() ffmpeg = _find_ffmpeg()
import tempfile resolved_main = str(Path(main_path).resolve())
import os
# If output_path collides with an input, write to temp first
all_inputs = [resolved_main] + [str(Path(p).resolve()) for p in append_paths]
needs_rename = str(Path(output_path).resolve()) in all_inputs
final_output = output_path
if needs_rename:
final_output = output_path + ".concat_tmp.mp4"
temp_dir = tempfile.mkdtemp(prefix="aive_concat_") temp_dir = tempfile.mkdtemp(prefix="aive_concat_")
try: try:
segment_files = [main_path]
segment_files.extend(append_paths)
# Create concat file list
concat_file = os.path.join(temp_dir, "concat.txt") concat_file = os.path.join(temp_dir, "concat.txt")
with open(concat_file, "w") as f: with open(concat_file, "w") as f:
for path in segment_files: for path in all_inputs:
resolved = os.path.abspath(path) f.write(f"file '{path}'\n")
f.write(f"file '{resolved}'\n")
cmd = [ cmd = [
ffmpeg, "-y", ffmpeg, "-y",
"-f", "concat", "-f", "concat",
@ -220,13 +265,16 @@ def concat_clips(
"-i", concat_file, "-i", concat_file,
"-c", "copy", "-c", "copy",
"-movflags", "+faststart", "-movflags", "+faststart",
output_path, final_output,
] ]
result = subprocess.run(cmd, capture_output=True, text=True) result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0: if result.returncode != 0:
raise RuntimeError(f"Clip concat failed: {result.stderr[-500:]}") raise RuntimeError(f"Clip concat failed: {result.stderr[-500:]}")
if needs_rename:
os.replace(final_output, output_path)
return output_path return output_path
finally: finally:
for f in os.listdir(temp_dir): for f in os.listdir(temp_dir):
@ -570,11 +618,9 @@ def export_reencode(
# Apply zoom post-processing if configured # Apply zoom post-processing if configured
if zoom_config and zoom_config.get("enabled") and has_video: if zoom_config and zoom_config.get("enabled") and has_video:
import tempfile as _tf
import os as _os
zoomed_path = output_path + ".zoomed.mp4" zoomed_path = output_path + ".zoomed.mp4"
_apply_zoom_post(output_path, zoomed_path, zoom_config) _apply_zoom_post(output_path, zoomed_path, zoom_config)
_os.replace(zoomed_path, output_path) os.replace(zoomed_path, output_path)
logger.info("Zoom/punch-in applied to %s (factor=%s)", output_path, zoom_config.get("zoomFactor", 1.0)) logger.info("Zoom/punch-in applied to %s (factor=%s)", output_path, zoom_config.get("zoomFactor", 1.0))
return output_path return output_path
@ -737,11 +783,9 @@ def export_reencode_with_subs(
# Apply zoom post-processing if configured # Apply zoom post-processing if configured
if zoom_config and zoom_config.get("enabled"): if zoom_config and zoom_config.get("enabled"):
import tempfile as _tf
import os as _os
zoomed_path = output_path + ".zoomed.mp4" zoomed_path = output_path + ".zoomed.mp4"
_apply_zoom_post(output_path, zoomed_path, zoom_config) _apply_zoom_post(output_path, zoomed_path, zoom_config)
_os.replace(zoomed_path, output_path) os.replace(zoomed_path, output_path)
logger.info("Zoom/punch-in applied to %s (factor=%s)", output_path, zoom_config.get("zoomFactor", 1.0)) logger.info("Zoom/punch-in applied to %s (factor=%s)", output_path, zoom_config.get("zoomFactor", 1.0))
return output_path return output_path

View File

@ -0,0 +1,44 @@
# Gitea Runner — Windows Laptop
Self-hosted runner registered as `windows-laptop` with label `windows-latest`.
## Setup
```powershell
# Download
Invoke-WebRequest -Uri "https://gitea.com/gitea/runner/releases/download/v1.0.1/gitea-runner-1.0.1-windows-amd64.exe" -OutFile "$env:USERPROFILE\gitea-runner-windows-amd64.exe"
# Register (Admin PowerShell)
.\gitea-runner-windows-amd64.exe register --instance http://143.244.157.110:3000 --token NS5LXzLzNOvPKD9Id4SrLQ09bReHOrn6T2c4EyGM --name windows-laptop --labels windows-latest --no-interactive
# Start (foreground)
.\gitea-runner-windows-amd64.exe daemon
# Install as Windows service (auto-starts on boot)
.\gitea-runner-windows-amd64.exe service install
```
## Logs
### Workflow job logs (step output)
Stored on the Gitea server (not locally). Download from:
`http://143.244.157.110:3000/<owner>/<repo>/actions/runs/<run_id>`
Click a job, then the **Download log** button at the top-right.
### Runner daemon logs (runner itself)
| Mode | Log location |
|---|---|
| Foreground (`daemon`) | PowerShell console stdout |
| Windows service (`service install`) | `%ProgramData%\gitea-runner\log\` or Windows Event Viewer → Windows Logs → Application |
## Diagnostics
If a CI job fails, download the full log from the Gitea Actions UI (as above), then search for the first error:
- **Rust**: look for `error[E...]`, `error: could not compile`, or `cargo test` failures
- **Python**: look for `FAILED`, `AssertionError`, or `ModuleNotFoundError`
The runner's own logs (`daemon` mode) will show which job it picked up, container lifecycle, and any infrastructure issues (disk full, Docker unavailable, etc.).

View File

@ -30,6 +30,7 @@
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0", "globals": "^17.5.0",
"jsdom": "^29.1.1",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"tailwindcss": "^3.4.0", "tailwindcss": "^3.4.0",
"typescript": "^5.7.0", "typescript": "^5.7.0",
@ -51,6 +52,57 @@
"url": "https://github.com/sponsors/sindresorhus" "url": "https://github.com/sponsors/sindresorhus"
} }
}, },
"node_modules/@asamuzakjp/css-color": {
"version": "5.1.11",
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@csstools/css-calc": "^3.2.0",
"@csstools/css-color-parser": "^4.1.0",
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/dom-selector": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
"integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/generational-cache": "^1.0.1",
"@asamuzakjp/nwsapi": "^2.3.9",
"bidi-js": "^1.0.3",
"css-tree": "^3.2.1",
"is-potential-custom-element-name": "^1.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/generational-cache": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/@asamuzakjp/nwsapi": {
"version": "2.3.9",
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
"dev": true,
"license": "MIT"
},
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
"version": "7.29.0", "version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@ -333,6 +385,159 @@
"node": ">=6.9.0" "node": ">=6.9.0"
} }
}, },
"node_modules/@bramus/specificity": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
"dev": true,
"license": "MIT",
"dependencies": {
"css-tree": "^3.0.0"
},
"bin": {
"specificity": "bin/cli.js"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
"integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@csstools/css-calc": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz",
"integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-color-parser": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz",
"integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"dependencies": {
"@csstools/color-helpers": "^6.0.2",
"@csstools/css-calc": "^3.2.0"
},
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-parser-algorithms": "^4.0.0",
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-parser-algorithms": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
},
"peerDependencies": {
"@csstools/css-tokenizer": "^4.0.0"
}
},
"node_modules/@csstools/css-syntax-patches-for-csstree": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz",
"integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT-0",
"peerDependencies": {
"css-tree": "^3.2.1"
},
"peerDependenciesMeta": {
"css-tree": {
"optional": true
}
}
},
"node_modules/@csstools/css-tokenizer": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
"dev": true,
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/csstools"
},
{
"type": "opencollective",
"url": "https://opencollective.com/csstools"
}
],
"license": "MIT",
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/@esbuild/aix-ppc64": { "node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12", "version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@ -932,6 +1137,24 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
} }
}, },
"node_modules/@exodus/bytes": {
"version": "1.15.0",
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz",
"integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
},
"peerDependencies": {
"@noble/hashes": "^1.8.0 || ^2.0.0"
},
"peerDependenciesMeta": {
"@noble/hashes": {
"optional": true
}
}
},
"node_modules/@humanfs/core": { "node_modules/@humanfs/core": {
"version": "0.19.1", "version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@ -2365,6 +2588,16 @@
"node": ">=6.0.0" "node": ">=6.0.0"
} }
}, },
"node_modules/bidi-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
"dev": true,
"license": "MIT",
"dependencies": {
"require-from-string": "^2.0.2"
}
},
"node_modules/binary-extensions": { "node_modules/binary-extensions": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@ -2601,6 +2834,20 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/css-tree": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.27.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/cssesc": { "node_modules/cssesc": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@ -2621,6 +2868,20 @@
"devOptional": true, "devOptional": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/data-urls": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
"dev": true,
"license": "MIT",
"dependencies": {
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/debug": { "node_modules/debug": {
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
@ -2639,6 +2900,13 @@
} }
} }
}, },
"node_modules/decimal.js": {
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"license": "MIT"
},
"node_modules/deep-is": { "node_modules/deep-is": {
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@ -2667,6 +2935,19 @@
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
"node_modules/entities": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
}
},
"node_modules/es-module-lexer": { "node_modules/es-module-lexer": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
@ -3183,6 +3464,19 @@
"hermes-estree": "0.25.1" "hermes-estree": "0.25.1"
} }
}, },
"node_modules/html-encoding-sniffer": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.6.0"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@ -3282,6 +3576,13 @@
"node": ">=0.12.0" "node": ">=0.12.0"
} }
}, },
"node_modules/is-potential-custom-element-name": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"license": "MIT"
},
"node_modules/isexe": { "node_modules/isexe": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
@ -3319,6 +3620,57 @@
"js-yaml": "bin/js-yaml.js" "js-yaml": "bin/js-yaml.js"
} }
}, },
"node_modules/jsdom": {
"version": "29.1.1",
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@asamuzakjp/css-color": "^5.1.11",
"@asamuzakjp/dom-selector": "^7.1.1",
"@bramus/specificity": "^2.4.2",
"@csstools/css-syntax-patches-for-csstree": "^1.1.3",
"@exodus/bytes": "^1.15.0",
"css-tree": "^3.2.1",
"data-urls": "^7.0.0",
"decimal.js": "^10.6.0",
"html-encoding-sniffer": "^6.0.0",
"is-potential-custom-element-name": "^1.0.1",
"lru-cache": "^11.3.5",
"parse5": "^8.0.1",
"saxes": "^6.0.0",
"symbol-tree": "^3.2.4",
"tough-cookie": "^6.0.1",
"undici": "^7.25.0",
"w3c-xmlserializer": "^5.0.0",
"webidl-conversions": "^8.0.1",
"whatwg-mimetype": "^5.0.0",
"whatwg-url": "^16.0.1",
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
},
"peerDependencies": {
"canvas": "^3.0.0"
},
"peerDependenciesMeta": {
"canvas": {
"optional": true
}
}
},
"node_modules/jsdom/node_modules/lru-cache": {
"version": "11.3.6",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz",
"integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==",
"dev": true,
"license": "BlueOak-1.0.0",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/jsesc": { "node_modules/jsesc": {
"version": "3.1.0", "version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@ -3462,6 +3814,13 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/mdn-data": {
"version": "2.27.1",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/merge2": { "node_modules/merge2": {
"version": "1.4.1", "version": "1.4.1",
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
@ -3655,6 +4014,19 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/parse5": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
"dev": true,
"license": "MIT",
"dependencies": {
"entities": "^8.0.0"
},
"funding": {
"url": "https://github.com/inikulin/parse5?sponsor=1"
}
},
"node_modules/path-exists": { "node_modules/path-exists": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@ -3997,6 +4369,16 @@
"node": ">=8.10.0" "node": ">=8.10.0"
} }
}, },
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.11", "version": "1.22.11",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
@ -4108,6 +4490,19 @@
"queue-microtask": "^1.2.2" "queue-microtask": "^1.2.2"
} }
}, },
"node_modules/saxes": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"dependencies": {
"xmlchars": "^2.2.0"
},
"engines": {
"node": ">=v12.22.7"
}
},
"node_modules/scheduler": { "node_modules/scheduler": {
"version": "0.27.0", "version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@ -4240,6 +4635,13 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/symbol-tree": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true,
"license": "MIT"
},
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "3.4.19", "version": "3.4.19",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
@ -4376,6 +4778,26 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/tldts": {
"version": "7.0.30",
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz",
"integrity": "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==",
"dev": true,
"license": "MIT",
"dependencies": {
"tldts-core": "^7.0.30"
},
"bin": {
"tldts": "bin/cli.js"
}
},
"node_modules/tldts-core": {
"version": "7.0.30",
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.30.tgz",
"integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==",
"dev": true,
"license": "MIT"
},
"node_modules/to-regex-range": { "node_modules/to-regex-range": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
@ -4389,6 +4811,32 @@
"node": ">=8.0" "node": ">=8.0"
} }
}, },
"node_modules/tough-cookie": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
"integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
"dev": true,
"license": "BSD-3-Clause",
"dependencies": {
"tldts": "^7.0.5"
},
"engines": {
"node": ">=16"
}
},
"node_modules/tr46": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
"dev": true,
"license": "MIT",
"dependencies": {
"punycode": "^2.3.1"
},
"engines": {
"node": ">=20"
}
},
"node_modules/ts-api-utils": { "node_modules/ts-api-utils": {
"version": "2.5.0", "version": "2.5.0",
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
@ -4460,6 +4908,16 @@
"typescript": ">=4.8.4 <6.1.0" "typescript": ">=4.8.4 <6.1.0"
} }
}, },
"node_modules/undici": {
"version": "7.25.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.18.1"
}
},
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@ -4717,12 +5175,60 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/w3c-xmlserializer": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"dev": true,
"license": "MIT",
"dependencies": {
"xml-name-validator": "^5.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/wavesurfer.js": { "node_modules/wavesurfer.js": {
"version": "7.12.1", "version": "7.12.1",
"resolved": "https://registry.npmjs.org/wavesurfer.js/-/wavesurfer.js-7.12.1.tgz", "resolved": "https://registry.npmjs.org/wavesurfer.js/-/wavesurfer.js-7.12.1.tgz",
"integrity": "sha512-NswPjVHxk0Q1F/VMRemCPUzSojjuHHisQrBqQiRXg7MVbe3f5vQ6r0rTTXA/a/neC/4hnOEC4YpXca4LpH0SUg==", "integrity": "sha512-NswPjVHxk0Q1F/VMRemCPUzSojjuHHisQrBqQiRXg7MVbe3f5vQ6r0rTTXA/a/neC/4hnOEC4YpXca4LpH0SUg==",
"license": "BSD-3-Clause" "license": "BSD-3-Clause"
}, },
"node_modules/webidl-conversions": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
"dev": true,
"license": "BSD-2-Clause",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-mimetype": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20"
}
},
"node_modules/whatwg-url": {
"version": "16.0.1",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@exodus/bytes": "^1.11.0",
"tr46": "^6.0.0",
"webidl-conversions": "^8.0.1"
},
"engines": {
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
}
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@ -4766,6 +5272,23 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/xml-name-validator": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18"
}
},
"node_modules/xmlchars": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT"
},
"node_modules/yallist": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@ -33,6 +33,7 @@
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0", "globals": "^17.5.0",
"jsdom": "^29.1.1",
"postcss": "^8.4.49", "postcss": "^8.4.49",
"tailwindcss": "^3.4.0", "tailwindcss": "^3.4.0",
"typescript": "^5.7.0", "typescript": "^5.7.0",

View File

@ -12,9 +12,11 @@ import SilenceTrimmerPanel from './components/SilenceTrimmerPanel';
import ZoneEditor from './components/ZoneEditor'; import ZoneEditor from './components/ZoneEditor';
import BackgroundMusicPanel from './components/BackgroundMusicPanel'; import BackgroundMusicPanel from './components/BackgroundMusicPanel';
import AppendClipPanel from './components/AppendClipPanel'; import AppendClipPanel from './components/AppendClipPanel';
import LicenseDialog from './components/LicenseDialog';
import HelpContent from './components/HelpContent';
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'; import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
import { useLicenseStore } from './store/licenseStore';
import { import {
Film,
FolderOpen, FolderOpen,
Settings, Settings,
Sparkles, Sparkles,
@ -31,44 +33,48 @@ import {
MapPin, MapPin,
Music, Music,
ListVideo, ListVideo,
Clock,
AlertTriangle,
HelpCircle,
} from 'lucide-react'; } from 'lucide-react';
const LAST_MEDIA_PATH_KEY = 'talkedit:lastMediaPath'; const LAST_MEDIA_PATH_KEY = 'talkedit:lastMediaPath';
type Panel = 'ai' | 'settings' | 'export' | 'silence' | 'zones' | 'markers' | 'music' | 'append' | null; type Panel = 'ai' | 'settings' | 'export' | 'silence' | 'zones' | 'markers' | 'music' | 'append' | 'help' | null;
export default function App() { export default function App() {
const { const {
projectFilePath, projectFilePath,
videoPath, videoPath,
exportedAudioPath, exportedAudioPath,
words, words,
segments, segments,
cutRanges, cutRanges,
muteRanges, muteRanges,
gainRanges, gainRanges,
speedRanges, speedRanges,
globalGainDb, globalGainDb,
silenceTrimGroups, silenceTrimGroups,
transcriptionModel, transcriptionModel,
language, language,
isTranscribing, isTranscribing,
transcriptionStatus, transcriptionStatus,
markInTime, markInTime,
markOutTime, markOutTime,
loadVideo, loadVideo,
setProjectFilePath, setProjectFilePath,
setBackendUrl, setBackendUrl,
clearMarkRange, clearMarkRange,
setTranscription, setTranscription,
setTranscriptionModel, setTranscriptionModel,
setTranscribing, setTranscribing,
selectedWordIndices, selectedWordIndices,
addCutRange, addCutRange,
addMuteRange, addMuteRange,
addGainRange, addGainRange,
addSpeedRange, addSpeedRange,
} = useEditorStore(); backendUrl,
} = useEditorStore();
const [activePanel, setActivePanel] = useState<Panel>(null); const [activePanel, setActivePanel] = useState<Panel>(null);
const [projectName, setProjectName] = useState<string | null>(null); const [projectName, setProjectName] = useState<string | null>(null);
@ -132,6 +138,14 @@ export default function App() {
const [showUnsavedPrompt, setShowUnsavedPrompt] = useState(false); const [showUnsavedPrompt, setShowUnsavedPrompt] = useState(false);
const [pendingProceedAction, setPendingProceedAction] = useState<(() => Promise<void>) | null>(null); const [pendingProceedAction, setPendingProceedAction] = useState<(() => Promise<void>) | null>(null);
const [lastSavedSignature, setLastSavedSignature] = useState<string | null>(null); const [lastSavedSignature, setLastSavedSignature] = useState<string | null>(null);
const [showFileMenu, setShowFileMenu] = useState(false);
const [showRecoveryDialog, setShowRecoveryDialog] = useState(false);
const [recoveryData, setRecoveryData] = useState<any>(null);
const [recoveryMinutesAgo, setRecoveryMinutesAgo] = useState(0);
const [backendDown, setBackendDown] = useState(false);
const canEdit = useLicenseStore((s) => s.canEdit);
const licenseStatus = useLicenseStore((s) => s.status);
const setShowLicenseDialog = useLicenseStore((s) => s.setShowDialog);
const projectSignature = useMemo(() => { const projectSignature = useMemo(() => {
if (!videoPath) return null; if (!videoPath) return null;
@ -167,7 +181,10 @@ export default function App() {
const hasUnsavedChanges = Boolean(projectSignature) && projectSignature !== lastSavedSignature; const hasUnsavedChanges = Boolean(projectSignature) && projectSignature !== lastSavedSignature;
const loadProjectFromData = (data: any) => { const loadProjectFromData = (data: any) => {
useEditorStore.getState().loadProject(data); const removedCount = useEditorStore.getState().loadProject(data);
if (removedCount > 0) {
window.alert(`${removedCount} invalid zones were removed from the loaded project.`);
}
const loadedSignature = JSON.stringify({ const loadedSignature = JSON.stringify({
videoPath: data.videoPath, videoPath: data.videoPath,
exportedAudioPath: data.exportedAudioPath ?? null, exportedAudioPath: data.exportedAudioPath ?? null,
@ -196,7 +213,23 @@ export default function App() {
useKeyboardShortcuts(); useKeyboardShortcuts();
// Handle Escape key to exit timeline zone modes useEffect(() => {
useLicenseStore.getState().checkStatus();
window.electronAPI?.readAutosave().then((data) => {
if (data) {
try {
const parsed = JSON.parse(data);
const savedAt = parsed.savedAt;
const minutesAgo = savedAt ? Math.round((Date.now() - savedAt) / 60000) : 0;
setRecoveryData(parsed);
setRecoveryMinutesAgo(minutesAgo);
setShowRecoveryDialog(true);
} catch {}
}
});
}, []);
// Handle Escape key to exit timeline zone modes and close menus
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
@ -204,9 +237,9 @@ export default function App() {
setMuteMode(false); setMuteMode(false);
setGainMode(false); setGainMode(false);
setSpeedMode(false); setSpeedMode(false);
setShowFileMenu(false);
} }
}; };
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, []); }, []);
@ -231,6 +264,36 @@ export default function App() {
sessionStorage.removeItem(LAST_MEDIA_PATH_KEY); sessionStorage.removeItem(LAST_MEDIA_PATH_KEY);
}, [videoPath]); }, [videoPath]);
useEffect(() => {
if (!videoPath) return;
const interval = setInterval(() => {
const state = useEditorStore.getState();
const hasData = state.words.length > 0 || state.cutRanges.length > 0 || state.muteRanges.length > 0 || state.gainRanges.length > 0 || state.speedRanges.length > 0;
if (!hasData) return;
const autosaveData = {
savedAt: Date.now(),
videoPath: state.videoPath,
words: state.words,
segments: state.segments,
cutRanges: state.cutRanges,
muteRanges: state.muteRanges,
gainRanges: state.gainRanges,
speedRanges: state.speedRanges,
globalGainDb: state.globalGainDb,
silenceTrimGroups: state.silenceTrimGroups,
transcriptionModel: state.transcriptionModel,
language: state.language,
markInTime: state.markInTime,
markOutTime: state.markOutTime,
timelineMarkers: state.timelineMarkers,
};
window.electronAPI.writeAutosave(JSON.stringify(autosaveData));
}, 60000);
return () => clearInterval(interval);
}, [videoPath]);
const handleLoadProject = async () => { const handleLoadProject = async () => {
await runGuarded(async () => { await runGuarded(async () => {
try { try {
@ -287,7 +350,9 @@ export default function App() {
setProjectFilePath(null); setProjectFilePath(null);
setProjectName(null); setProjectName(null);
loadVideo(path); loadVideo(path);
await transcribeVideo(path); if (canEdit) {
await transcribeVideo(path);
}
} }
}); });
}; };
@ -385,6 +450,26 @@ export default function App() {
setPendingProceedAction(null); setPendingProceedAction(null);
}; };
const handleRecoverAutosave = () => {
if (!recoveryData) return;
const data = recoveryData;
const removedCount = useEditorStore.getState().loadProject(data);
if (removedCount > 0) {
window.alert(`${removedCount} invalid zones were removed from the loaded project.`);
}
if (data.markInTime != null) useEditorStore.getState().setMarkInTime(data.markInTime);
if (data.markOutTime != null) useEditorStore.getState().setMarkOutTime(data.markOutTime);
window.electronAPI.deleteAutosave();
setShowRecoveryDialog(false);
setRecoveryData(null);
};
const handleDismissRecovery = () => {
window.electronAPI.deleteAutosave();
setShowRecoveryDialog(false);
setRecoveryData(null);
};
const togglePanel = (panel: Panel) => { const togglePanel = (panel: Panel) => {
setActivePanel((prev) => (prev === panel ? null : panel)); setActivePanel((prev) => (prev === panel ? null : panel));
}; };
@ -493,234 +578,161 @@ export default function App() {
} }
}; };
if (!videoPath) { // Health check timer
return ( useEffect(() => {
<div className="h-screen flex flex-col items-center justify-center gap-8 bg-editor-bg px-6"> const checkHealth = async () => {
<div className="flex flex-col items-center gap-3"> try {
<Film className="w-14 h-14 text-editor-accent opacity-80" /> const res = await fetch(`${backendUrl}/health`);
<h1 className="text-3xl font-semibold tracking-tight">TalkEdit</h1> setBackendDown(!res.ok);
<p className="text-editor-text-muted text-sm max-w-sm text-center"> } catch {
Offline AI-powered video editor. setBackendDown(true);
</p> }
</div> };
checkHealth();
{/* Whisper model selector */} const interval = setInterval(checkHealth, 30000);
<div className="flex items-center gap-3"> return () => clearInterval(interval);
<label className="text-xs text-editor-text-muted whitespace-nowrap">Model:</label> }, [videoPath, backendUrl]);
<select
value={whisperModel}
onChange={(e) => setWhisperModel(e.target.value)}
className="px-3 py-1.5 bg-editor-surface border border-editor-border rounded-lg text-xs text-black focus:outline-none focus:border-editor-accent"
>
<optgroup label="Multilingual (any language)">
<option value="tiny">tiny ~75 MB · fastest, low accuracy</option>
<option value="base">base ~140 MB · fast, decent accuracy</option>
<option value="small">small ~460 MB · good balance</option>
<option value="medium">medium ~1.5 GB · better accuracy</option>
<option value="large-v2">large-v2 ~2.9 GB · high accuracy</option>
<option value="large-v3">large-v3 ~2.9 GB · best overall </option>
<option value="large-v3-turbo">large-v3-turbo ~1.6 GB · fast + accurate </option>
<option value="distil-large-v3">distil-large-v3 ~1.5 GB · fast, near large-v3 quality</option>
</optgroup>
<optgroup label="English-only (faster &amp; more accurate for English)">
<option value="tiny.en">tiny.en ~75 MB · fastest English</option>
<option value="base.en">base.en ~140 MB · fast English</option>
<option value="small.en">small.en ~460 MB · good English</option>
<option value="medium.en">medium.en ~1.5 GB · great English</option>
<option value="distil-small.en">distil-small.en ~190 MB · fast English </option>
<option value="distil-medium.en">distil-medium.en ~750 MB · best fast English </option>
</optgroup>
</select>
</div>
<p className="text-[11px] text-editor-text-muted text-center max-w-sm">
For noisy/YouTube videos use <span className="text-white">large-v3</span> or <span className="text-white">large-v3-turbo</span>.
English-only models are ~10% faster and more accurate for English content.
</p>
<div className="flex flex-col items-center gap-3">
<button
onClick={handleOpenFile}
className="flex items-center gap-2 px-6 py-3 bg-editor-accent hover:bg-editor-accent-hover rounded-lg text-white font-medium transition-colors"
>
<FolderOpen className="w-5 h-5" />
Open Video File
</button>
<button
onClick={handleLoadProject}
className="flex items-center gap-2 px-4 py-2 text-sm text-editor-text-muted hover:text-editor-text hover:bg-editor-surface rounded-lg transition-colors"
>
<FileInput className="w-4 h-4" />
Load Project (.aive)
</button>
</div>
</div>
);
}
return ( return (
<div className="h-screen flex flex-col bg-editor-bg overflow-hidden"> <div className="h-screen flex flex-col bg-editor-bg overflow-hidden">
{/* Top bar */} {/* Top bar */}
<header className="h-12 flex items-center px-4 border-b border-editor-border shrink-0"> <header className="h-12 flex items-center px-4 border-b border-editor-border shrink-0">
<div className="flex items-center gap-0.5"> <div className="flex items-center gap-0.5">
<ToolbarButton <div className="relative">
icon={<FilePlus2 className="w-4 h-4" />}
label="New"
onClick={handleNewProject}
/>
<ToolbarButton
icon={<FolderOpen className="w-4 h-4" />}
label="Open"
onClick={handleOpenFile}
/>
<ToolbarButton
icon={<Save className="w-4 h-4" />}
label="Save"
onClick={handleSaveProject}
disabled={words.length === 0}
/>
<ToolbarButton
icon={<Save className="w-4 h-4" />}
label="Save As"
onClick={handleSaveProjectAs}
disabled={words.length === 0}
/>
<ToolbarButton
icon={<FileInput className="w-4 h-4" />}
label="Load"
onClick={handleLoadProject}
/>
<ToolbarButton
icon={<Scissors className="w-4 h-4" />}
label="Cut"
onClick={handleCut}
active={cutMode}
/>
<ToolbarButton
icon={<VolumeX className="w-4 h-4" />}
label="Mute"
onClick={handleMute}
active={muteMode}
/>
<div className="flex items-center gap-1">
<ToolbarButton <ToolbarButton
icon={<SlidersHorizontal className="w-4 h-4" />} icon={<FolderOpen className="w-4 h-4" />}
label="Gain Zone" label="File"
onClick={handleGain} onClick={() => setShowFileMenu((p) => !p)}
active={gainMode} active={showFileMenu}
/>
<input
type="number"
min={-24}
max={24}
step={0.5}
value={gainModeDb}
onChange={(e) => setGainModeDb(Math.max(-24, Math.min(24, Number(e.target.value) || 0)))}
className="w-16 px-1.5 py-1 text-xs bg-editor-surface border border-editor-border rounded text-editor-text focus:outline-none focus:border-editor-accent"
title="Gain dB for new gain zones"
/> />
{showFileMenu && (
<>
<div className="fixed inset-0 z-40" onClick={() => setShowFileMenu(false)} />
<div className="absolute left-0 top-full mt-1 z-50 w-44 rounded-lg border border-editor-border bg-editor-surface shadow-xl py-1">
<DropdownItem icon={<FilePlus2 className="w-4 h-4" />} label="New Project" title="Start a new empty project" onClick={() => { setShowFileMenu(false); handleNewProject(); }} />
<DropdownItem icon={<FolderOpen className="w-4 h-4" />} label="Open File" title="Open a video or audio file for transcription" onClick={() => { setShowFileMenu(false); handleOpenFile(); }} />
<DropdownItem icon={<FileInput className="w-4 h-4" />} label="Load Project" title="Open a saved .aive project file" onClick={() => { setShowFileMenu(false); handleLoadProject(); }} />
<div className="border-t border-editor-border my-1" />
<DropdownItem icon={<Save className="w-4 h-4" />} label="Save" title="Save current project" onClick={() => { setShowFileMenu(false); handleSaveProject(); }} disabled={words.length === 0} />
<DropdownItem icon={<Save className="w-4 h-4" />} label="Save As" title="Save a copy of the current project" onClick={() => { setShowFileMenu(false); handleSaveProjectAs(); }} disabled={words.length === 0} />
</div>
</>
)}
</div> </div>
<div className="flex items-center gap-1">
<ToolbarButton <ToolbarButton
icon={<Gauge className="w-4 h-4" />} icon={<Scissors className="w-4 h-4" />}
label="Speed Zone" label="Cut"
onClick={handleSpeed} onClick={handleCut}
active={speedMode} active={cutMode}
disabled={!canEdit}
title="Cut selected word range or mark in/out area — removes the segment from output"
/> />
<input <ToolbarButton
type="number" icon={<VolumeX className="w-4 h-4" />}
min={0.25} label="Mute"
max={4} onClick={handleMute}
step={0.05} active={muteMode}
value={speedModeValue} disabled={!canEdit}
onChange={(e) => setSpeedModeValue(Math.max(0.25, Math.min(4, Number(e.target.value) || 1)))} title="Mute selected word range or mark in/out area — silences audio, keeps video"
className="w-16 px-1.5 py-1 text-xs bg-editor-surface border border-editor-border rounded text-editor-text focus:outline-none focus:border-editor-accent"
title="Playback rate for new speed zones"
/> />
</div> <div className="flex items-center gap-1">
<ToolbarButton <ToolbarButton
icon={<Grid3x3 className="w-4 h-4" />} icon={<SlidersHorizontal className="w-4 h-4" />}
label="Zones" label="Sound Gain"
active={activePanel === 'zones'} onClick={handleGain}
onClick={() => togglePanel('zones')} active={gainMode}
disabled={!videoPath} disabled={!canEdit}
/> title="Add gain zone from selection or mark in/out — adjust volume up or down"
<ToolbarButton />
icon={<span className="text-[10px] font-semibold">PA</span>} <input
label="Pause Trim" type="number"
active={activePanel === 'silence'} min={-24}
onClick={() => togglePanel('silence')} max={24}
disabled={!videoPath} step={0.5}
/> value={gainModeDb}
<ToolbarButton onChange={(e) => setGainModeDb(Math.max(-24, Math.min(24, Number(e.target.value) || 0)))}
icon={<MapPin className="w-4 h-4" />} className="w-16 px-1.5 py-1 text-xs bg-editor-surface border border-editor-border rounded text-editor-text focus:outline-none focus:border-editor-accent"
label="Markers" title="Volume adjustment in decibels for new gain zones — positive boosts, negative reduces"
active={activePanel === 'markers'} disabled={!canEdit}
onClick={() => togglePanel('markers')} />
disabled={!videoPath} </div>
/> <div className="flex items-center gap-1">
<ToolbarButton <ToolbarButton
icon={<Music className="w-4 h-4" />} icon={<Gauge className="w-4 h-4" />}
label="Music" label="Speed Adjust"
active={activePanel === 'music'} onClick={handleSpeed}
onClick={() => togglePanel('music')} active={speedMode}
disabled={!videoPath} disabled={!canEdit}
/> title="Add speed zone from selection or mark in/out — change playback speed"
<ToolbarButton />
icon={<ListVideo className="w-4 h-4" />} <input
label="Append" type="number"
active={activePanel === 'append'} min={0.25}
onClick={() => togglePanel('append')} max={4}
disabled={!videoPath} step={0.05}
/> value={speedModeValue}
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-editor-surface border border-editor-border"> onChange={(e) => setSpeedModeValue(Math.max(0.25, Math.min(4, Number(e.target.value) || 1)))}
<select className="w-16 px-1.5 py-1 text-xs bg-editor-surface border border-editor-border rounded text-editor-text focus:outline-none focus:border-editor-accent"
value={whisperModel} title="Playback speed multiplier for new speed zones — 1x is normal, 2x is double speed"
onChange={(e) => setWhisperModel(e.target.value)} disabled={!canEdit}
className="bg-editor-surface text-xs text-editor-text focus:outline-none [color-scheme:dark]" />
title="Transcription model" </div>
> <div className="w-px h-5 bg-editor-border mx-1" />
<optgroup label="Multilingual"> <ToolbarButton
<option value="tiny">tiny</option> icon={<Grid3x3 className="w-4 h-4" />}
<option value="base">base</option> label="Edit Zones"
<option value="small">small</option> active={activePanel === 'zones'}
<option value="medium">medium</option> onClick={() => togglePanel('zones')}
<option value="large-v2">large-v2</option> disabled={!videoPath || !canEdit}
<option value="large-v3">large-v3</option> title="Open zone editor panel — view and manage all cut, mute, gain, and speed zones"
<option value="large-v3-turbo">large-v3-turbo</option> />
<option value="distil-large-v3">distil-large-v3</option> <ToolbarButton
</optgroup> icon={<span className="text-[10px] font-semibold">PA</span>}
<optgroup label="English"> label="Trim Silence"
<option value="tiny.en">tiny.en</option> active={activePanel === 'silence'}
<option value="base.en">base.en</option> onClick={() => togglePanel('silence')}
<option value="small.en">small.en</option> disabled={!videoPath || !canEdit}
<option value="medium.en">medium.en</option> title="Detect and remove silent pauses — batch-removes silence above a configurable threshold"
<option value="distil-small.en">distil-small.en</option> />
<option value="distil-medium.en">distil-medium.en</option> <ToolbarButton
</optgroup> icon={<MapPin className="w-4 h-4" />}
</select> label="Chapter Marks"
<button active={activePanel === 'markers'}
onClick={handleReprocessProject} onClick={() => togglePanel('markers')}
disabled={isTranscribing || !videoPath} disabled={!videoPath || !canEdit}
title="Reprocess transcript with selected model" title="Add and manage timeline markers — chapter points, key moments, YouTube timestamps"
className="flex items-center gap-1 px-2 py-1 rounded text-xs text-editor-text hover:bg-editor-bg disabled:opacity-40 disabled:cursor-not-allowed" />
> <ToolbarButton
<RefreshCw className={`w-3 h-3 ${isTranscribing ? 'animate-spin' : ''}`} /> icon={<Music className="w-4 h-4" />}
Reprocess label="Bkg. Music"
</button> active={activePanel === 'music'}
</div> onClick={() => togglePanel('music')}
disabled={!videoPath || !canEdit}
title="Add background music track with auto-ducking — music lowers when someone speaks"
/>
<ToolbarButton
icon={<ListVideo className="w-4 h-4" />}
label="Add Clips"
active={activePanel === 'append'}
onClick={() => togglePanel('append')}
disabled={!videoPath || !canEdit}
title="Append additional video clips — concatenate multiple files during export"
/>
<div className="w-px h-5 bg-editor-border mx-1" />
<ToolbarButton <ToolbarButton
icon={<Sparkles className="w-4 h-4" />} icon={<Sparkles className="w-4 h-4" />}
label="AI" label="AI Tools"
active={activePanel === 'ai'} active={activePanel === 'ai'}
onClick={() => togglePanel('ai')} onClick={() => togglePanel('ai')}
disabled={words.length === 0} disabled={words.length === 0 || !canEdit}
title="AI filler detection, clip suggestions, and transcript analysis"
/> />
<ToolbarButton <ToolbarButton
icon={<Download className="w-4 h-4" />} icon={<Download className="w-4 h-4" />}
label="Export" label="Export"
active={activePanel === 'export'} active={activePanel === 'export'}
onClick={() => togglePanel('export')} onClick={() => togglePanel('export')}
disabled={words.length === 0} disabled={!videoPath}
/> />
<ToolbarButton <ToolbarButton
icon={<Settings className="w-4 h-4" />} icon={<Settings className="w-4 h-4" />}
@ -728,6 +740,13 @@ export default function App() {
active={activePanel === 'settings'} active={activePanel === 'settings'}
onClick={() => togglePanel('settings')} onClick={() => togglePanel('settings')}
/> />
<ToolbarButton
icon={<HelpCircle className="w-4 h-4" />}
label="Help"
active={activePanel === 'help'}
onClick={() => togglePanel('help')}
title="View help and feature documentation"
/>
</div> </div>
</header> </header>
@ -738,14 +757,51 @@ export default function App() {
<div ref={splitRef} className="flex-1 flex min-h-0" style={{ position: 'relative' }}> <div ref={splitRef} className="flex-1 flex min-h-0" style={{ position: 'relative' }}>
{/* Video player */} {/* Video player */}
<div className="p-3 flex items-center justify-center bg-black/20 overflow-hidden" style={{ width: `${splitRatio * 100}%`, minWidth: 0 }}> <div className="p-3 flex items-center justify-center bg-black/20 overflow-hidden" style={{ width: `${splitRatio * 100}%`, minWidth: 0 }}>
<VideoPlayer /> {videoPath ? (
<VideoPlayer />
) : (
<div className="flex flex-col items-center gap-4">
<div className="w-20 h-20 rounded-[18px] bg-editor-accent/10 border border-editor-accent/20 flex items-center justify-center shadow-lg shadow-editor-accent/5">
<svg width="48" height="48" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 10h12a6 6 0 0 1 0 12H8l-2 4V10Z" stroke="#818cf8" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" opacity="0.7"/>
<path d="M6 10h12a6 6 0 0 1 0 12H8l-2 4V10Z" stroke="#6366f1" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
<path d="M10 14v4M13 13v6M16 14v4" stroke="#6366f1" strokeWidth="1.5" strokeLinecap="round"/>
<path d="M22 16h6M22 19h4M22 22h5" stroke="#818cf8" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" opacity="0.6"/>
</svg>
</div>
<div className="flex flex-col items-center gap-3">
<button
onClick={handleOpenFile}
className="flex items-center gap-2 px-6 py-2.5 bg-editor-accent hover:bg-editor-accent-hover rounded-lg text-white text-sm font-medium transition-all"
>
<FolderOpen className="w-4 h-4" />
Open File
</button>
<button
onClick={handleLoadProject}
className="flex items-center gap-2 px-4 py-1.5 text-xs text-editor-text-muted hover:text-editor-text hover:bg-editor-surface rounded-md transition-colors"
>
<FileInput className="w-3.5 h-3.5" />
Load Project
</button>
</div>
</div>
)}
</div> </div>
{/* Draggable divider */} {/* Draggable divider */}
<div <div
tabIndex={0}
role="separator"
aria-label="Resize panel"
className="w-1 shrink-0 bg-editor-border cursor-col-resize hover:bg-editor-accent/50 active:bg-editor-accent transition-colors relative z-10" className="w-1 shrink-0 bg-editor-border cursor-col-resize hover:bg-editor-accent/50 active:bg-editor-accent transition-colors relative z-10"
style={{ cursor: isDraggingSplit.current ? 'col-resize' : 'col-resize' }} style={{ cursor: isDraggingSplit.current ? 'col-resize' : 'col-resize' }}
onMouseDown={startSplitDrag} onMouseDown={startSplitDrag}
onKeyDown={(e) => {
if (e.key === 'ArrowLeft') setSplitRatio(Math.max(0.15, splitRatio - 0.02));
if (e.key === 'ArrowRight') setSplitRatio(Math.min(0.85, splitRatio + 0.02));
}}
title="Drag to resize"
/> />
{/* Transcript */} {/* Transcript */}
@ -784,6 +840,9 @@ export default function App() {
<div className="text-center space-y-1"> <div className="text-center space-y-1">
<p className="text-sm font-medium text-editor-text">Processing audio</p> <p className="text-sm font-medium text-editor-text">Processing audio</p>
<p className="text-xs text-editor-text-muted">{transcriptionStatus || 'Please wait...'}</p> <p className="text-xs text-editor-text-muted">{transcriptionStatus || 'Please wait...'}</p>
<div className="w-48 h-1.5 bg-editor-border rounded-full overflow-hidden mt-3">
<div className="h-full bg-editor-accent rounded-full animate-pulse" style={{ width: '60%' }} />
</div>
</div> </div>
</div> </div>
) : words.length > 0 ? ( ) : words.length > 0 ? (
@ -821,8 +880,16 @@ export default function App() {
<div className="flex shrink-0"> <div className="flex shrink-0">
{/* Draggable sidebar divider */} {/* Draggable sidebar divider */}
<div <div
tabIndex={0}
role="separator"
aria-label="Resize panel"
className="w-1 shrink-0 bg-editor-border cursor-col-resize hover:bg-editor-accent/50 active:bg-editor-accent transition-colors relative z-10" className="w-1 shrink-0 bg-editor-border cursor-col-resize hover:bg-editor-accent/50 active:bg-editor-accent transition-colors relative z-10"
onMouseDown={startSidebarDrag} onMouseDown={startSidebarDrag}
onKeyDown={(e) => {
if (e.key === 'ArrowUp') setSidebarWidth(Math.max(180, sidebarWidth - sidebarWidth * 0.02));
if (e.key === 'ArrowDown') setSidebarWidth(Math.min(600, sidebarWidth + sidebarWidth * 0.02));
}}
title="Drag to resize"
/> />
<div className="overflow-y-auto" style={{ width: sidebarWidth }}> <div className="overflow-y-auto" style={{ width: sidebarWidth }}>
{activePanel === 'zones' && ( {activePanel === 'zones' && (
@ -832,15 +899,18 @@ export default function App() {
{activePanel === 'markers' && <MarkersPanel />} {activePanel === 'markers' && <MarkersPanel />}
{activePanel === 'music' && <BackgroundMusicPanel />} {activePanel === 'music' && <BackgroundMusicPanel />}
{activePanel === 'append' && <AppendClipPanel />} {activePanel === 'append' && <AppendClipPanel />}
{activePanel === 'ai' && <AIPanel />} {activePanel === 'ai' && <AIPanel onReprocess={handleReprocessProject} whisperModel={whisperModel} setWhisperModel={setWhisperModel} />}
{activePanel === 'export' && <ExportDialog />} {activePanel === 'export' && <ExportDialog />}
{activePanel === 'settings' && <SettingsPanel />} {activePanel === 'settings' && <SettingsPanel />}
{activePanel === 'help' && <HelpContent />}
</div> </div>
</div> </div>
)} )}
</div> </div>
{import.meta.env.DEV && <DevPanel />} {import.meta.env.DEV && <DevPanel />}
<LicenseDialog />
{showReprocessConfirm && ( {showReprocessConfirm && (
<div <div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 px-4" className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 px-4"
@ -908,6 +978,44 @@ export default function App() {
</div> </div>
</div> </div>
)} )}
{showRecoveryDialog && (
<div
className="fixed inset-0 z-[80] flex items-center justify-center bg-black/60 px-4"
onClick={handleDismissRecovery}
>
<div
className="w-full max-w-md rounded-xl border border-editor-border bg-editor-bg p-4 space-y-3"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-sm font-semibold">Recover unsaved work?</h3>
<p className="text-xs text-editor-text-muted leading-relaxed">
TalkEdit recovered a project from {recoveryMinutesAgo} minute{recoveryMinutesAgo !== 1 ? 's' : ''} ago.
</p>
<div className="flex items-center justify-end gap-2 pt-1">
<button
onClick={handleDismissRecovery}
className="px-3 py-1.5 rounded-md text-xs text-editor-text-muted hover:text-editor-text hover:bg-editor-surface"
>
Dismiss
</button>
<button
onClick={handleRecoverAutosave}
className="px-3 py-1.5 rounded-md text-xs bg-editor-accent hover:bg-editor-accent-hover text-white"
>
Recover
</button>
</div>
</div>
</div>
)}
{backendDown && (
<div className="fixed bottom-0 left-0 right-0 z-[90] flex items-center justify-center gap-2 px-4 py-2 bg-amber-500/15 border-t border-amber-500/30 text-amber-400 text-xs font-medium">
<AlertTriangle className="w-3.5 h-3.5" />
Backend disconnected — retrying...
</div>
)}
</div> </div>
); );
} }
@ -918,26 +1026,60 @@ function ToolbarButton({
active, active,
onClick, onClick,
disabled, disabled,
title,
}: { }: {
icon: React.ReactNode; icon: React.ReactNode;
label: string; label: string;
active?: boolean; active?: boolean;
onClick: () => void; onClick: () => void;
disabled?: boolean; disabled?: boolean;
title?: string;
}) { }) {
return ( return (
<button <span title={title || label}>
onClick={onClick} <button
disabled={disabled} onClick={onClick}
title={label} disabled={disabled}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${ className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
active active
? 'bg-editor-accent text-white' ? 'bg-editor-accent text-white'
: 'text-editor-text-muted hover:text-editor-text hover:bg-editor-surface' : 'text-editor-text-muted hover:text-editor-text hover:bg-editor-surface'
} ${disabled ? 'opacity-40 cursor-not-allowed' : ''}`} } ${disabled ? 'opacity-40 cursor-not-allowed' : ''}`}
> >
{icon} {icon}
{label} {label}
</button> </button>
</span>
);
}
function DropdownItem({
icon,
label,
onClick,
disabled,
title,
}: {
icon: React.ReactNode;
label: string;
onClick: () => void;
disabled?: boolean;
title?: string;
}) {
return (
<span title={title || label}>
<button
onClick={onClick}
disabled={disabled}
className={`w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left transition-colors ${
disabled
? 'opacity-40 cursor-not-allowed'
: 'text-editor-text-muted hover:text-editor-text hover:bg-editor-bg'
}`}
>
{icon}
{label}
</button>
</span>
); );
} }

View File

@ -1,11 +1,20 @@
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { useEditorStore } from '../store/editorStore'; import { useEditorStore } from '../store/editorStore';
import { useAIStore } from '../store/aiStore'; import { useAIStore } from '../store/aiStore';
import { Sparkles, Scissors, Film, Loader2, Check, X, Play, Download } from 'lucide-react'; import { useLicenseStore } from '../store/licenseStore';
import { Sparkles, Scissors, Film, Loader2, Check, X, Play, Download, RotateCcw, RefreshCw, Lock } from 'lucide-react';
import type { ClipSuggestion } from '../types/project'; import type { ClipSuggestion } from '../types/project';
export default function AIPanel() { interface AIPanelProps {
onReprocess: () => void;
whisperModel: string;
setWhisperModel: (model: string) => void;
}
export default function AIPanel({ onReprocess, whisperModel, setWhisperModel }: AIPanelProps) {
const { words, videoPath, backendUrl, deleteWordRange, setCurrentTime } = useEditorStore(); const { words, videoPath, backendUrl, deleteWordRange, setCurrentTime } = useEditorStore();
const canUseAI = useLicenseStore((s) => s.canUseAI);
const setShowLicenseDialog = useLicenseStore((s) => s.setShowDialog);
const { const {
defaultProvider, defaultProvider,
providers, providers,
@ -20,10 +29,12 @@ export default function AIPanel() {
setProcessing, setProcessing,
} = useAIStore(); } = useAIStore();
const [activeTab, setActiveTab] = useState<'filler' | 'clips'>('filler'); const [activeTab, setActiveTab] = useState<'filler' | 'clips' | 'reprocess'>('filler');
const [error, setError] = useState<string | null>(null);
const detectFillers = useCallback(async () => { const detectFillers = useCallback(async () => {
if (words.length === 0) return; if (words.length === 0) return;
setError(null);
setProcessing(true, 'Detecting filler words...'); setProcessing(true, 'Detecting filler words...');
try { try {
const config = providers[defaultProvider]; const config = providers[defaultProvider];
@ -41,11 +52,15 @@ export default function AIPanel() {
custom_filler_words: customFillerWords || undefined, custom_filler_words: customFillerWords || undefined,
}), }),
}); });
if (!res.ok) throw new Error('Filler detection failed'); if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.error || `Filler detection failed (${res.status})`);
}
const data = await res.json(); const data = await res.json();
setFillerResult(data); setFillerResult(data);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
setError(err instanceof Error ? err.message : 'Filler detection failed');
} finally { } finally {
setProcessing(false); setProcessing(false);
} }
@ -53,6 +68,7 @@ export default function AIPanel() {
const createClips = useCallback(async () => { const createClips = useCallback(async () => {
if (words.length === 0) return; if (words.length === 0) return;
setError(null);
setProcessing(true, 'Finding best clip segments...'); setProcessing(true, 'Finding best clip segments...');
try { try {
const config = providers[defaultProvider]; const config = providers[defaultProvider];
@ -75,11 +91,15 @@ export default function AIPanel() {
target_duration: 60, target_duration: 60,
}), }),
}); });
if (!res.ok) throw new Error('Clip creation failed'); if (!res.ok) {
const errData = await res.json().catch(() => ({}));
throw new Error(errData.error || `Clip creation failed (${res.status})`);
}
const data = await res.json(); const data = await res.json();
setClipSuggestions(data.clips || []); setClipSuggestions(data.clips || []);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
setError(err instanceof Error ? err.message : 'Clip creation failed');
} finally { } finally {
setProcessing(false); setProcessing(false);
} }
@ -150,156 +170,275 @@ export default function AIPanel() {
onClick={() => setActiveTab('filler')} onClick={() => setActiveTab('filler')}
icon={<Scissors className="w-3.5 h-3.5" />} icon={<Scissors className="w-3.5 h-3.5" />}
label="Filler Words" label="Filler Words"
title="Detect and remove filler words from transcript"
/> />
<TabButton <TabButton
active={activeTab === 'clips'} active={activeTab === 'clips'}
onClick={() => setActiveTab('clips')} onClick={() => setActiveTab('clips')}
icon={<Film className="w-3.5 h-3.5" />} icon={<Film className="w-3.5 h-3.5" />}
label="Create Clips" label="Create Clips"
title="Find the best segments for social media clips"
/>
<TabButton
active={activeTab === 'reprocess'}
onClick={() => setActiveTab('reprocess')}
icon={<RefreshCw className="w-3.5 h-3.5" />}
label="Reprocess"
title="Re-run transcription with a different Whisper model"
/> />
</div> </div>
<div className="flex-1 overflow-y-auto p-4"> <div className="flex-1 overflow-y-auto p-4">
{activeTab === 'filler' && ( {activeTab === 'filler' && (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-xs text-editor-text-muted"> {!canUseAI ? (
Use AI to detect and remove filler words like "um", "uh", "like", "you know" from <div className="text-center py-8 px-4">
your transcript. <Lock className="w-8 h-8 text-editor-text-muted mx-auto mb-3" />
</p> <p className="text-sm font-medium mb-1">AI editing requires Business</p>
<div className="space-y-1.5"> <p className="text-xs text-editor-text-muted mb-4">
<label className="text-[11px] text-editor-text-muted font-medium"> Upgrade to Business to unlock filler word removal, clip suggestions, and more.
Custom filler words (comma-separated) </p>
</label> <button
<input onClick={() => setShowLicenseDialog(true)}
type="text" className="px-4 py-2 bg-editor-accent hover:bg-editor-accent-hover text-white rounded-lg text-sm font-medium transition-colors"
value={customFillerWords} >
onChange={(e) => setCustomFillerWords(e.target.value)} Upgrade Now
placeholder="e.g. okay, alright, anyway" </button>
className="w-full px-2.5 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none" </div>
/> ) : (
</div> <>
<button <p className="text-xs text-editor-text-muted">
onClick={detectFillers} Use AI to detect and remove filler words like "um", "uh", "like", "you know" from
disabled={isProcessing || words.length === 0} your transcript.
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-50 rounded-lg text-sm font-medium transition-colors" </p>
> <div className="space-y-1.5">
{isProcessing ? ( <label className="text-[11px] text-editor-text-muted font-medium">
<> Custom filler words (comma-separated)
<Loader2 className="w-4 h-4 animate-spin" /> </label>
{processingMessage} <input
</> type="text"
) : ( value={customFillerWords}
<> onChange={(e) => setCustomFillerWords(e.target.value)}
<Sparkles className="w-4 h-4" /> placeholder="e.g. okay, alright, anyway"
Detect Filler Words className="w-full px-2.5 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
</> />
)} </div>
</button> <button
onClick={detectFillers}
disabled={isProcessing || words.length === 0}
title="Scan the entire transcript for filler words (um, uh, like, you know) and mark for removal"
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-40 rounded-lg text-sm font-medium transition-colors"
>
{isProcessing ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{processingMessage}
</>
) : (
<>
<Sparkles className="w-4 h-4" />
Detect Filler Words
</>
)}
</button>
{fillerResult && fillerResult.fillerWords.length > 0 && ( {error && (
<div className="space-y-3"> <div className="bg-red-500/10 border border-red-500/40 rounded text-xs text-red-300 p-2 flex items-center justify-between">
<div className="flex items-center justify-between"> <span>{error}</span>
<span className="text-xs font-medium">
Found {fillerResult.fillerWords.length} filler words
</span>
<div className="flex gap-1">
<button <button
onClick={applyFillerDeletions} onClick={detectFillers}
className="flex items-center gap-1 px-2 py-1 text-xs bg-editor-success/20 text-editor-success rounded hover:bg-editor-success/30" className="flex items-center gap-1 px-2 py-1 text-xs bg-red-500/20 hover:bg-red-500/30 rounded transition-colors shrink-0 ml-2"
> >
<Check className="w-3 h-3" /> Apply All <RotateCcw className="w-3 h-3" /> Retry
</button>
<button
onClick={() => setFillerResult(null)}
className="flex items-center gap-1 px-2 py-1 text-xs bg-editor-border text-editor-text-muted rounded hover:bg-editor-surface"
>
<X className="w-3 h-3" /> Dismiss
</button> </button>
</div> </div>
</div> )}
<div className="space-y-1 max-h-64 overflow-y-auto"> {fillerResult && fillerResult.fillerWords.length > 0 && (
{fillerResult.fillerWords.map((fw) => ( <div className="space-y-3">
<div <div className="flex items-center justify-between">
key={fw.index} <span className="text-xs font-medium">
className="flex items-center justify-between px-2 py-1.5 bg-editor-word-filler rounded text-xs" Found {fillerResult.fillerWords.length} filler words
>
<span>
<strong>"{fw.word}"</strong>
<span className="text-editor-text-muted ml-1"> {fw.reason}</span>
</span> </span>
<div className="flex gap-1">
<button
onClick={applyFillerDeletions}
title="Create cut ranges for all detected filler words at once"
className="flex items-center gap-1 px-2 py-1 text-xs bg-editor-success/20 text-editor-success rounded hover:bg-editor-success/30"
>
<Check className="w-3 h-3" /> Apply All
</button>
<button
onClick={() => { setFillerResult(null); setError(null); }}
title="Clear detected filler word results without applying"
className="flex items-center gap-1 px-2 py-1 text-xs bg-editor-border text-editor-text-muted rounded hover:bg-editor-surface"
>
<X className="w-3 h-3" /> Dismiss
</button>
</div>
</div> </div>
))} <div className="space-y-1 max-h-64 overflow-y-auto">
</div> {fillerResult.fillerWords.map((fw) => (
</div> <div
)} key={fw.index}
className="flex items-center justify-between px-2 py-1.5 bg-editor-word-filler rounded text-xs"
>
<span>
<strong>"{fw.word}"</strong>
<span className="text-editor-text-muted ml-1"> {fw.reason}</span>
</span>
</div>
))}
</div>
</div>
)}
{fillerResult && fillerResult.fillerWords.length === 0 && ( {fillerResult && fillerResult.fillerWords.length === 0 && (
<p className="text-xs text-editor-success">No filler words detected.</p> <p className="text-xs text-editor-success">No filler words detected.</p>
)}
</>
)} )}
</div> </div>
)} )}
{activeTab === 'clips' && ( {activeTab === 'clips' && (
<div className="space-y-4"> <div className="space-y-4">
<p className="text-xs text-editor-text-muted"> {!canUseAI ? (
AI analyzes your transcript and suggests the most engaging segments for a <div className="text-center py-8 px-4">
YouTube Short or social media clip. <Lock className="w-8 h-8 text-editor-text-muted mx-auto mb-3" />
</p> <p className="text-sm font-medium mb-1">AI clip suggestions require Business</p>
<button <p className="text-xs text-editor-text-muted mb-4">
onClick={createClips} Upgrade to Business to find the best segments for social media clips.
disabled={isProcessing || words.length === 0} </p>
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-50 rounded-lg text-sm font-medium transition-colors" <button
> onClick={() => setShowLicenseDialog(true)}
{isProcessing ? ( className="px-4 py-2 bg-editor-accent hover:bg-editor-accent-hover text-white rounded-lg text-sm font-medium transition-colors"
<> >
<Loader2 className="w-4 h-4 animate-spin" /> Upgrade Now
{processingMessage} </button>
</>
) : (
<>
<Film className="w-4 h-4" />
Find Best Clips
</>
)}
</button>
{clipSuggestions.length > 0 && (
<div className="space-y-3">
{clipSuggestions.map((clip, i) => (
<div key={i} className="p-3 bg-editor-surface rounded-lg space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold">{clip.title}</span>
<span className="text-[10px] text-editor-text-muted">
{Math.round(clip.endTime - clip.startTime)}s
</span>
</div>
<p className="text-[11px] text-editor-text-muted">{clip.reason}</p>
<div className="flex gap-2">
<button
onClick={() => handlePreviewClip(clip)}
className="flex-1 flex items-center justify-center gap-1 px-2 py-1.5 text-xs bg-editor-accent/20 text-editor-accent rounded hover:bg-editor-accent/30 transition-colors"
>
<Play className="w-3 h-3" /> Preview
</button>
<button
onClick={() => handleExportClip(clip, i)}
disabled={exportingClipIndex === i}
className="flex-1 flex items-center justify-center gap-1 px-2 py-1.5 text-xs bg-editor-success/20 text-editor-success rounded hover:bg-editor-success/30 disabled:opacity-50 transition-colors"
>
{exportingClipIndex === i ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<Download className="w-3 h-3" />
)}
Export
</button>
</div>
</div>
))}
</div> </div>
) : (
<>
<p className="text-xs text-editor-text-muted">
AI analyzes your transcript and suggests the most engaging segments for a
YouTube Short or social media clip.
</p>
<button
onClick={createClips}
disabled={isProcessing || words.length === 0}
title="Analyze transcript to find the most engaging 20-60 second segments for social media"
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-40 rounded-lg text-sm font-medium transition-colors"
>
{isProcessing ? (
<>
<Loader2 className="w-4 h-4 animate-spin" />
{processingMessage}
</>
) : (
<>
<Film className="w-4 h-4" />
Find Best Clips
</>
)}
</button>
{error && (
<div className="bg-red-500/10 border border-red-500/40 rounded text-xs text-red-300 p-2 flex items-center justify-between">
<span>{error}</span>
<button
onClick={createClips}
className="flex items-center gap-1 px-2 py-1 text-xs bg-red-500/20 hover:bg-red-500/30 rounded transition-colors shrink-0 ml-2"
>
<RotateCcw className="w-3 h-3" /> Retry
</button>
</div>
)}
{clipSuggestions.length > 0 && (
<div className="space-y-3">
{clipSuggestions.map((clip, i) => (
<div key={i} className="p-3 bg-editor-surface rounded-lg space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs font-semibold">{clip.title}</span>
<span className="text-[10px] text-editor-text-muted">
{Math.round(clip.endTime - clip.startTime)}s
</span>
</div>
<p className="text-[11px] text-editor-text-muted">{clip.reason}</p>
<div className="flex gap-2">
<button
onClick={() => handlePreviewClip(clip)}
title="Seek to this clip's position and play a preview"
className="flex-1 flex items-center justify-center gap-1 px-2 py-1.5 text-xs bg-editor-accent/20 text-editor-accent rounded hover:bg-editor-accent/30 transition-colors"
>
<Play className="w-3 h-3" /> Preview
</button>
<button
onClick={() => handleExportClip(clip, i)}
disabled={exportingClipIndex === i}
title="Export just this segment as a standalone video file"
className="flex-1 flex items-center justify-center gap-1 px-2 py-1.5 text-xs bg-editor-success/20 text-editor-success rounded hover:bg-editor-success/30 disabled:opacity-40 transition-colors"
>
{exportingClipIndex === i ? (
<Loader2 className="w-3 h-3 animate-spin" />
) : (
<Download className="w-3 h-3" />
)}
Export
</button>
</div>
</div>
))}
</div>
)}
</>
)} )}
</div> </div>
)} )}
{activeTab === 'reprocess' && (
<div className="space-y-4">
<p className="text-xs text-editor-text-muted">
Re-run transcription with a different model replaces the current transcript entirely.
</p>
<div className="space-y-1.5">
<label className="text-[11px] text-editor-text-muted font-medium">
Whisper Model
</label>
<select
value={whisperModel}
onChange={(e) => setWhisperModel(e.target.value)}
className="w-full px-2.5 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
>
<optgroup label="Multilingual (any language)">
<option value="tiny">tiny ~75 MB · fastest, low accuracy</option>
<option value="base">base ~140 MB · fast, decent accuracy</option>
<option value="small">small ~460 MB · good balance</option>
<option value="medium">medium ~1.5 GB · better accuracy</option>
<option value="large-v2">large-v2 ~2.9 GB · high accuracy</option>
<option value="large-v3">large-v3 ~2.9 GB · best overall </option>
<option value="large-v3-turbo">large-v3-turbo ~1.6 GB · fast + accurate </option>
<option value="distil-large-v3">distil-large-v3 ~1.5 GB · fast, near large-v3 quality</option>
</optgroup>
<optgroup label="English-only (faster &amp; more accurate for English)">
<option value="tiny.en">tiny.en ~75 MB · fastest English</option>
<option value="base.en">base.en ~140 MB · fast English</option>
<option value="small.en">small.en ~460 MB · good English</option>
<option value="medium.en">medium.en ~1.5 GB · great English</option>
<option value="distil-small.en">distil-small.en ~190 MB · fast English </option>
<option value="distil-medium.en">distil-medium.en ~750 MB · best fast English </option>
</optgroup>
</select>
</div>
<button
onClick={onReprocess}
disabled={isProcessing || words.length === 0}
title="Re-run transcription with the selected model — this will replace all current words"
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-40 rounded-lg text-sm font-medium transition-colors"
>
<RefreshCw className="w-4 h-4" />
Reprocess Transcript
</button>
</div>
)}
</div> </div>
</div> </div>
); );
@ -310,15 +449,18 @@ function TabButton({
onClick, onClick,
icon, icon,
label, label,
title,
}: { }: {
active: boolean; active: boolean;
onClick: () => void; onClick: () => void;
icon: React.ReactNode; icon: React.ReactNode;
label: string; label: string;
title?: string;
}) { }) {
return ( return (
<button <button
onClick={onClick} onClick={onClick}
title={title}
className={`flex-1 flex items-center justify-center gap-1.5 px-3 py-2.5 text-xs font-medium transition-colors border-b-2 ${ className={`flex-1 flex items-center justify-center gap-1.5 px-3 py-2.5 text-xs font-medium transition-colors border-b-2 ${
active active
? 'border-editor-accent text-editor-accent' ? 'border-editor-accent text-editor-accent'

View File

@ -5,7 +5,12 @@ export default function AppendClipPanel() {
const { additionalClips, addAdditionalClip, removeAdditionalClip, reorderAdditionalClip, videoPath } = useEditorStore(); const { additionalClips, addAdditionalClip, removeAdditionalClip, reorderAdditionalClip, videoPath } = useEditorStore();
const handleAddClip = async () => { const handleAddClip = async () => {
const path = await window.electronAPI?.openFile(); const path = await window.electronAPI?.openFile({
filters: [
{ name: 'Video Files', extensions: ['mp4', 'mkv', 'mov', 'avi', 'webm'] },
{ name: 'All Files', extensions: ['*'] },
],
});
if (path) { if (path) {
addAdditionalClip(path); addAdditionalClip(path);
} }
@ -69,6 +74,7 @@ export default function AppendClipPanel() {
onClick={handleAddClip} onClick={handleAddClip}
disabled={!videoPath} disabled={!videoPath}
className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg border-2 border-dashed border-editor-border text-xs text-editor-text-muted hover:text-editor-text hover:border-editor-text-muted disabled:opacity-40 transition-colors" className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg border-2 border-dashed border-editor-border text-xs text-editor-text-muted hover:text-editor-text hover:border-editor-text-muted disabled:opacity-40 transition-colors"
title="Select a video or audio file to append during export"
> >
<Plus className="w-3.5 h-3.5" /> <Plus className="w-3.5 h-3.5" />
Add Clip Add Clip

View File

@ -5,7 +5,12 @@ export default function BackgroundMusicPanel() {
const { backgroundMusic, setBackgroundMusic, updateBackgroundMusic } = useEditorStore(); const { backgroundMusic, setBackgroundMusic, updateBackgroundMusic } = useEditorStore();
const handleLoadMusic = async () => { const handleLoadMusic = async () => {
const path = await window.electronAPI?.openFile(); const path = await window.electronAPI?.openFile({
filters: [
{ name: 'Audio Files', extensions: ['mp3', 'wav', 'm4a', 'flac', 'ogg', 'aac', 'wma'] },
{ name: 'All Files', extensions: ['*'] },
],
});
if (path) { if (path) {
setBackgroundMusic({ setBackgroundMusic({
path, path,
@ -33,6 +38,7 @@ export default function BackgroundMusicPanel() {
<button <button
onClick={handleLoadMusic} onClick={handleLoadMusic}
className="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-lg border-2 border-dashed border-editor-border text-xs text-editor-text-muted hover:text-editor-text hover:border-editor-text-muted transition-colors" className="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-lg border-2 border-dashed border-editor-border text-xs text-editor-text-muted hover:text-editor-text hover:border-editor-text-muted transition-colors"
title="Select an audio file to use as background music"
> >
<Disc3 className="w-4 h-4" /> <Disc3 className="w-4 h-4" />
Load Music File Load Music File
@ -65,6 +71,7 @@ export default function BackgroundMusicPanel() {
value={backgroundMusic.volumeDb} value={backgroundMusic.volumeDb}
onChange={(e) => updateBackgroundMusic({ volumeDb: Number(e.target.value) })} onChange={(e) => updateBackgroundMusic({ volumeDb: Number(e.target.value) })}
className="flex-1 h-1.5" className="flex-1 h-1.5"
title="Background music volume relative to main audio — positive boosts, negative reduces"
/> />
<span className="text-xs text-editor-text w-10 text-right">{backgroundMusic.volumeDb} dB</span> <span className="text-xs text-editor-text w-10 text-right">{backgroundMusic.volumeDb} dB</span>
</div> </div>
@ -76,6 +83,7 @@ export default function BackgroundMusicPanel() {
checked={backgroundMusic.duckingEnabled} checked={backgroundMusic.duckingEnabled}
onChange={(e) => updateBackgroundMusic({ duckingEnabled: e.target.checked })} onChange={(e) => updateBackgroundMusic({ duckingEnabled: e.target.checked })}
className="w-4 h-4 rounded bg-editor-surface border-editor-border accent-editor-accent" className="w-4 h-4 rounded bg-editor-surface border-editor-border accent-editor-accent"
title="Automatically lower music volume when speech is detected"
/> />
<div> <div>
<span className="text-xs font-medium">Auto-ducking</span> <span className="text-xs font-medium">Auto-ducking</span>
@ -97,6 +105,7 @@ export default function BackgroundMusicPanel() {
value={backgroundMusic.duckingDb} value={backgroundMusic.duckingDb}
onChange={(e) => updateBackgroundMusic({ duckingDb: Number(e.target.value) })} onChange={(e) => updateBackgroundMusic({ duckingDb: Number(e.target.value) })}
className="flex-1 h-1.5" className="flex-1 h-1.5"
title="How much to reduce music volume during speech (1-20 dB)"
/> />
<span className="text-xs text-editor-text w-10 text-right">{backgroundMusic.duckingDb} dB</span> <span className="text-xs text-editor-text w-10 text-right">{backgroundMusic.duckingDb} dB</span>
</div> </div>
@ -110,6 +119,7 @@ export default function BackgroundMusicPanel() {
value={backgroundMusic.duckingAttackMs} value={backgroundMusic.duckingAttackMs}
onChange={(e) => updateBackgroundMusic({ duckingAttackMs: Number(e.target.value) })} onChange={(e) => updateBackgroundMusic({ duckingAttackMs: Number(e.target.value) })}
className="flex-1 h-1.5" className="flex-1 h-1.5"
title="How quickly the ducking effect engages when speech starts"
/> />
<span className="text-xs text-editor-text w-10 text-right">{backgroundMusic.duckingAttackMs}ms</span> <span className="text-xs text-editor-text w-10 text-right">{backgroundMusic.duckingAttackMs}ms</span>
</div> </div>
@ -123,6 +133,7 @@ export default function BackgroundMusicPanel() {
value={backgroundMusic.duckingReleaseMs} value={backgroundMusic.duckingReleaseMs}
onChange={(e) => updateBackgroundMusic({ duckingReleaseMs: Number(e.target.value) })} onChange={(e) => updateBackgroundMusic({ duckingReleaseMs: Number(e.target.value) })}
className="flex-1 h-1.5" className="flex-1 h-1.5"
title="How quickly the ducking effect fades when speech ends"
/> />
<span className="text-xs text-editor-text w-10 text-right">{backgroundMusic.duckingReleaseMs}ms</span> <span className="text-xs text-editor-text w-10 text-right">{backgroundMusic.duckingReleaseMs}ms</span>
</div> </div>

View File

@ -1,12 +1,13 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { useEditorStore } from '../store/editorStore'; import { useEditorStore } from '../store/editorStore';
import { Terminal, ChevronDown, ChevronUp, Play, Wifi } from 'lucide-react'; import { Terminal, ChevronDown, ChevronUp, Play, Wifi, AlertTriangle } from 'lucide-react';
export default function DevPanel() { export default function DevPanel() {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [pathInput, setPathInput] = useState(''); const [pathInput, setPathInput] = useState('');
const [testResult, setTestResult] = useState<string | null>(null); const [testResult, setTestResult] = useState<string | null>(null);
const [testing, setTesting] = useState(false); const [testing, setTesting] = useState(false);
const [showResetConfirm, setShowResetConfirm] = useState(false);
const { backendUrl, videoPath, loadVideo } = useEditorStore(); const { backendUrl, videoPath, loadVideo } = useEditorStore();
@ -121,6 +122,37 @@ export default function DevPanel() {
{testResult} {testResult}
</pre> </pre>
)} )}
{/* Danger Zone */}
<div className="space-y-1">
<div className="text-[#ef4444] uppercase tracking-wider text-[9px]">Danger Zone</div>
{!showResetConfirm ? (
<button
onClick={() => setShowResetConfirm(true)}
className="w-full px-2 py-1.5 rounded border border-red-500/40 text-red-400 hover:bg-red-500/10 text-xs flex items-center justify-center gap-1.5"
>
<AlertTriangle className="w-3 h-3" />
Reset Editor State
</button>
) : (
<div className="bg-[#1e1020] border border-red-500/40 rounded p-2 space-y-1.5">
<p className="text-[#fca5a5] text-[10px]">This will clear all editor data and reload the page. Unsaved changes will be lost.</p>
<div className="flex gap-1">
<button
onClick={() => setShowResetConfirm(false)}
className="flex-1 px-2 py-1 rounded text-[10px] text-[#6b7280] hover:text-white hover:bg-[#2a2d3e]"
>
Cancel
</button>
<button
onClick={() => { useEditorStore.getState().reset(); window.location.reload(); }}
className="flex-1 px-2 py-1 rounded text-[10px] border border-red-500/40 text-red-400 hover:bg-red-500/10"
>
Confirm Reset
</button>
</div>
</div>
)}
</div>
</div> </div>
)} )}
</div> </div>

View File

@ -0,0 +1,90 @@
import { Component, type ReactNode } from 'react';
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
}
export default class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error('ErrorBoundary caught:', error, info.componentStack);
try {
window.electronAPI?.logError?.(error.message, error.stack || '', info.componentStack || '');
} catch {}
}
handleReload = () => {
window.location.reload();
};
handleReset = () => {
try {
localStorage.clear();
sessionStorage.clear();
} catch {}
window.location.reload();
};
render() {
if (this.state.hasError) {
return (
<div className="h-screen flex flex-col items-center justify-center gap-6 bg-editor-bg px-6">
<div className="flex flex-col items-center gap-3 max-w-md text-center">
<div className="w-12 h-12 rounded-full bg-red-500/20 flex items-center justify-center">
<svg className="w-6 h-6 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
</div>
<h2 className="text-lg font-semibold text-editor-text">Something went wrong</h2>
<p className="text-xs text-editor-text-muted leading-relaxed">
An unexpected error occurred. Your work may still be recoverable.
</p>
</div>
{this.state.error && (
<details className="max-w-md w-full">
<summary className="text-xs text-editor-text-muted cursor-pointer hover:text-editor-text">
Error details
</summary>
<pre className="mt-2 p-3 rounded bg-editor-surface border border-editor-border text-[10px] text-red-300 overflow-auto max-h-32 whitespace-pre-wrap">
{this.state.error.message}
{'\n'}
{this.state.error.stack}
</pre>
</details>
)}
<div className="flex flex-col items-center gap-2">
<button
onClick={this.handleReload}
className="px-4 py-2 bg-editor-accent hover:bg-editor-accent-hover rounded-lg text-sm font-medium transition-colors"
>
Reload App
</button>
<button
onClick={this.handleReset}
className="text-xs text-editor-text-muted hover:text-editor-text underline transition-colors"
>
Reset & Clear All Data
</button>
</div>
</div>
);
}
return this.props.children;
}
}

View File

@ -2,6 +2,7 @@ import { useState, useCallback } from 'react';
import { useEditorStore } from '../store/editorStore'; import { useEditorStore } from '../store/editorStore';
import { Download, Loader2, Zap, Cog, Info, Volume2, FileText, ZoomIn, Video, Music } from 'lucide-react'; import { Download, Loader2, Zap, Cog, Info, Volume2, FileText, ZoomIn, Video, Music } from 'lucide-react';
import type { ExportOptions } from '../types/project'; import type { ExportOptions } from '../types/project';
import { assert } from '../lib/assert';
export default function ExportDialog() { export default function ExportDialog() {
const { videoPath, words, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, isExporting, exportProgress, backendUrl, setExporting, getKeepSegments, additionalClips, backgroundMusic } = const { videoPath, words, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, isExporting, exportProgress, backendUrl, setExporting, getKeepSegments, additionalClips, backgroundMusic } =
@ -9,6 +10,19 @@ export default function ExportDialog() {
const hasCuts = cutRanges.length > 0; const hasCuts = cutRanges.length > 0;
// Compute set of deleted word indices from cutRanges
const getDeletedSet = useCallback(() => {
const deletedSet = new Set<number>();
for (const range of cutRanges) {
for (let i = 0; i < words.length; i++) {
if (words[i].start >= range.start && words[i].end <= range.end) {
deletedSet.add(i);
}
}
}
return deletedSet;
}, [cutRanges, words]);
// Detect if input is audio-only by its extension // Detect if input is audio-only by its extension
const audioExtensions = new Set(['.wav', '.mp3', '.flac', '.m4a', '.ogg', '.aac', '.wma']); const audioExtensions = new Set(['.wav', '.mp3', '.flac', '.m4a', '.ogg', '.aac', '.wma']);
const inputExt = videoPath ? '.' + videoPath.split('.').pop()?.toLowerCase() : ''; const inputExt = videoPath ? '.' + videoPath.split('.').pop()?.toLowerCase() : '';
@ -46,14 +60,7 @@ export default function ExportDialog() {
setIsTranscribingTranscript(true); setIsTranscribingTranscript(true);
try { try {
// Compute deleted word set // Compute deleted word set
const deletedSet = new Set<number>(); const deletedSet = getDeletedSet();
for (const range of cutRanges) {
for (let i = 0; i < words.length; i++) {
if (words[i].start >= range.start && words[i].end <= range.end) {
deletedSet.add(i);
}
}
}
// Generate content entirely on the frontend — no backend needed // Generate content entirely on the frontend — no backend needed
let content: string; let content: string;
@ -103,7 +110,7 @@ export default function ExportDialog() {
} finally { } finally {
setIsTranscribingTranscript(false); setIsTranscribingTranscript(false);
} }
}, [videoPath, words, cutRanges, transcriptFormat]); }, [videoPath, words, getDeletedSet, transcriptFormat]);
const HANDLE_EXPORT_filters = useCallback(() => { const HANDLE_EXPORT_filters = useCallback(() => {
const ext = options.format; const ext = options.format;
@ -130,14 +137,8 @@ export default function ExportDialog() {
setExportError(null); setExportError(null);
try { try {
const keepSegments = getKeepSegments(); const keepSegments = getKeepSegments();
assert(words.length > 0, 'handleExport: words is empty before building keep segments');
const deletedSet = new Set<number>(); const deletedSet = getDeletedSet();
for (const range of cutRanges) {
for (let i = 0; i < words.length; i++) {
const w = words[i];
if (w.start >= range.start && w.end <= range.end) deletedSet.add(i);
}
}
// Map frontend camelCase gain/speed fields to backend snake_case // Map frontend camelCase gain/speed fields to backend snake_case
const backendGainRanges = gainRanges.map((r) => ({ const backendGainRanges = gainRanges.map((r) => ({
@ -213,7 +214,7 @@ export default function ExportDialog() {
setExportError(err instanceof Error ? err.message : 'Export failed'); setExportError(err instanceof Error ? err.message : 'Export failed');
setExporting(false); setExporting(false);
} }
}, [videoPath, options, backendUrl, setExporting, getKeepSegments, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, words, HANDLE_EXPORT_filters, additionalClips, backgroundMusic]); }, [videoPath, options, backendUrl, setExporting, getKeepSegments, getDeletedSet, muteRanges, gainRanges, speedRanges, globalGainDb, words, HANDLE_EXPORT_filters, additionalClips, backgroundMusic]);
return ( return (
<div className="p-4 space-y-5"> <div className="p-4 space-y-5">
@ -229,6 +230,7 @@ export default function ExportDialog() {
icon={<Zap className="w-4 h-4" />} icon={<Zap className="w-4 h-4" />}
title="Fast" title="Fast"
desc="Stream copy, seconds" desc="Stream copy, seconds"
tooltip="Stream copy — fast, no quality loss, but does not apply cuts or effects"
/> />
<ModeCard <ModeCard
active={options.mode === 'reencode'} active={options.mode === 'reencode'}
@ -236,6 +238,7 @@ export default function ExportDialog() {
icon={<Cog className="w-4 h-4" />} icon={<Cog className="w-4 h-4" />}
title="Re-encode" title="Re-encode"
desc="Custom quality, slower" desc="Custom quality, slower"
tooltip="Full re-encode — applies cuts, gain, speed, zoom, captions, and effects"
/> />
</div> </div>
</fieldset> </fieldset>
@ -251,6 +254,7 @@ export default function ExportDialog() {
{ value: '1080p', label: '1080p (Full HD)' }, { value: '1080p', label: '1080p (Full HD)' },
{ value: '4k', label: '4K (Ultra HD)' }, { value: '4k', label: '4K (Ultra HD)' },
]} ]}
title="Output video resolution — higher resolution = larger file"
/> />
)} )}
@ -265,6 +269,7 @@ export default function ExportDialog() {
{ value: 'webm', label: 'WebM (VP9)' }, { value: 'webm', label: 'WebM (VP9)' },
...(isAudioOnly ? [{ value: 'wav' as const, label: 'WAV (Uncompressed)' }] : []), ...(isAudioOnly ? [{ value: 'wav' as const, label: 'WAV (Uncompressed)' }] : []),
]} ]}
title="Output container format — MP4 is most compatible"
/> />
{/* Video zoom / punch-in */} {/* Video zoom / punch-in */}
@ -275,6 +280,7 @@ export default function ExportDialog() {
checked={options.zoom?.enabled || false} checked={options.zoom?.enabled || false}
onChange={(e) => setOptions((o) => ({ ...o, zoom: { ...o.zoom!, enabled: e.target.checked } }))} onChange={(e) => setOptions((o) => ({ ...o, zoom: { ...o.zoom!, enabled: e.target.checked } }))}
className="w-4 h-4 rounded bg-editor-surface border-editor-border accent-editor-accent" className="w-4 h-4 rounded bg-editor-surface border-editor-border accent-editor-accent"
title="Crop and reposition the video frame — useful for removing black bars or reframing"
/> />
<div> <div>
<span className="text-xs font-medium flex items-center gap-1"> <span className="text-xs font-medium flex items-center gap-1">
@ -298,6 +304,7 @@ export default function ExportDialog() {
value={options.zoom?.zoomFactor || 1} value={options.zoom?.zoomFactor || 1}
onChange={(e) => setOptions((o) => ({ ...o, zoom: { ...o.zoom!, zoomFactor: Number(e.target.value) } }))} onChange={(e) => setOptions((o) => ({ ...o, zoom: { ...o.zoom!, zoomFactor: Number(e.target.value) } }))}
className="flex-1 h-1.5" className="flex-1 h-1.5"
title="Magnification level — 1.0x is original, higher values zoom in"
/> />
<span className="text-xs text-editor-text w-10 text-right">{options.zoom?.zoomFactor?.toFixed(2)}x</span> <span className="text-xs text-editor-text w-10 text-right">{options.zoom?.zoomFactor?.toFixed(2)}x</span>
</div> </div>
@ -311,6 +318,7 @@ export default function ExportDialog() {
value={options.zoom?.panX || 0} value={options.zoom?.panX || 0}
onChange={(e) => setOptions((o) => ({ ...o, zoom: { ...o.zoom!, panX: Number(e.target.value) } }))} onChange={(e) => setOptions((o) => ({ ...o, zoom: { ...o.zoom!, panX: Number(e.target.value) } }))}
className="flex-1 h-1.5" className="flex-1 h-1.5"
title="Horizontal position of the crop window — negative moves left, positive moves right"
/> />
<span className="text-xs text-editor-text w-10 text-right">{((options.zoom?.panX || 0) * 100).toFixed(0)}%</span> <span className="text-xs text-editor-text w-10 text-right">{((options.zoom?.panX || 0) * 100).toFixed(0)}%</span>
</div> </div>
@ -324,6 +332,7 @@ export default function ExportDialog() {
value={options.zoom?.panY || 0} value={options.zoom?.panY || 0}
onChange={(e) => setOptions((o) => ({ ...o, zoom: { ...o.zoom!, panY: Number(e.target.value) } }))} onChange={(e) => setOptions((o) => ({ ...o, zoom: { ...o.zoom!, panY: Number(e.target.value) } }))}
className="flex-1 h-1.5" className="flex-1 h-1.5"
title="Vertical position of the crop window — negative moves up, positive moves down"
/> />
<span className="text-xs text-editor-text w-10 text-right">{((options.zoom?.panY || 0) * 100).toFixed(0)}%</span> <span className="text-xs text-editor-text w-10 text-right">{((options.zoom?.panY || 0) * 100).toFixed(0)}%</span>
</div> </div>
@ -340,6 +349,7 @@ export default function ExportDialog() {
checked={options.removeBackground || false} checked={options.removeBackground || false}
onChange={(e) => setOptions((o) => ({ ...o, removeBackground: e.target.checked }))} onChange={(e) => setOptions((o) => ({ ...o, removeBackground: e.target.checked }))}
className="w-4 h-4 rounded bg-editor-surface border-editor-border accent-editor-accent" className="w-4 h-4 rounded bg-editor-surface border-editor-border accent-editor-accent"
title="Remove or replace the background behind the speaker"
/> />
<div> <div>
<span className="text-xs font-medium flex items-center gap-1"> <span className="text-xs font-medium flex items-center gap-1">
@ -408,6 +418,7 @@ export default function ExportDialog() {
checked={options.normalizeAudio} checked={options.normalizeAudio}
onChange={(e) => setOptions((o) => ({ ...o, normalizeAudio: e.target.checked }))} onChange={(e) => setOptions((o) => ({ ...o, normalizeAudio: e.target.checked }))}
className="w-4 h-4 rounded bg-editor-surface border-editor-border accent-editor-accent" className="w-4 h-4 rounded bg-editor-surface border-editor-border accent-editor-accent"
title="Normalize audio to a consistent loudness target"
/> />
<div> <div>
<span className="text-xs font-medium">Normalize loudness</span> <span className="text-xs font-medium">Normalize loudness</span>
@ -423,6 +434,7 @@ export default function ExportDialog() {
value={options.normalizeTarget} value={options.normalizeTarget}
onChange={(e) => setOptions((o) => ({ ...o, normalizeTarget: Number(e.target.value) }))} onChange={(e) => setOptions((o) => ({ ...o, normalizeTarget: Number(e.target.value) }))}
className="flex-1 px-2 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:outline-none focus:border-editor-accent [color-scheme:dark]" className="flex-1 px-2 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:outline-none focus:border-editor-accent [color-scheme:dark]"
title="Loudness target — YouTube (-14), Spotify (-16), Broadcast (-23)"
> >
<option value={-14}>YouTube (-14 LUFS)</option> <option value={-14}>YouTube (-14 LUFS)</option>
<option value={-16}>Spotify (-16 LUFS)</option> <option value={-16}>Spotify (-16 LUFS)</option>
@ -441,6 +453,7 @@ export default function ExportDialog() {
checked={options.enhanceAudio} checked={options.enhanceAudio}
onChange={(e) => setOptions((o) => ({ ...o, enhanceAudio: e.target.checked }))} onChange={(e) => setOptions((o) => ({ ...o, enhanceAudio: e.target.checked }))}
className="w-4 h-4 rounded bg-editor-surface border-editor-border accent-editor-accent" className="w-4 h-4 rounded bg-editor-surface border-editor-border accent-editor-accent"
title="Apply noise reduction and speech enhancement"
/> />
<span className="text-xs">Enhance audio (Studio Sound)</span> <span className="text-xs">Enhance audio (Studio Sound)</span>
</label> </label>
@ -455,6 +468,7 @@ export default function ExportDialog() {
{ value: 'burn-in', label: 'Burn-in (permanent)' }, { value: 'burn-in', label: 'Burn-in (permanent)' },
{ value: 'sidecar', label: 'Sidecar SRT file' }, { value: 'sidecar', label: 'Sidecar SRT file' },
]} ]}
title="Burn captions into video, export as separate SRT/VTT file, or none"
/> />
{/* Transcript-only export */} {/* Transcript-only export */}
@ -479,6 +493,7 @@ export default function ExportDialog() {
onClick={handleTranscriptExport} onClick={handleTranscriptExport}
disabled={isTranscribingTranscript || words.length === 0} disabled={isTranscribingTranscript || words.length === 0}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-editor-accent/20 text-editor-accent hover:bg-editor-accent/30 disabled:opacity-40 transition-colors" className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-editor-accent/20 text-editor-accent hover:bg-editor-accent/30 disabled:opacity-40 transition-colors"
title="Export just the transcript text or subtitles without the video"
> >
{isTranscribingTranscript ? ( {isTranscribingTranscript ? (
<Loader2 className="w-3 h-3 animate-spin" /> <Loader2 className="w-3 h-3 animate-spin" />
@ -494,21 +509,31 @@ export default function ExportDialog() {
<button <button
onClick={handleExport} onClick={handleExport}
disabled={isExporting || !videoPath} disabled={isExporting || !videoPath}
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-50 rounded-lg text-sm font-semibold transition-colors" className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-40 rounded-lg text-sm font-semibold transition-colors"
title="Start export with current settings"
> >
{isExporting ? ( <Download className="w-4 h-4" />
<> Export Video
<Loader2 className="w-4 h-4 animate-spin" />
Exporting... {Math.round(exportProgress)}%
</>
) : (
<>
<Download className="w-4 h-4" />
Export Video
</>
)}
</button> </button>
{/* Export progress */}
{isExporting && (
<div className="space-y-2">
<div className="flex items-center gap-2">
<Loader2 className="w-4 h-4 animate-spin text-editor-accent" />
<span className="text-xs font-medium">Exporting...</span>
<span className="text-xs text-editor-text-muted">{Math.round(exportProgress)}%</span>
</div>
<div className="w-full h-2 bg-editor-border rounded-full overflow-hidden">
<div
className="h-full bg-editor-accent rounded-full transition-all duration-300"
style={{ width: `${exportProgress}%` }}
/>
</div>
<p className="text-xs text-editor-text-muted">Export in progress...</p>
</div>
)}
{exportError && ( {exportError && (
<div className="rounded border border-red-500/40 bg-red-500/10 px-3 py-2 text-xs text-red-300"> <div className="rounded border border-red-500/40 bg-red-500/10 px-3 py-2 text-xs text-red-300">
{exportError} {exportError}
@ -539,16 +564,19 @@ function ModeCard({
icon, icon,
title, title,
desc, desc,
tooltip,
}: { }: {
active: boolean; active: boolean;
onClick: () => void; onClick: () => void;
icon: React.ReactNode; icon: React.ReactNode;
title: string; title: string;
desc: string; desc: string;
tooltip?: string;
}) { }) {
return ( return (
<button <button
onClick={onClick} onClick={onClick}
title={tooltip}
className={`flex flex-col items-center gap-1 p-3 rounded-lg border-2 transition-colors ${ className={`flex flex-col items-center gap-1 p-3 rounded-lg border-2 transition-colors ${
active active
? 'border-editor-accent bg-editor-accent/10' ? 'border-editor-accent bg-editor-accent/10'
@ -567,16 +595,19 @@ function SelectField({
value, value,
onChange, onChange,
options, options,
title,
}: { }: {
label: string; label: string;
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
options: Array<{ value: string; label: string }>; options: Array<{ value: string; label: string }>;
title?: string;
}) { }) {
return ( return (
<div className="space-y-1"> <div className="space-y-1">
<label className="text-xs text-editor-text-muted font-medium">{label}</label> <label className="text-xs text-editor-text-muted font-medium">{label}</label>
<select <select
title={title}
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
className="w-full px-3 py-2 bg-editor-surface border border-editor-border rounded-lg text-xs text-editor-text focus:outline-none focus:border-editor-accent [color-scheme:dark]" className="w-full px-3 py-2 bg-editor-surface border border-editor-border rounded-lg text-xs text-editor-text focus:outline-none focus:border-editor-accent [color-scheme:dark]"

View File

@ -0,0 +1,156 @@
import { HelpCircle, Scissors, VolumeX, SlidersHorizontal, Gauge, Film, Search, FileText, Download, Music, MapPin, ListVideo, Sparkles, Keyboard } from 'lucide-react';
export default function HelpContent() {
return (
<div className="p-4 space-y-5 overflow-y-auto">
<h3 className="text-sm font-semibold flex items-center gap-1.5">
<HelpCircle className="w-4 h-4" />
Help &amp; Reference
</h3>
<Section title="Getting Started" icon={<Film className="w-3.5 h-3.5" />}>
<Step num={1}>Open a video file click <strong>File &gt; Open File</strong> or press <kbd>Ctrl+O</kbd></Step>
<Step num={2}>Wait for transcription Whisper processes your audio and creates a word-level transcript</Step>
<Step num={3}>Edit by selecting words choose <strong>Cut</strong>, <strong>Mute</strong>, <strong>Sound Gain</strong>, or <strong>Speed Adjust</strong> from the toolbar</Step>
<Step num={4}>Use AI tools detect filler words, find clips, re-transcribe with a different model</Step>
<Step num={5}>Export apply all edits and save your final video</Step>
<Step>Press <kbd>?</kbd> anytime to see all keyboard shortcuts</Step>
</Section>
<Section title="Cut / Mute / Sound Gain / Speed Adjust" icon={<Scissors className="w-3.5 h-3.5" />}>
<P>These are time-range edits applied during export. You create them in three ways:</P>
<Bullet>Select words in the transcript the toolbar buttons create a zone from the selected word range</Bullet>
<Bullet>Use <strong>Mark In</strong> (<kbd>I</kbd>) and <strong>Mark Out</strong> (<kbd>O</kbd>) on the timeline, then clicking the toolbar button</Bullet>
<Bullet>Click a toolbar button to enter <strong>zone mode</strong>, then drag on the waveform timeline to draw a zone</Bullet>
<P className="mt-2">
<strong>Cut</strong> removes the segment from the output entirely<br />
<strong>Mute</strong> silences the audio but keeps the video<br />
<strong>Sound Gain</strong> adjusts volume (positive = louder, negative = quieter)<br />
<strong>Speed Adjust</strong> changes playback speed (1.0x = normal, 2.0x = double)
</P>
<P>View and manage all zones in the <strong>Edit Zones</strong> panel. Click a zone on the waveform to select it drag edges to resize, drag the body to move.</P>
</Section>
<Section title="Waveform Timeline" icon={<Film className="w-3.5 h-3.5" />}>
<Bullet>Click to seek, drag to scrub through the video</Bullet>
<Bullet>Enter zone mode from the toolbar, then drag on the waveform to create a zone</Bullet>
<Bullet>Click an existing zone to select it drag edges to resize, drag body to move</Bullet>
<Bullet><kbd>Delete</kbd> or <kbd>Backspace</kbd> removes the selected zone (with confirmation)</Bullet>
<Bullet><kbd>Ctrl+Scroll</kbd> to zoom in/out, scroll to pan horizontally</Bullet>
<Bullet>Toggle individual zone types on/off with the colored buttons above the waveform</Bullet>
<Bullet>"Show adjusted timeline" compresses cut regions to preview the output</Bullet>
</Section>
<Section title="Transcript Editing" icon={<FileText className="w-3.5 h-3.5" />}>
<Bullet>Click a word to select it, <kbd>Shift+Click</kbd> to extend the selection</Bullet>
<Bullet><kbd>Ctrl+Click</kbd> any word to seek the video to that exact timestamp</Bullet>
<Bullet>Double-click any word to edit its text directly</Bullet>
<Bullet>Words with low confidence get an orange dotted underline adjust the threshold in Settings</Bullet>
<Bullet><kbd>Ctrl+F</kbd> to search the transcript navigate matches with <kbd>Enter</kbd> / <kbd>Shift+Enter</kbd></Bullet>
<Bullet>Select a word range and click <strong>Re-transcribe</strong> to re-run Whisper on just that segment</Bullet>
</Section>
<Section title="Chapter Marks" icon={<MapPin className="w-3.5 h-3.5" />}>
<Bullet>Add markers at the current playhead position with a label and color</Bullet>
<Bullet>Use <kbd>I</kbd> / <kbd>O</kbd> keys to set mark in/out points on the timeline</Bullet>
<Bullet>Markers auto-sort as chapters click <strong>Copy as YouTube timestamps</strong> to get chapter text</Bullet>
</Section>
<Section title="AI Tools" icon={<Sparkles className="w-3.5 h-3.5" />}>
<P><strong>Filler Words</strong> detects "um", "uh", "like", "you know" and similar words. Add custom fillers (e.g. "okay", "alright"). <strong>Apply All</strong> creates cut ranges for every detection at once.</P>
<P><strong>Create Clips</strong> analyzes your transcript to find the best 20-60 second segments for TikTok, YouTube Shorts, or Instagram Reels.</P>
<P><strong>Reprocess</strong> re-run transcription with a different Whisper model. Larger models are more accurate but slower. English-only models are faster for English content.</P>
<P>AI features work with the bundled local model (no setup needed), or via Ollama/OpenAI/Claude configure in Settings.</P>
</Section>
<Section title="Export" icon={<Download className="w-3.5 h-3.5" />}>
<Bullet><strong>Fast mode</strong> (stream copy): instant, no quality loss but doesn't apply cuts or effects</Bullet>
<Bullet><strong>Re-encode mode</strong>: applies all edits — cuts, gain, speed, zoom, captions, background music</Bullet>
<Bullet>Captions can be burned into the video or exported as separate SRT/VTT files</Bullet>
<Bullet>Loudness normalization targets: YouTube (-14 LUFS), Spotify (-16), Broadcast (-23)</Bullet>
<Bullet>Audio enhancement: noise reduction and speech clarity</Bullet>
<Bullet>Export Transcript Only — get SRT or plain text without the video</Bullet>
</Section>
<Section title="Background Music + Add Clips" icon={<Music className="w-3.5 h-3.5" />}>
<Bullet><strong>Bkg. Music</strong> — add a music track with auto-ducking: the music automatically lowers when someone speaks. Adjust volume, duck amount, attack, and release times.</Bullet>
<Bullet><strong>Add Clips</strong> — load additional video files to concatenate during export. Drag to reorder.</Bullet>
<Bullet>Both are applied during re-encode export only</Bullet>
</Section>
<Section title="Keyboard Shortcuts" icon={<Keyboard className="w-3.5 h-3.5" />}>
<P>Press <kbd>?</kbd> anytime to see the full cheatsheet overlay. Remap any shortcut in Settings.</P>
<div className="grid grid-cols-2 gap-1 mt-2">
<Shortcut keys="Space" desc="Play / Pause" />
<Shortcut keys="J K L" desc="Slow / Pause / Speed" />
<Shortcut keys="← →" desc="Skip 5s back / forward" />
<Shortcut keys="I / O" desc="Mark In / Out points" />
<Shortcut keys="Delete" desc="Cut selected / marked range" />
<Shortcut keys="Ctrl+Z" desc="Undo" />
<Shortcut keys="Ctrl+Shift+Z" desc="Redo" />
<Shortcut keys="Ctrl+S" desc="Save project" />
<Shortcut keys="Ctrl+E" desc="Export" />
<Shortcut keys="Ctrl+F" desc="Find in transcript" />
<Shortcut keys="?" desc="Toggle cheatsheet" />
</div>
<button
onClick={() => window.dispatchEvent(new KeyboardEvent('keydown', { key: '?' }))}
className="text-editor-accent hover:underline text-xs mt-2"
>
View full keyboard shortcut reference
</button>
</Section>
<div className="text-[10px] text-editor-text-muted leading-relaxed border-t border-editor-border pt-4">
TalkEdit is 100% offline. No account required. No data leaves your machine. No subscription — buy once, own forever.
</div>
</div>
);
}
function Section({ title, icon, children }: { title: string; icon: React.ReactNode; children: React.ReactNode }) {
return (
<div className="space-y-2 p-3 bg-editor-surface rounded-lg">
<h4 className="text-xs font-semibold flex items-center gap-1.5 text-editor-text">
{icon}
{title}
</h4>
<div className="space-y-1.5">
{children}
</div>
</div>
);
}
function P({ children, className = '' }: { children: React.ReactNode; className?: string }) {
return <p className={`text-xs text-editor-text-muted leading-relaxed ${className}`}>{children}</p>;
}
function Bullet({ children }: { children: React.ReactNode }) {
return (
<div className="flex items-start gap-1.5">
<span className="text-editor-accent mt-1.5 w-1 h-1 rounded-full bg-editor-accent shrink-0" />
<span className="text-xs text-editor-text-muted leading-relaxed">{children}</span>
</div>
);
}
function Step({ num, children }: { num?: number; children: React.ReactNode }) {
return (
<div className="flex items-start gap-2">
<span className="w-5 h-5 rounded-full bg-editor-accent/20 text-editor-accent text-[10px] font-semibold flex items-center justify-center shrink-0 mt-0.5">
{num}
</span>
<span className="text-xs text-editor-text-muted leading-relaxed">{children}</span>
</div>
);
}
function Shortcut({ keys, desc }: { keys: string; desc: string }) {
return (
<div className="flex items-center gap-2 text-xs">
<kbd className="px-1.5 py-0.5 text-[10px] font-mono bg-editor-bg border border-editor-border rounded text-editor-text min-w-[72px] text-center">{keys}</kbd>
<span className="text-editor-text-muted">{desc}</span>
</div>
);
}

View File

@ -0,0 +1,296 @@
import { useState } from 'react';
import { useLicenseStore } from '../store/licenseStore';
import { Key, Check, X, Loader2, Shield, Clock, AlertTriangle } from 'lucide-react';
export default function LicenseDialog() {
const { status, showDialog, setShowDialog, activateLicense } = useLicenseStore();
const [key, setKey] = useState('');
const [error, setError] = useState<string | null>(null);
const [activating, setActivating] = useState(false);
const [confirmedEmail, setConfirmedEmail] = useState<string | null>(null);
const [verifying, setVerifying] = useState(false);
const handleActivate = async () => {
if (!key.trim()) return;
setError(null);
// If we already verified and the user confirmed, complete activation
if (confirmedEmail) {
setActivating(true);
const ok = await activateLicense(key.trim());
if (!ok) {
setError('Invalid license key. Check it was entered correctly.');
}
setActivating(false);
return;
}
// Step 1: Verify the key (don't cache yet) to get the email
setVerifying(true);
try {
const payload = await window.electronAPI?.verifyLicense(key.trim());
if (payload?.customer_email) {
setConfirmedEmail(payload.customer_email);
} else {
setError('Invalid license key. Check it was entered correctly.');
}
} catch {
setError('Invalid license key. Check it was entered correctly.');
}
setVerifying(false);
};
const handleDeny = () => {
setConfirmedEmail(null);
setKey('');
setError(null);
};
const formatDate = (ts: number) => {
const d = new Date(ts * 1000);
return d.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
};
if (!status) return null;
if (status.tag === 'Licensed') {
return (
<div className="fixed bottom-4 right-4 z-50">
<div className="flex items-center gap-2 px-3 py-2 rounded-lg bg-editor-surface border border-editor-border shadow-lg text-xs">
<Shield className="w-3.5 h-3.5 text-editor-success" />
<span className="text-editor-text-muted">
{status.license.tier === 'business' ? 'Business' : 'Pro'} {status.license.customer_email}
</span>
<span className="text-editor-text-muted/50">
expires {formatDate(status.license.expires_at)}
</span>
</div>
</div>
);
}
if (status.tag === 'Trial') {
return (
<>
<div className="fixed bottom-4 right-4 z-50">
<button
onClick={() => setShowDialog(true)}
className="flex items-center gap-2 px-3 py-2 rounded-lg bg-editor-surface border border-editor-border shadow-lg text-xs hover:bg-editor-bg transition-colors"
>
<Clock className="w-3.5 h-3.5 text-editor-accent" />
<span className="text-editor-text-muted">
Trial {status.days_remaining} day{status.days_remaining !== 1 ? 's' : ''} left
</span>
</button>
</div>
{showDialog && (
<LicenseActivateDialog
onClose={() => { setShowDialog(false); handleDeny(); }}
onActivate={handleActivate}
onDeny={handleDeny}
keyValue={key}
setKeyValue={setKey}
error={error}
activating={activating}
verifying={verifying}
confirmedEmail={confirmedEmail}
trialEnding={status.days_remaining <= 3}
/>
)}
</>
);
}
// Expired — show banner + activation dialog (both dismissible)
return (
<>
<ExpiredBanner onActivate={() => setShowDialog(true)} />
{showDialog && (
<LicenseActivateDialog
onClose={() => { setShowDialog(false); handleDeny(); }}
onActivate={handleActivate}
onDeny={handleDeny}
keyValue={key}
setKeyValue={setKey}
error={error}
activating={activating}
verifying={verifying}
confirmedEmail={confirmedEmail}
expired
/>
)}
</>
);
}
/** Persistent top banner shown when trial expired — still allows export and loading */
function ExpiredBanner({ onActivate }: { onActivate: () => void }) {
return (
<div className="h-9 flex items-center justify-center gap-3 px-4 bg-red-500/15 border-b border-red-500/30 shrink-0">
<AlertTriangle className="w-3.5 h-3.5 text-red-400 shrink-0" />
<span className="text-xs text-red-300">
Trial expired export and project loading still work.&nbsp;
<button onClick={onActivate} className="underline font-medium hover:text-red-200">
Activate license
</button>
&nbsp;to restore editing.
</span>
</div>
);
}
function LicenseActivateDialog({
onClose,
onActivate,
onDeny,
keyValue,
setKeyValue,
error,
activating,
verifying,
confirmedEmail,
trialEnding,
expired,
}: {
onClose: () => void;
onActivate: () => void;
onDeny: () => void;
keyValue: string;
setKeyValue: (v: string) => void;
error: string | null;
activating: boolean;
verifying: boolean;
confirmedEmail: string | null;
trialEnding?: boolean;
expired?: boolean;
}) {
const isProcessing = activating || verifying;
if (confirmedEmail) {
return (
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/60 px-4">
<div
className="w-full max-w-md rounded-xl border border-editor-border bg-editor-bg p-6 space-y-4"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center gap-2">
<Shield className="w-5 h-5 text-editor-accent" />
<h3 className="text-sm font-semibold">Confirm License</h3>
</div>
<div className="p-3 rounded-lg bg-editor-surface border border-editor-border space-y-1">
<p className="text-xs text-editor-text-muted">
This license key is registered to:
</p>
<p className="text-sm font-medium text-editor-text">{confirmedEmail}</p>
</div>
<p className="text-xs text-editor-text-muted leading-relaxed">
License keys are tied to your email. Sharing this key may result in deactivation.
</p>
<div className="flex items-center justify-end gap-2 pt-1">
<button
onClick={onDeny}
className="px-3 py-1.5 rounded-md text-xs text-editor-text-muted hover:text-editor-text hover:bg-editor-surface"
>
Cancel
</button>
<button
onClick={onActivate}
disabled={activating}
className="px-4 py-2 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-40 rounded-lg text-sm font-medium transition-colors flex items-center gap-2"
>
{activating ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Check className="w-4 h-4" />
)}
Activate
</button>
</div>
</div>
</div>
);
}
return (
<div className="fixed inset-0 z-[80] flex items-center justify-center bg-black/60 px-4">
<div
className="w-full max-w-md rounded-xl border border-editor-border bg-editor-bg p-6 space-y-4"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Key className="w-5 h-5 text-editor-accent" />
<h3 className="text-sm font-semibold">
{expired ? 'Trial Expired' : 'Activate TalkEdit'}
</h3>
</div>
<button
onClick={onClose}
className="p-1 rounded hover:bg-editor-surface text-editor-text-muted"
title="Close dialog"
>
<X className="w-4 h-4" />
</button>
</div>
{expired && (
<div className="text-xs text-editor-text-muted leading-relaxed space-y-1">
<p className="font-medium text-red-300">Your 30-day trial has ended.</p>
<p>
You can still <strong>export videos</strong> and <strong>load projects</strong>.
Enter a license key to restore editing, AI tools, and all other features.
</p>
</div>
)}
{trialEnding && !expired && (
<div className="flex items-start gap-2 p-3 rounded-lg bg-amber-500/10 border border-amber-500/30">
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0 mt-0.5" />
<p className="text-xs text-amber-300">Your trial ends soon. Activate now to keep using all features.</p>
</div>
)}
{!expired && !trialEnding && (
<p className="text-xs text-editor-text-muted leading-relaxed">
Enter your license key to activate TalkEdit Pro or Business.
You received this key by email after purchase.
</p>
)}
<div className="space-y-1.5">
<label className="text-xs text-editor-text-muted font-medium">License Key</label>
<textarea
value={keyValue}
onChange={(e) => { setKeyValue(e.target.value); }}
placeholder="talkedit_v1_..."
rows={3}
className="w-full px-3 py-2 text-xs font-mono bg-editor-surface border border-editor-border rounded-lg text-editor-text placeholder:text-editor-text-muted/50 focus:outline-none focus:border-editor-accent resize-none"
/>
{error && <p className="text-xs text-red-400">{error}</p>}
</div>
<button
onClick={onActivate}
disabled={isProcessing || !keyValue.trim()}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-40 rounded-lg text-sm font-medium transition-colors"
>
{isProcessing ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Key className="w-4 h-4" />
)}
{verifying ? 'Verifying...' : 'Verify Key'}
</button>
<p className="text-[10px] text-editor-text-muted text-center">
No license? <a href="#" className="text-editor-accent hover:underline">Purchase at talked.it</a>
</p>
</div>
</div>
);
}

View File

@ -2,6 +2,17 @@ import { useState } from 'react';
import { useEditorStore } from '../store/editorStore'; import { useEditorStore } from '../store/editorStore';
import { MapPin, Trash2, PencilLine, Check, X, Copy } from 'lucide-react'; import { MapPin, Trash2, PencilLine, Check, X, Copy } from 'lucide-react';
const COLOR_NAMES: Record<string, string> = {
'#6366f1': 'Indigo',
'#ef4444': 'Red',
'#22c55e': 'Green',
'#f59e0b': 'Amber',
'#3b82f6': 'Blue',
'#ec4899': 'Pink',
'#8b5cf6': 'Purple',
'#14b8a6': 'Teal',
};
const COLORS = ['#6366f1', '#ef4444', '#22c55e', '#f59e0b', '#3b82f6', '#ec4899', '#8b5cf6', '#14b8a6']; const COLORS = ['#6366f1', '#ef4444', '#22c55e', '#f59e0b', '#3b82f6', '#ec4899', '#8b5cf6', '#14b8a6'];
export default function MarkersPanel() { export default function MarkersPanel() {
@ -73,6 +84,7 @@ export default function MarkersPanel() {
onClick={() => setNewColor(c)} onClick={() => setNewColor(c)}
className={`w-4 h-4 rounded-full border ${newColor === c ? 'border-white ring-1 ring-white' : 'border-transparent'}`} className={`w-4 h-4 rounded-full border ${newColor === c ? 'border-white ring-1 ring-white' : 'border-transparent'}`}
style={{ backgroundColor: c }} style={{ backgroundColor: c }}
title={COLOR_NAMES[c]}
/> />
))} ))}
</div> </div>
@ -80,6 +92,7 @@ export default function MarkersPanel() {
<button <button
onClick={addAtCurrentTime} onClick={addAtCurrentTime}
className="w-full flex items-center justify-center gap-1 px-2 py-1.5 text-xs bg-editor-accent/20 text-editor-accent hover:bg-editor-accent/30 rounded" className="w-full flex items-center justify-center gap-1 px-2 py-1.5 text-xs bg-editor-accent/20 text-editor-accent hover:bg-editor-accent/30 rounded"
title="Add a marker at the current playhead position"
> >
<MapPin className="w-3 h-3" /> <MapPin className="w-3 h-3" />
Add Add
@ -87,7 +100,7 @@ export default function MarkersPanel() {
</div> </div>
{/* Marker list */} {/* Marker list */}
{timelineMarkers.length > 0 && ( {timelineMarkers.length > 0 ? (
<div className="space-y-1 max-h-60 overflow-y-auto"> <div className="space-y-1 max-h-60 overflow-y-auto">
{timelineMarkers.map((m) => ( {timelineMarkers.map((m) => (
<div <div
@ -110,13 +123,19 @@ export default function MarkersPanel() {
) : ( ) : (
<> <>
<span className="flex-1 truncate">{m.label}</span> <span className="flex-1 truncate">{m.label}</span>
<button onClick={() => startEdit(m.id, m.label)} className="p-0.5 hover:text-editor-accent"><PencilLine className="w-3 h-3" /></button> <button onClick={() => startEdit(m.id, m.label)} className="p-0.5 hover:text-editor-accent" title="Edit marker label and color"><PencilLine className="w-3 h-3" /></button>
<button onClick={() => removeTimelineMarker(m.id)} className="p-0.5 hover:text-editor-danger"><Trash2 className="w-3 h-3" /></button> <button onClick={() => { if (window.confirm("Delete this marker?")) removeTimelineMarker(m.id); }} className="p-0.5 hover:text-editor-danger" title="Delete this marker"><Trash2 className="w-3 h-3" /></button>
</> </>
)} )}
</div> </div>
))} ))}
</div> </div>
) : (
<div className="p-4 rounded border border-dashed border-editor-border text-center">
<p className="text-xs text-editor-text-muted">
No markers yet. Press I and O on the timeline to set mark in/out points, then add a marker here.
</p>
</div>
)} )}
{/* Chapters */} {/* Chapters */}
@ -138,6 +157,7 @@ export default function MarkersPanel() {
<button <button
onClick={exportChapters} onClick={exportChapters}
className="flex items-center gap-1 text-[10px] text-editor-accent hover:underline" className="flex items-center gap-1 text-[10px] text-editor-accent hover:underline"
title="Copy chapter timestamps to clipboard in YouTube format"
> >
<Copy className="w-2.5 h-2.5" /> <Copy className="w-2.5 h-2.5" />
Copy as YouTube timestamps Copy as YouTube timestamps

View File

@ -2,7 +2,7 @@ import { useAIStore } from '../store/aiStore';
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import type { AIProvider, KeyBinding, HotkeyPreset } from '../types/project'; import type { AIProvider, KeyBinding, HotkeyPreset } from '../types/project';
import { useEditorStore } from '../store/editorStore'; import { useEditorStore } from '../store/editorStore';
import { Bot, Cloud, Brain, RefreshCw, Keyboard } from 'lucide-react'; import { Bot, Cloud, Brain, RefreshCw, Keyboard, Trash2, HardDrive } from 'lucide-react';
import { loadBindings, saveBindings, applyPreset as applyKeyPreset, DEFAULT_PRESETS, detectConflicts as detectKeyConflicts } from '../lib/keybindings'; import { loadBindings, saveBindings, applyPreset as applyKeyPreset, DEFAULT_PRESETS, detectConflicts as detectKeyConflicts } from '../lib/keybindings';
export default function SettingsPanel() { export default function SettingsPanel() {
@ -65,11 +65,51 @@ export default function SettingsPanel() {
persistBindings(bindings.map((b, i) => (i === idx ? { ...existing } : b))); persistBindings(bindings.map((b, i) => (i === idx ? { ...existing } : b)));
}; };
const [ollamaModels, setOllamaModels] = useState<string[]>([]); const [models, setModels] = useState<ModelInfo[]>([]);
const [loadingModels, setLoadingModels] = useState(false); const [loadingModels, setLoadingModels] = useState(false);
const [deleting, setDeleting] = useState<string | null>(null);
const fetchModels = useCallback(async () => {
setLoadingModels(true);
try {
const list = await window.electronAPI.listModels();
setModels(list);
} catch {
setModels([]);
} finally {
setLoadingModels(false);
}
}, []);
useEffect(() => {
fetchModels();
}, [fetchModels]);
const handleDeleteModel = useCallback(async (model: ModelInfo) => {
if (deleting) return;
setDeleting(model.path);
try {
await window.electronAPI.deleteModel(model.path);
setModels((prev) => prev.filter((m) => m.path !== model.path));
} catch {
// Model deletion failed silently
} finally {
setDeleting(null);
}
}, [deleting]);
const formatBytes = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
};
const [ollamaModels, setOllamaModels] = useState<string[]>([]);
const [loadingOllamaModels, setLoadingOllamaModels] = useState(false);
const fetchOllamaModels = useCallback(async () => { const fetchOllamaModels = useCallback(async () => {
setLoadingModels(true); setLoadingOllamaModels(true);
try { try {
const res = await fetch(`${backendUrl}/ai/ollama-models`); const res = await fetch(`${backendUrl}/ai/ollama-models`);
if (res.ok) { if (res.ok) {
@ -79,7 +119,7 @@ export default function SettingsPanel() {
} catch { } catch {
setOllamaModels([]); setOllamaModels([]);
} finally { } finally {
setLoadingModels(false); setLoadingOllamaModels(false);
} }
}, [backendUrl]); }, [backendUrl]);
@ -109,6 +149,7 @@ export default function SettingsPanel() {
value={zonePreviewPaddingSeconds} value={zonePreviewPaddingSeconds}
onChange={(e) => setZonePreviewPaddingSeconds(Number(e.target.value) || 0)} onChange={(e) => setZonePreviewPaddingSeconds(Number(e.target.value) || 0)}
className="flex-1 h-1.5" className="flex-1 h-1.5"
title="Extra time in seconds to show before and after each zone during preview"
/> />
<input <input
type="number" type="number"
@ -118,6 +159,7 @@ export default function SettingsPanel() {
value={zonePreviewPaddingSeconds} value={zonePreviewPaddingSeconds}
onChange={(e) => setZonePreviewPaddingSeconds(Number(e.target.value) || 0)} onChange={(e) => setZonePreviewPaddingSeconds(Number(e.target.value) || 0)}
className="w-16 px-2 py-1 bg-editor-bg border border-editor-border rounded-lg text-xs text-editor-text focus:outline-none focus:border-editor-accent" className="w-16 px-2 py-1 bg-editor-bg border border-editor-border rounded-lg text-xs text-editor-text focus:outline-none focus:border-editor-accent"
title="Extra time in seconds to show before and after each zone during preview"
/> />
<span className="text-xs text-editor-text-muted w-6">s</span> <span className="text-xs text-editor-text-muted w-6">s</span>
</div> </div>
@ -140,6 +182,7 @@ export default function SettingsPanel() {
value={confidenceThreshold} value={confidenceThreshold}
onChange={(e) => setConfidenceThreshold(Number(e.target.value))} onChange={(e) => setConfidenceThreshold(Number(e.target.value))}
className="flex-1 h-1.5" className="flex-1 h-1.5"
title="Words below this confidence get an orange underline — lower values show fewer warnings"
/> />
<input <input
type="number" type="number"
@ -149,6 +192,7 @@ export default function SettingsPanel() {
value={confidenceThreshold} value={confidenceThreshold}
onChange={(e) => setConfidenceThreshold(Math.max(0, Math.min(1, Number(e.target.value) || 0)))} onChange={(e) => setConfidenceThreshold(Math.max(0, Math.min(1, Number(e.target.value) || 0)))}
className="w-16 px-2 py-1 bg-editor-bg border border-editor-border rounded-lg text-xs text-editor-text focus:outline-none focus:border-editor-accent" className="w-16 px-2 py-1 bg-editor-bg border border-editor-border rounded-lg text-xs text-editor-text focus:outline-none focus:border-editor-accent"
title="Words below this confidence get an orange underline — lower values show fewer warnings"
/> />
</div> </div>
<div className="flex items-center justify-between text-[10px]"> <div className="flex items-center justify-between text-[10px]">
@ -168,12 +212,14 @@ export default function SettingsPanel() {
<button <button
onClick={() => applyPresetAction('standard')} onClick={() => applyPresetAction('standard')}
className="flex-1 px-2 py-1.5 text-xs rounded bg-editor-accent/20 text-editor-accent hover:bg-editor-accent/30" className="flex-1 px-2 py-1.5 text-xs rounded bg-editor-accent/20 text-editor-accent hover:bg-editor-accent/30"
title="Reset all shortcuts to the Standard preset"
> >
Standard Preset Standard Preset
</button> </button>
<button <button
onClick={() => applyPresetAction('left-hand')} onClick={() => applyPresetAction('left-hand')}
className="flex-1 px-2 py-1.5 text-xs rounded bg-editor-accent/20 text-editor-accent hover:bg-editor-accent/30" className="flex-1 px-2 py-1.5 text-xs rounded bg-editor-accent/20 text-editor-accent hover:bg-editor-accent/30"
title="Reset all shortcuts to the Left-Hand preset"
> >
Left-Hand Preset Left-Hand Preset
</button> </button>
@ -197,10 +243,12 @@ export default function SettingsPanel() {
onKeyDown={(e) => handleKeyCapture(e, i)} onKeyDown={(e) => handleKeyCapture(e, i)}
className="w-28 px-2 py-1 text-[10px] font-mono bg-editor-bg border border-editor-border rounded text-center focus:outline-none focus:border-editor-accent" className="w-28 px-2 py-1 text-[10px] font-mono bg-editor-bg border border-editor-border rounded text-center focus:outline-none focus:border-editor-accent"
placeholder="Type shortcut" placeholder="Type shortcut"
title="Click then press the desired key combination"
/> />
<button <button
onClick={() => handleReset(i)} onClick={() => handleReset(i)}
className="text-[10px] text-editor-text-muted hover:text-editor-text px-1" className="text-[10px] text-editor-text-muted hover:text-editor-text px-1"
title="Reset this shortcut to default"
> >
</button> </button>
@ -220,6 +268,11 @@ export default function SettingsPanel() {
<button <button
key={p} key={p}
onClick={() => setDefaultProvider(p)} onClick={() => setDefaultProvider(p)}
title={`Use ${p.charAt(0).toUpperCase() + p.slice(1)} for AI features — ${
p === 'ollama' ? 'Use a local Ollama instance' :
p === 'openai' ? "Use OpenAI's API (requires API key)" :
"Use Anthropic's Claude API (requires API key)"
}`}
className={`flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors text-[10px] ${ className={`flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors text-[10px] ${
defaultProvider === p defaultProvider === p
? 'border-editor-accent bg-editor-accent/10 text-editor-accent' ? 'border-editor-accent bg-editor-accent/10 text-editor-accent'
@ -233,6 +286,50 @@ export default function SettingsPanel() {
</div> </div>
</div> </div>
{/* Manage downloaded models */}
<div className="space-y-2 pt-1 border-t border-editor-border">
<h4 className="text-xs font-semibold flex items-center gap-1.5">
<HardDrive className="w-3.5 h-3.5" />
Manage Models
</h4>
<p className="text-[10px] text-editor-text-muted leading-relaxed">
Downloaded Whisper transcription models and bundled LLM files.
</p>
{models.length === 0 ? (
<p className="text-xs text-editor-text-muted">No downloaded models found.</p>
) : (
<div className="space-y-1.5">
{models.map((m) => (
<div key={m.path} className="flex items-center gap-2 p-2 rounded bg-editor-bg border border-editor-border">
<div className="flex-1 min-w-0">
<p className="text-xs text-editor-text truncate">{m.name}</p>
<p className="text-[10px] text-editor-text-muted">
{formatBytes(m.size_bytes)} &middot; {m.kind === 'whisper' ? 'Whisper' : 'LLM'}
</p>
</div>
<button
onClick={() => handleDeleteModel(m)}
disabled={deleting === m.path}
className="p-1.5 rounded text-editor-text-muted hover:text-red-400 hover:bg-red-500/10 transition-colors disabled:opacity-40"
title="Delete model"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
)}
<button
onClick={fetchModels}
disabled={loadingModels}
className="text-[10px] text-editor-accent hover:underline flex items-center gap-0.5"
title="Refresh list of downloaded models"
>
<RefreshCw className={`w-2.5 h-2.5 ${loadingModels ? 'animate-spin' : ''}`} />
Refresh
</button>
</div>
<h4 className="text-xs font-semibold uppercase tracking-wide text-editor-text-muted">AI Settings</h4> <h4 className="text-xs font-semibold uppercase tracking-wide text-editor-text-muted">AI Settings</h4>
{/* Ollama settings */} {/* Ollama settings */}
@ -242,16 +339,18 @@ export default function SettingsPanel() {
value={providers.ollama.baseUrl || ''} value={providers.ollama.baseUrl || ''}
onChange={(v) => setProviderConfig('ollama', { baseUrl: v })} onChange={(v) => setProviderConfig('ollama', { baseUrl: v })}
placeholder="http://localhost:11434" placeholder="http://localhost:11434"
title="URL of your Ollama instance — http://localhost:11434 by default"
/> />
<div className="space-y-1"> <div className="space-y-1">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<label className="text-xs text-editor-text-muted">Model</label> <label className="text-xs text-editor-text-muted">Model</label>
<button <button
onClick={fetchOllamaModels} onClick={fetchOllamaModels}
disabled={loadingModels} disabled={loadingOllamaModels}
className="text-[10px] text-editor-accent hover:underline flex items-center gap-0.5" className="text-[10px] text-editor-accent hover:underline flex items-center gap-0.5"
title="Refresh available Ollama models"
> >
<RefreshCw className={`w-2.5 h-2.5 ${loadingModels ? 'animate-spin' : ''}`} /> <RefreshCw className={`w-2.5 h-2.5 ${loadingOllamaModels ? 'animate-spin' : ''}`} />
Refresh Refresh
</button> </button>
</div> </div>
@ -260,6 +359,7 @@ export default function SettingsPanel() {
value={providers.ollama.model} value={providers.ollama.model}
onChange={(e) => setProviderConfig('ollama', { model: e.target.value })} onChange={(e) => setProviderConfig('ollama', { model: e.target.value })}
className="w-full px-3 py-2 bg-editor-surface border border-editor-border rounded-lg text-xs text-white focus:outline-none focus:border-editor-accent" className="w-full px-3 py-2 bg-editor-surface border border-editor-border rounded-lg text-xs text-white focus:outline-none focus:border-editor-accent"
title="Which Ollama model to use for AI features"
> >
{ollamaModels.map((m) => ( {ollamaModels.map((m) => (
<option key={m} value={m}>{m}</option> <option key={m} value={m}>{m}</option>
@ -271,6 +371,7 @@ export default function SettingsPanel() {
value={providers.ollama.model} value={providers.ollama.model}
onChange={(v) => setProviderConfig('ollama', { model: v })} onChange={(v) => setProviderConfig('ollama', { model: v })}
placeholder="llama3" placeholder="llama3"
title="Which Ollama model to use for AI features"
/> />
)} )}
</div> </div>
@ -284,12 +385,14 @@ export default function SettingsPanel() {
onChange={(v) => setProviderConfig('openai', { apiKey: v })} onChange={(v) => setProviderConfig('openai', { apiKey: v })}
placeholder="sk-..." placeholder="sk-..."
type="password" type="password"
title="Your OpenAI API key — stored encrypted on your machine"
/> />
<InputField <InputField
label="Model" label="Model"
value={providers.openai.model} value={providers.openai.model}
onChange={(v) => setProviderConfig('openai', { model: v })} onChange={(v) => setProviderConfig('openai', { model: v })}
placeholder="gpt-4o" placeholder="gpt-4o"
title="OpenAI model to use (e.g. gpt-4o, gpt-4o-mini)"
/> />
</ProviderSection> </ProviderSection>
@ -301,12 +404,14 @@ export default function SettingsPanel() {
onChange={(v) => setProviderConfig('claude', { apiKey: v })} onChange={(v) => setProviderConfig('claude', { apiKey: v })}
placeholder="sk-ant-..." placeholder="sk-ant-..."
type="password" type="password"
title="Your Anthropic Claude API key — stored encrypted on your machine"
/> />
<InputField <InputField
label="Model" label="Model"
value={providers.claude.model} value={providers.claude.model}
onChange={(v) => setProviderConfig('claude', { model: v })} onChange={(v) => setProviderConfig('claude', { model: v })}
placeholder="claude-sonnet-4-20250514" placeholder="claude-sonnet-4-20250514"
title="Claude model to use (e.g. claude-sonnet-4-20250514)"
/> />
</ProviderSection> </ProviderSection>
</div> </div>
@ -339,12 +444,14 @@ function InputField({
onChange, onChange,
placeholder, placeholder,
type = 'text', type = 'text',
title,
}: { }: {
label: string; label: string;
value: string; value: string;
onChange: (value: string) => void; onChange: (value: string) => void;
placeholder: string; placeholder: string;
type?: string; type?: string;
title?: string;
}) { }) {
return ( return (
<div className="space-y-1"> <div className="space-y-1">
@ -354,6 +461,7 @@ function InputField({
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
placeholder={placeholder} placeholder={placeholder}
title={title}
className="w-full px-3 py-2 bg-editor-bg border border-editor-border rounded-lg text-xs text-editor-text placeholder:text-editor-text-muted/50 focus:outline-none focus:border-editor-accent" className="w-full px-3 py-2 bg-editor-bg border border-editor-border rounded-lg text-xs text-editor-text placeholder:text-editor-text-muted/50 focus:outline-none focus:border-editor-accent"
/> />
</div> </div>

View File

@ -134,6 +134,7 @@ export default function SilenceTrimmerPanel() {
value={minSilenceMs} value={minSilenceMs}
onChange={(e) => setMinSilenceMs(Number(e.target.value) || 500)} onChange={(e) => setMinSilenceMs(Number(e.target.value) || 500)}
className="w-full px-2.5 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none" className="w-full px-2.5 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
title="Minimum duration of silence to detect in milliseconds"
/> />
</div> </div>
@ -149,6 +150,7 @@ export default function SilenceTrimmerPanel() {
value={silenceDb} value={silenceDb}
onChange={(e) => setSilenceDb(Number(e.target.value) || -35)} onChange={(e) => setSilenceDb(Number(e.target.value) || -35)}
className="w-full px-2.5 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none" className="w-full px-2.5 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
title="Volume threshold in dB — lower values detect quieter sounds as silence"
/> />
</div> </div>
@ -165,6 +167,7 @@ export default function SilenceTrimmerPanel() {
value={preBufferMs} value={preBufferMs}
onChange={(e) => setPreBufferMs(Number(e.target.value) || 0)} onChange={(e) => setPreBufferMs(Number(e.target.value) || 0)}
className="w-full px-2.5 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none" className="w-full px-2.5 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
title="Extra time to add before each detected silence"
/> />
</div> </div>
<div className="space-y-1.5"> <div className="space-y-1.5">
@ -179,6 +182,7 @@ export default function SilenceTrimmerPanel() {
value={postBufferMs} value={postBufferMs}
onChange={(e) => setPostBufferMs(Number(e.target.value) || 0)} onChange={(e) => setPostBufferMs(Number(e.target.value) || 0)}
className="w-full px-2.5 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none" className="w-full px-2.5 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
title="Extra time to add after each detected silence"
/> />
</div> </div>
</div> </div>
@ -186,7 +190,8 @@ export default function SilenceTrimmerPanel() {
<button <button
onClick={detectSilence} onClick={detectSilence}
disabled={isDetecting || !videoPath} disabled={isDetecting || !videoPath}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-50 rounded-lg text-sm font-medium transition-colors" className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-40 rounded-lg text-sm font-medium transition-colors"
title="Scan the entire audio track for silent pauses"
> >
{isDetecting ? ( {isDetecting ? (
<> <>
@ -214,6 +219,7 @@ export default function SilenceTrimmerPanel() {
<button <button
onClick={reapplySelectedGroup} onClick={reapplySelectedGroup}
className="flex items-center gap-1 px-2 py-1 text-xs bg-editor-warning/20 text-editor-warning rounded hover:bg-editor-warning/30" className="flex items-center gap-1 px-2 py-1 text-xs bg-editor-warning/20 text-editor-warning rounded hover:bg-editor-warning/30"
title="Re-apply this silence trim group with current settings"
> >
<RotateCcw className="w-3 h-3" /> <RotateCcw className="w-3 h-3" />
Reapply Group Reapply Group
@ -222,6 +228,7 @@ export default function SilenceTrimmerPanel() {
<button <button
onClick={applyAsNewGroup} onClick={applyAsNewGroup}
className="flex items-center gap-1 px-2 py-1 text-xs bg-editor-accent/20 text-editor-accent rounded hover:bg-editor-accent/30" className="flex items-center gap-1 px-2 py-1 text-xs bg-editor-accent/20 text-editor-accent rounded hover:bg-editor-accent/30"
title="Create a new silence trim group from detected pauses"
> >
<Scissors className="w-3 h-3" /> <Scissors className="w-3 h-3" />
Apply As New Group Apply As New Group

View File

@ -1,7 +1,9 @@
import { useCallback, useRef, useEffect, useMemo, useState } from 'react'; import { useCallback, useRef, useEffect, useMemo, useState } from 'react';
import { useEditorStore } from '../store/editorStore'; import { useEditorStore } from '../store/editorStore';
import { useLicenseStore } from '../store/licenseStore';
import { Virtuoso } from 'react-virtuoso'; import { Virtuoso } from 'react-virtuoso';
import { Scissors, VolumeX, SlidersHorizontal, Gauge, RotateCcw, Search, ChevronUp, ChevronDown, X, RefreshCw } from 'lucide-react'; import { Scissors, VolumeX, SlidersHorizontal, Gauge, RotateCcw, Search, ChevronUp, ChevronDown, X, RefreshCw } from 'lucide-react';
import { assert } from '../lib/assert';
interface TranscriptEditorProps { interface TranscriptEditorProps {
cutMode: boolean; cutMode: boolean;
@ -42,6 +44,7 @@ export default function TranscriptEditor({
const addGainRange = useEditorStore((s) => s.addGainRange); const addGainRange = useEditorStore((s) => s.addGainRange);
const addSpeedRange = useEditorStore((s) => s.addSpeedRange); const addSpeedRange = useEditorStore((s) => s.addSpeedRange);
const getWordAtTime = useEditorStore((s) => s.getWordAtTime); const getWordAtTime = useEditorStore((s) => s.getWordAtTime);
const canEdit = useLicenseStore((s) => s.canEdit);
const selectionStart = useRef<number | null>(null); const selectionStart = useRef<number | null>(null);
const wasDragging = useRef(false); const wasDragging = useRef(false);
@ -204,9 +207,11 @@ export default function TranscriptEditor({
const handleMouseUp = useCallback(() => { const handleMouseUp = useCallback(() => {
if (zoneDragStart.current !== null && zoneDragRange) { if (zoneDragStart.current !== null && zoneDragRange) {
assert(zoneDragRange.start >= 0 && zoneDragRange.start < words.length, 'handleMouseUp: zoneDragRange.start out of bounds');
assert(zoneDragRange.end >= 0 && zoneDragRange.end < words.length, 'handleMouseUp: zoneDragRange.end out of bounds');
const startWord = words[zoneDragRange.start]; const startWord = words[zoneDragRange.start];
const endWord = words[zoneDragRange.end]; const endWord = words[zoneDragRange.end];
if (startWord && endWord) { if (startWord && endWord && canEdit) {
if (cutMode) addCutRange(startWord.start, endWord.end); if (cutMode) addCutRange(startWord.start, endWord.end);
if (muteMode) addMuteRange(startWord.start, endWord.end); if (muteMode) addMuteRange(startWord.start, endWord.end);
if (gainMode) addGainRange(startWord.start, endWord.end, gainModeDb); if (gainMode) addGainRange(startWord.start, endWord.end, gainModeDb);
@ -216,7 +221,7 @@ export default function TranscriptEditor({
zoneDragStart.current = null; zoneDragStart.current = null;
setZoneDragRange(null); setZoneDragRange(null);
selectionStart.current = null; selectionStart.current = null;
}, [zoneDragRange, words, cutMode, muteMode, gainMode, gainModeDb, speedMode, speedModeValue, addCutRange, addMuteRange, addGainRange, addSpeedRange]); }, [zoneDragRange, words, cutMode, muteMode, gainMode, gainModeDb, speedMode, speedModeValue, addCutRange, addMuteRange, addGainRange, addSpeedRange, canEdit]);
const handleClickOutside = useCallback( const handleClickOutside = useCallback(
(e: React.MouseEvent) => { (e: React.MouseEvent) => {
@ -267,6 +272,7 @@ export default function TranscriptEditor({
// Snapshot indices and word timings before the async gap // Snapshot indices and word timings before the async gap
const sorted = [...selectedWordIndices].sort((a, b) => a - b); const sorted = [...selectedWordIndices].sort((a, b) => a - b);
assert(sorted[0] >= 0 && sorted[sorted.length - 1] < words.length, 'handleReTranscribe: sorted indices out of bounds');
const startWord = words[sorted[0]]; const startWord = words[sorted[0]];
const endWord = words[sorted[sorted.length - 1]]; const endWord = words[sorted[sorted.length - 1]];
if (!startWord || !endWord) { if (!startWord || !endWord) {
@ -303,8 +309,9 @@ export default function TranscriptEditor({
const handleWordDoubleClick = useCallback((index: number) => { const handleWordDoubleClick = useCallback((index: number) => {
if (cutMode || muteMode || gainMode || speedMode) return; if (cutMode || muteMode || gainMode || speedMode) return;
if (!canEdit) return;
startEditing(index); startEditing(index);
}, [cutMode, muteMode, gainMode, speedMode, startEditing]); }, [cutMode, muteMode, gainMode, speedMode, startEditing, canEdit]);
// Focus edit input when it appears // Focus edit input when it appears
useEffect(() => { useEffect(() => {
@ -333,6 +340,8 @@ export default function TranscriptEditor({
const cutSelectedWords = useCallback(() => { const cutSelectedWords = useCallback(() => {
if (selectedWordIndices.length === 0) return; if (selectedWordIndices.length === 0) return;
const sorted = [...selectedWordIndices].sort((a, b) => a - b); const sorted = [...selectedWordIndices].sort((a, b) => a - b);
assert(sorted[0] >= 0 && sorted[0] < words.length, 'cutSelectedWords: sorted[0] out of bounds');
assert(sorted[sorted.length - 1] >= 0 && sorted[sorted.length - 1] < words.length, 'cutSelectedWords: sorted[last] out of bounds');
const startTime = words[sorted[0]].start; const startTime = words[sorted[0]].start;
const endTime = words[sorted[sorted.length - 1]].end; const endTime = words[sorted[sorted.length - 1]].end;
addCutRange(startTime, endTime); addCutRange(startTime, endTime);
@ -454,8 +463,8 @@ export default function TranscriptEditor({
${isZoneDragSelected && muteMode ? 'bg-blue-500/30 ring-1 ring-blue-400/60' : ''} ${isZoneDragSelected && muteMode ? 'bg-blue-500/30 ring-1 ring-blue-400/60' : ''}
${isZoneDragSelected && gainMode ? 'bg-amber-500/30 ring-1 ring-amber-400/60' : ''} ${isZoneDragSelected && gainMode ? 'bg-amber-500/30 ring-1 ring-amber-400/60' : ''}
${isZoneDragSelected && speedMode ? 'bg-emerald-500/30 ring-1 ring-emerald-400/60' : ''} ${isZoneDragSelected && speedMode ? 'bg-emerald-500/30 ring-1 ring-emerald-400/60' : ''}
${isSearchMatch && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-accent/15 ring-1 ring-editor-accent/35' : ''} ${isSearchMatch && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-accent/15 ring-2 ring-editor-accent/50' : ''}
${isActiveSearchMatch && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-accent/35 ring-1 ring-editor-accent text-white' : ''} ${isActiveSearchMatch && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-accent/35 ring-2 ring-editor-accent text-white font-medium' : ''}
${isSelected && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-word-selected text-white' : ''} ${isSelected && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-word-selected text-white' : ''}
${isActive && !isSelected && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-accent/20 text-editor-accent' : ''} ${isActive && !isSelected && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-accent/20 text-editor-accent' : ''}
${isHovered && !isSelected && !isActive && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-word-hover' : ''} ${isHovered && !isSelected && !isActive && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-word-hover' : ''}
@ -556,35 +565,43 @@ export default function TranscriptEditor({
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<button <button
onClick={cutSelectedWords} onClick={cutSelectedWords}
className="flex items-center gap-1 px-2 py-1 text-xs bg-red-500/20 text-red-300 rounded hover:bg-red-500/30 transition-colors" disabled={!canEdit}
className="flex items-center gap-1 px-2 py-1 text-xs bg-red-500/20 text-red-300 rounded hover:bg-red-500/30 transition-colors disabled:opacity-40"
title="Remove this word range from the output"
> >
<Scissors className="w-3 h-3" /> <Scissors className="w-3 h-3" />
Cut Cut
</button> </button>
<button <button
onClick={muteSelectedWords} onClick={muteSelectedWords}
className="flex items-center gap-1 px-2 py-1 text-xs bg-blue-500/20 text-blue-300 rounded hover:bg-blue-500/30 transition-colors" disabled={!canEdit}
className="flex items-center gap-1 px-2 py-1 text-xs bg-blue-500/20 text-blue-300 rounded hover:bg-blue-500/30 transition-colors disabled:opacity-40"
title="Silence audio for this word range"
> >
<VolumeX className="w-3 h-3" /> <VolumeX className="w-3 h-3" />
Mute Mute
</button> </button>
<button <button
onClick={gainSelectedWords} onClick={gainSelectedWords}
className="flex items-center gap-1 px-2 py-1 text-xs bg-amber-500/20 text-amber-300 rounded hover:bg-amber-500/30 transition-colors" disabled={!canEdit}
className="flex items-center gap-1 px-2 py-1 text-xs bg-amber-500/20 text-amber-300 rounded hover:bg-amber-500/30 transition-colors disabled:opacity-40"
title="Adjust volume for this word range — positive boosts, negative reduces"
> >
<SlidersHorizontal className="w-3 h-3" /> <SlidersHorizontal className="w-3 h-3" />
Gain ({gainModeDb > 0 ? '+' : ''}{gainModeDb.toFixed(1)} dB) Gain ({gainModeDb > 0 ? '+' : ''}{gainModeDb.toFixed(1)} dB)
</button> </button>
<button <button
onClick={speedSelectedWords} onClick={speedSelectedWords}
className="flex items-center gap-1 px-2 py-1 text-xs bg-emerald-500/20 text-emerald-300 rounded hover:bg-emerald-500/30 transition-colors" disabled={!canEdit}
className="flex items-center gap-1 px-2 py-1 text-xs bg-emerald-500/20 text-emerald-300 rounded hover:bg-emerald-500/30 transition-colors disabled:opacity-40"
title="Change playback speed for this word range — lower is slower, higher is faster"
> >
<Gauge className="w-3 h-3" /> <Gauge className="w-3 h-3" />
Speed {speedModeValue.toFixed(2)}x Speed {speedModeValue.toFixed(2)}x
</button> </button>
<button <button
onClick={handleReTranscribe} onClick={handleReTranscribe}
disabled={isReTranscribing} disabled={isReTranscribing || !canEdit}
className="flex items-center gap-1 px-2 py-1 text-xs bg-purple-500/20 text-purple-300 rounded hover:bg-purple-500/30 disabled:opacity-40 transition-colors" className="flex items-center gap-1 px-2 py-1 text-xs bg-purple-500/20 text-purple-300 rounded hover:bg-purple-500/30 disabled:opacity-40 transition-colors"
title="Re-run Whisper transcription on this segment" title="Re-run Whisper transcription on this segment"
> >

View File

@ -1,174 +0,0 @@
import { useMemo, useState } from 'react';
import { useEditorStore } from '../store/editorStore';
import { Trash2, Volume2 } from 'lucide-react';
interface VolumePanelProps {
gainMode: boolean;
onToggleGainMode: () => void;
timelineGainDb: number;
onTimelineGainDbChange: (gainDb: number) => void;
}
export default function VolumePanel({
gainMode,
onToggleGainMode,
timelineGainDb,
onTimelineGainDbChange,
}: VolumePanelProps) {
const {
words,
selectedWordIndices,
globalGainDb,
gainRanges,
setGlobalGainDb,
addGainRange,
updateGainRange,
removeGainRange,
} = useEditorStore();
const [selectionGainDb, setSelectionGainDb] = useState(3);
const canApplySelection = selectedWordIndices.length > 0;
const selectedRange = useMemo(() => {
if (!canApplySelection) return null;
const sorted = [...selectedWordIndices].sort((a, b) => a - b);
const startWord = words[sorted[0]];
const endWord = words[sorted[sorted.length - 1]];
if (!startWord || !endWord) return null;
return {
start: startWord.start,
end: endWord.end,
};
}, [canApplySelection, selectedWordIndices, words]);
const applySelectionGain = () => {
if (!selectedRange) return;
addGainRange(selectedRange.start, selectedRange.end, selectionGainDb);
};
return (
<div className="p-4 space-y-4">
<div className="space-y-1">
<h3 className="text-sm font-semibold flex items-center gap-2">
<Volume2 className="w-4 h-4" />
Volume / Gain
</h3>
<p className="text-xs text-editor-text-muted">
Apply global gain or per-selection gain ranges.
</p>
</div>
<div className="space-y-2">
<label className="text-xs text-editor-text-muted font-medium">Global Gain (dB)</label>
<input
type="range"
min={-24}
max={24}
step={0.5}
value={globalGainDb}
onChange={(e) => setGlobalGainDb(Number(e.target.value))}
className="w-full"
/>
<div className="flex items-center justify-between text-xs">
<span className="text-editor-text-muted">-24 dB</span>
<span className="font-medium text-editor-text">{globalGainDb.toFixed(1)} dB</span>
<span className="text-editor-text-muted">+24 dB</span>
</div>
</div>
<div className="space-y-2 pt-1 border-t border-editor-border">
<label className="text-xs text-editor-text-muted font-medium">Timeline Gain Zone (dB)</label>
<div className="flex items-center gap-2">
<input
type="number"
min={-24}
max={24}
step={0.5}
value={timelineGainDb}
onChange={(e) => onTimelineGainDbChange(Math.max(-24, Math.min(24, Number(e.target.value) || 0)))}
className="w-24 px-2 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
/>
<button
onClick={onToggleGainMode}
className={`px-3 py-1.5 text-xs rounded transition-colors ${
gainMode
? 'bg-editor-accent text-white hover:bg-editor-accent-hover'
: 'bg-editor-accent/20 text-editor-accent hover:bg-editor-accent/30'
}`}
>
{gainMode ? 'Exit Zone Mode' : 'Add Gain Zones'}
</button>
</div>
<p className="text-[11px] text-editor-text-muted">
In gain zone mode, drag on the timeline to create a zone with this dB value.
</p>
</div>
<div className="space-y-2 pt-1 border-t border-editor-border">
<label className="text-xs text-editor-text-muted font-medium">Selection Gain (dB)</label>
<div className="flex items-center gap-2">
<input
type="number"
min={-24}
max={24}
step={0.5}
value={selectionGainDb}
onChange={(e) => setSelectionGainDb(Number(e.target.value) || 0)}
className="w-24 px-2 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
/>
<button
onClick={applySelectionGain}
disabled={!canApplySelection || !selectedRange}
className="px-3 py-1.5 text-xs rounded bg-editor-accent/20 text-editor-accent hover:bg-editor-accent/30 disabled:opacity-40"
>
Apply To Selection
</button>
</div>
<p className="text-[11px] text-editor-text-muted">
{canApplySelection
? `${selectedWordIndices.length} selected words${selectedRange ? ` (${selectedRange.start.toFixed(2)}s - ${selectedRange.end.toFixed(2)}s)` : ''}`
: 'Select transcript words to apply a gain range.'}
</p>
</div>
{gainRanges.length > 0 && (
<div className="space-y-2 pt-1 border-t border-editor-border">
<div className="text-xs font-medium">Gain Ranges</div>
<div className="max-h-56 overflow-y-auto space-y-1 pr-1">
{gainRanges.map((range) => (
<div
key={range.id}
className="px-2 py-1.5 rounded bg-editor-surface border border-editor-border text-xs flex items-center gap-2"
>
<div className="flex-1 min-w-0">
<div className="font-medium truncate">
{range.start.toFixed(2)}s - {range.end.toFixed(2)}s
</div>
<div className="text-editor-text-muted">{range.id}</div>
</div>
<input
type="number"
min={-24}
max={24}
step={0.5}
value={range.gainDb}
onChange={(e) => updateGainRange(range.id, Number(e.target.value) || 0)}
className="w-20 px-2 py-1 text-xs bg-editor-bg border border-editor-border rounded"
title="Gain dB"
/>
<button
onClick={() => removeGainRange(range.id)}
className="p-1 rounded hover:bg-editor-danger/20 text-editor-danger"
title="Delete gain range"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
</div>
)}
</div>
);
}

View File

@ -1,7 +1,9 @@
import { useRef, useEffect, useCallback, useState, useMemo } from 'react'; import { useRef, useEffect, useCallback, useState, useMemo } from 'react';
import { useEditorStore } from '../store/editorStore'; import { useEditorStore } from '../store/editorStore';
import { useLicenseStore } from '../store/licenseStore';
import { AlertTriangle } from 'lucide-react'; import { AlertTriangle } from 'lucide-react';
import { extractThumbnails } from '../lib/thumbnails'; import { extractThumbnails } from '../lib/thumbnails';
import { assert } from '../lib/assert';
const RULER_H = 20; // px reserved at top of canvas for the time ruler const RULER_H = 20; // px reserved at top of canvas for the time ruler
const COLLAPSED_CUT_DISPLAY_SECONDS = 0.08; const COLLAPSED_CUT_DISPLAY_SECONDS = 0.08;
@ -235,6 +237,7 @@ export default function WaveformTimeline({
const headCanvasRef = useRef<HTMLCanvasElement>(null); const headCanvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [audioError, setAudioError] = useState<string | null>(null); const [audioError, setAudioError] = useState<string | null>(null);
const [waveformReady, setWaveformReady] = useState(false);
const videoUrl = useEditorStore((s) => s.videoUrl); const videoUrl = useEditorStore((s) => s.videoUrl);
const videoPath = useEditorStore((s) => s.videoPath); const videoPath = useEditorStore((s) => s.videoPath);
@ -264,6 +267,7 @@ export default function WaveformTimeline({
const removeMuteRange = useEditorStore((s) => s.removeMuteRange); const removeMuteRange = useEditorStore((s) => s.removeMuteRange);
const removeGainRange = useEditorStore((s) => s.removeGainRange); const removeGainRange = useEditorStore((s) => s.removeGainRange);
const removeSpeedRange = useEditorStore((s) => s.removeSpeedRange); const removeSpeedRange = useEditorStore((s) => s.removeSpeedRange);
const canEdit = useLicenseStore((s) => s.canEdit);
const waveformDataRef = useRef<WaveformData | null>(null); const waveformDataRef = useRef<WaveformData | null>(null);
const zoomRef = useRef(1); // 1 = show all, >1 = zoomed in const zoomRef = useRef(1); // 1 = show all, >1 = zoomed in
@ -287,10 +291,9 @@ export default function WaveformTimeline({
const [showAdjustedTimeline, setShowAdjustedTimeline] = useState(false); const [showAdjustedTimeline, setShowAdjustedTimeline] = useState(false);
const sourceDuration = duration || waveformDataRef.current?.duration || 0; const sourceDuration = duration || waveformDataRef.current?.duration || 0;
const timelineCutRanges = showAdjustedTimeline ? cutRanges : [];
const { segments: timelineSegments, displayDuration } = useMemo( const { segments: timelineSegments, displayDuration } = useMemo(
() => buildTimelineSegments(sourceDuration, timelineCutRanges), () => buildTimelineSegments(sourceDuration, showAdjustedTimeline ? cutRanges : []),
[sourceDuration, timelineCutRanges], [sourceDuration, cutRanges, showAdjustedTimeline],
); );
useEffect(() => { useEffect(() => {
@ -347,6 +350,7 @@ export default function WaveformTimeline({
if (cancelled) return; if (cancelled) return;
waveformDataRef.current = waveformData; waveformDataRef.current = waveformData;
drawStaticWaveformRef.current(); drawStaticWaveformRef.current();
setWaveformReady(true);
} catch (err) { } catch (err) {
if (cancelled || (err instanceof DOMException && err.name === 'AbortError')) { if (cancelled || (err instanceof DOMException && err.name === 'AbortError')) {
console.log('[WaveformTimeline] req=', requestId, 'aborted/cancelled'); console.log('[WaveformTimeline] req=', requestId, 'aborted/cancelled');
@ -472,10 +476,10 @@ export default function WaveformTimeline({
// Draw resize handles // Draw resize handles
ctx.fillStyle = '#ef4444'; ctx.fillStyle = '#ef4444';
ctx.beginPath(); ctx.beginPath();
ctx.arc(x1, waveTop + waveH / 2, 4, 0, 2 * Math.PI); ctx.arc(x1, waveTop + waveH / 2, 6, 0, 2 * Math.PI);
ctx.fill(); ctx.fill();
ctx.beginPath(); ctx.beginPath();
ctx.arc(x2, waveTop + waveH / 2, 4, 0, 2 * Math.PI); ctx.arc(x2, waveTop + waveH / 2, 6, 0, 2 * Math.PI);
ctx.fill(); ctx.fill();
} }
} }
@ -497,10 +501,10 @@ export default function WaveformTimeline({
// Draw resize handles // Draw resize handles
ctx.fillStyle = '#3b82f6'; ctx.fillStyle = '#3b82f6';
ctx.beginPath(); ctx.beginPath();
ctx.arc(x1, waveTop + waveH / 2, 4, 0, 2 * Math.PI); ctx.arc(x1, waveTop + waveH / 2, 6, 0, 2 * Math.PI);
ctx.fill(); ctx.fill();
ctx.beginPath(); ctx.beginPath();
ctx.arc(x2, waveTop + waveH / 2, 4, 0, 2 * Math.PI); ctx.arc(x2, waveTop + waveH / 2, 6, 0, 2 * Math.PI);
ctx.fill(); ctx.fill();
} }
} }
@ -521,10 +525,10 @@ export default function WaveformTimeline({
ctx.fillStyle = '#f59e0b'; ctx.fillStyle = '#f59e0b';
ctx.beginPath(); ctx.beginPath();
ctx.arc(x1, waveTop + waveH / 2, 4, 0, 2 * Math.PI); ctx.arc(x1, waveTop + waveH / 2, 6, 0, 2 * Math.PI);
ctx.fill(); ctx.fill();
ctx.beginPath(); ctx.beginPath();
ctx.arc(x2, waveTop + waveH / 2, 4, 0, 2 * Math.PI); ctx.arc(x2, waveTop + waveH / 2, 6, 0, 2 * Math.PI);
ctx.fill(); ctx.fill();
} }
} }
@ -545,10 +549,10 @@ export default function WaveformTimeline({
ctx.fillStyle = '#10b981'; ctx.fillStyle = '#10b981';
ctx.beginPath(); ctx.beginPath();
ctx.arc(x1, waveTop + waveH / 2, 4, 0, 2 * Math.PI); ctx.arc(x1, waveTop + waveH / 2, 6, 0, 2 * Math.PI);
ctx.fill(); ctx.fill();
ctx.beginPath(); ctx.beginPath();
ctx.arc(x2, waveTop + waveH / 2, 4, 0, 2 * Math.PI); ctx.arc(x2, waveTop + waveH / 2, 6, 0, 2 * Math.PI);
ctx.fill(); ctx.fill();
} }
@ -687,7 +691,6 @@ export default function WaveformTimeline({
gainMode, gainMode,
speedMode, speedMode,
selectedZone, selectedZone,
showAdjustedTimeline,
markInTime, markInTime,
markOutTime, markOutTime,
displayDuration, displayDuration,
@ -696,6 +699,7 @@ export default function WaveformTimeline({
showGainZones, showGainZones,
showSpeedZones, showSpeedZones,
timelineSegments, timelineSegments,
timelineMarkers,
]); ]);
// Keep the ref in sync with the latest drawStaticWaveform closure // Keep the ref in sync with the latest drawStaticWaveform closure
@ -820,6 +824,7 @@ export default function WaveformTimeline({
}, [displayDuration, setCurrentTime, timelineSegments]); }, [displayDuration, setCurrentTime, timelineSegments]);
const clientXToTime = useCallback((clientX: number): number => { const clientXToTime = useCallback((clientX: number): number => {
assert(headCanvasRef.current !== null, 'clientXToTime: headCanvasRef.current is null');
const canvas = headCanvasRef.current; const canvas = headCanvasRef.current;
const dur = waveformDataRef.current?.duration; const dur = waveformDataRef.current?.duration;
if (!canvas || !dur) return 0; if (!canvas || !dur) return 0;
@ -848,7 +853,7 @@ export default function WaveformTimeline({
// Check if click is in waveform area // Check if click is in waveform area
if (y < waveTop || y > waveTop + waveH) return null; if (y < waveTop || y > waveTop + waveH) return null;
const handleSize = forHover ? 6 : 8; // Smaller hit area for hover, larger for click const handleSize = forHover ? 8 : 10;
// Check cut ranges // Check cut ranges
for (const range of showCutZones ? cutRanges : []) { for (const range of showCutZones ? cutRanges : []) {
@ -1026,7 +1031,7 @@ export default function WaveformTimeline({
// Check if clicking on a zone // Check if clicking on a zone
const zoneHit = getZoneAtPosition(e.clientX, e.clientY); const zoneHit = getZoneAtPosition(e.clientX, e.clientY);
if (zoneHit) { if (zoneHit && canEdit) {
if (zoneHit.edge === 'move') { if (zoneHit.edge === 'move') {
setSelectedZone({ type: zoneHit.type, id: zoneHit.id }); setSelectedZone({ type: zoneHit.type, id: zoneHit.id });
} else { } else {
@ -1099,7 +1104,7 @@ export default function WaveformTimeline({
// Clear selection if clicking elsewhere // Clear selection if clicking elsewhere
setSelectedZone(null); setSelectedZone(null);
if (cutMode || muteMode || gainMode || speedMode) { if (canEdit && (cutMode || muteMode || gainMode || speedMode)) {
// Range selection mode // Range selection mode
const startTime = clientXToTime(e.clientX); const startTime = clientXToTime(e.clientX);
selectionStartRef.current = startTime; selectionStartRef.current = startTime;
@ -1121,6 +1126,8 @@ export default function WaveformTimeline({
setIsDragging(false); setIsDragging(false);
if (selectionStartRef.current !== null && selectionEndRef.current !== null) { if (selectionStartRef.current !== null && selectionEndRef.current !== null) {
assert(selectionStartRef.current !== null, 'handleMouseDown: selectionStartRef is null');
assert(selectionEndRef.current !== null, 'handleMouseDown: selectionEndRef is null');
const start = Math.min(selectionStartRef.current, selectionEndRef.current); const start = Math.min(selectionStartRef.current, selectionEndRef.current);
const end = Math.max(selectionStartRef.current, selectionEndRef.current); const end = Math.max(selectionStartRef.current, selectionEndRef.current);
const minDuration = 0.01; const minDuration = 0.01;
@ -1182,11 +1189,12 @@ export default function WaveformTimeline({
if (e.key === 'Escape') { if (e.key === 'Escape') {
setSelectedZone(null); setSelectedZone(null);
editingZoneRef.current = null; editingZoneRef.current = null;
} else if (e.key === 'Delete' || e.key === 'Backspace') { } else if (canEdit && (e.key === 'Delete' || e.key === 'Backspace')) {
if (selectedZone) { if (selectedZone) {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
e.stopImmediatePropagation(); e.stopImmediatePropagation();
if (!window.confirm("Delete this zone?")) return;
if (selectedZone.type === 'cut') { if (selectedZone.type === 'cut') {
removeCutRange(selectedZone.id); removeCutRange(selectedZone.id);
} else if (selectedZone.type === 'mute') { } else if (selectedZone.type === 'mute') {
@ -1205,7 +1213,7 @@ export default function WaveformTimeline({
// Capture phase ensures zone delete runs before app-level bubble shortcuts. // Capture phase ensures zone delete runs before app-level bubble shortcuts.
window.addEventListener('keydown', handleKeyDown, { capture: true }); window.addEventListener('keydown', handleKeyDown, { capture: true });
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true }); return () => window.removeEventListener('keydown', handleKeyDown, { capture: true });
}, [selectedZone, removeCutRange, removeMuteRange, removeGainRange, removeSpeedRange]); }, [selectedZone, removeCutRange, removeMuteRange, removeGainRange, removeSpeedRange, canEdit]);
useEffect(() => { useEffect(() => {
if (!selectedZone) return; if (!selectedZone) return;
@ -1258,7 +1266,7 @@ export default function WaveformTimeline({
{markOutTime !== null && <span className="text-[10px] text-yellow-300">O {markOutTime.toFixed(2)}s</span>} {markOutTime !== null && <span className="text-[10px] text-yellow-300">O {markOutTime.toFixed(2)}s</span>}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="flex items-center gap-1 text-[10px] text-editor-text-muted select-none"> <label className="flex items-center gap-1 text-[10px] text-editor-text-muted select-none" title="Compress cut regions to preview the output timeline without gaps">
<input <input
type="checkbox" type="checkbox"
checked={showAdjustedTimeline} checked={showAdjustedTimeline}
@ -1272,28 +1280,28 @@ export default function WaveformTimeline({
<button <button
onClick={() => setShowCutZones((v) => !v)} onClick={() => setShowCutZones((v) => !v)}
className={`px-1.5 py-0.5 rounded text-[10px] border ${showCutZones ? 'border-red-500/60 text-red-300 bg-red-500/10' : 'border-editor-border text-editor-text-muted'}`} className={`px-1.5 py-0.5 rounded text-[10px] border ${showCutZones ? 'border-red-500/60 text-red-300 bg-red-500/10' : 'border-editor-border text-editor-text-muted'}`}
title="Toggle cut zones" title="Toggle cut zones on the timeline — red overlays show removed segments"
> >
Cut Cut
</button> </button>
<button <button
onClick={() => setShowMuteZones((v) => !v)} onClick={() => setShowMuteZones((v) => !v)}
className={`px-1.5 py-0.5 rounded text-[10px] border ${showMuteZones ? 'border-blue-500/60 text-blue-300 bg-blue-500/10' : 'border-editor-border text-editor-text-muted'}`} className={`px-1.5 py-0.5 rounded text-[10px] border ${showMuteZones ? 'border-blue-500/60 text-blue-300 bg-blue-500/10' : 'border-editor-border text-editor-text-muted'}`}
title="Toggle mute zones" title="Toggle mute zones on the timeline — blue overlays show silenced segments"
> >
Mute Mute
</button> </button>
<button <button
onClick={() => setShowGainZones((v) => !v)} onClick={() => setShowGainZones((v) => !v)}
className={`px-1.5 py-0.5 rounded text-[10px] border ${showGainZones ? 'border-amber-500/60 text-amber-300 bg-amber-500/10' : 'border-editor-border text-editor-text-muted'}`} className={`px-1.5 py-0.5 rounded text-[10px] border ${showGainZones ? 'border-amber-500/60 text-amber-300 bg-amber-500/10' : 'border-editor-border text-editor-text-muted'}`}
title="Toggle gain zones" title="Toggle gain zones on the timeline — amber overlays show volume adjustments"
> >
Gain Gain
</button> </button>
<button <button
onClick={() => setShowSpeedZones((v) => !v)} onClick={() => setShowSpeedZones((v) => !v)}
className={`px-1.5 py-0.5 rounded text-[10px] border ${showSpeedZones ? 'border-emerald-500/60 text-emerald-300 bg-emerald-500/10' : 'border-editor-border text-editor-text-muted'}`} className={`px-1.5 py-0.5 rounded text-[10px] border ${showSpeedZones ? 'border-emerald-500/60 text-emerald-300 bg-emerald-500/10' : 'border-editor-border text-editor-text-muted'}`}
title="Toggle speed zones" title="Toggle speed zones on the timeline — emerald overlays show speed changes"
> >
Speed Speed
</button> </button>
@ -1312,6 +1320,22 @@ export default function WaveformTimeline({
> >
{audioError} {audioError}
</pre> </pre>
<button
onClick={() => {
setAudioError(null);
waveformDataRef.current = null;
}}
className="px-3 py-1 text-xs rounded bg-editor-surface border border-editor-border text-editor-text hover:bg-editor-bg transition-colors"
>
Retry
</button>
</div>
) : !waveformReady ? (
<div className="flex-1 flex items-center justify-center">
<div className="flex flex-col items-center gap-3">
<div className="animate-spin rounded-full h-8 w-8 border-2 border-editor-border border-t-indigo-400" />
<span className="text-xs text-editor-text-muted">Loading waveform...</span>
</div>
</div> </div>
) : ( ) : (
<div className="flex-1 relative flex flex-col"> <div className="flex-1 relative flex flex-col">

View File

@ -94,7 +94,7 @@ export default function ZoneEditor() {
case 'cut': case 'cut':
return 'border-red-500/40 bg-red-500/5'; return 'border-red-500/40 bg-red-500/5';
case 'mute': case 'mute':
return 'border-orange-500/40 bg-orange-500/5'; return 'border-blue-500/40 bg-blue-500/20';
case 'gain': case 'gain':
return 'border-amber-500/40 bg-amber-500/5'; return 'border-amber-500/40 bg-amber-500/5';
case 'speed': case 'speed':
@ -120,6 +120,7 @@ export default function ZoneEditor() {
); );
const removeZone = useCallback((type: 'cut' | 'mute' | 'gain' | 'speed', id: string) => { const removeZone = useCallback((type: 'cut' | 'mute' | 'gain' | 'speed', id: string) => {
if (!window.confirm("Delete this zone?")) return;
if (type === 'cut') removeCutRange(id); if (type === 'cut') removeCutRange(id);
else if (type === 'mute') removeMuteRange(id); else if (type === 'mute') removeMuteRange(id);
else if (type === 'gain') removeGainRange(id); else if (type === 'gain') removeGainRange(id);
@ -193,6 +194,7 @@ export default function ZoneEditor() {
? 'bg-editor-accent text-white' ? 'bg-editor-accent text-white'
: 'text-editor-text-muted hover:text-editor-text' : 'text-editor-text-muted hover:text-editor-text'
}`} }`}
title="Show all zones"
> >
All All
</button> </button>
@ -203,6 +205,7 @@ export default function ZoneEditor() {
? 'bg-red-500/30 text-red-500' ? 'bg-red-500/30 text-red-500'
: 'text-editor-text-muted hover:text-editor-text' : 'text-editor-text-muted hover:text-editor-text'
}`} }`}
title="Show only Cut zones"
> >
Cut Cut
</button> </button>
@ -210,9 +213,10 @@ export default function ZoneEditor() {
onClick={() => setViewMode('mute')} onClick={() => setViewMode('mute')}
className={`px-2 py-1 text-xs rounded transition-colors ${ className={`px-2 py-1 text-xs rounded transition-colors ${
viewMode === 'mute' viewMode === 'mute'
? 'bg-orange-500/30 text-orange-500' ? 'bg-blue-500/20 text-blue-400'
: 'text-editor-text-muted hover:text-editor-text' : 'text-editor-text-muted hover:text-editor-text'
}`} }`}
title="Show only Mute zones"
> >
Mute Mute
</button> </button>
@ -223,6 +227,7 @@ export default function ZoneEditor() {
? 'bg-amber-500/30 text-amber-500' ? 'bg-amber-500/30 text-amber-500'
: 'text-editor-text-muted hover:text-editor-text' : 'text-editor-text-muted hover:text-editor-text'
}`} }`}
title="Show only Gain zones"
> >
Gain Gain
</button> </button>
@ -233,6 +238,7 @@ export default function ZoneEditor() {
? 'bg-emerald-500/30 text-emerald-500' ? 'bg-emerald-500/30 text-emerald-500'
: 'text-editor-text-muted hover:text-editor-text' : 'text-editor-text-muted hover:text-editor-text'
}`} }`}
title="Show only Speed zones"
> >
Speed Speed
</button> </button>
@ -240,7 +246,7 @@ export default function ZoneEditor() {
</div> </div>
{totalZones === 0 ? ( {totalZones === 0 ? (
<div className="p-4 rounded border border-dashed border-editor-border text-center"> <div className="p-4 rounded-lg border border-dashed border-editor-border text-center">
<p className="text-xs text-editor-text-muted"> <p className="text-xs text-editor-text-muted">
No zones yet. Create zones from the toolbar or by highlighting words. No zones yet. Create zones from the toolbar or by highlighting words.
</p> </p>
@ -259,13 +265,12 @@ export default function ZoneEditor() {
<div <div
key={range.id} key={range.id}
onClick={() => setFocusedZone({ type: 'cut', id: range.id })} onClick={() => setFocusedZone({ type: 'cut', id: range.id })}
className={`px-2 py-1.5 rounded border text-xs flex items-center gap-2 group cursor-pointer transition-colors ${getZoneTypeColor('cut')} ${isZoneFocused('cut', range.id) ? 'ring-1 ring-red-400 border-red-400/80 bg-red-500/12' : ''}`} className={`px-2 py-1.5 rounded-lg border text-xs flex items-center gap-2 group cursor-pointer transition-colors ${getZoneTypeColor('cut')} ${isZoneFocused('cut', range.id) ? 'ring-1 ring-red-400 border-red-400/80 bg-red-500/12' : ''}`}
> >
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-medium truncate"> <div className="font-medium truncate">
{formatTimelineLikeTime(range.start)} - {formatTimelineLikeTime(range.end)} {formatTimelineLikeTime(range.start)} - {formatTimelineLikeTime(range.end)}
</div> </div>
<div className="text-editor-text-muted text-[10px]">{range.id}</div>
</div> </div>
{renderPreviewButton(range.start, range.end, 'hover:bg-red-500/20 text-red-500/70 hover:text-red-500')} {renderPreviewButton(range.start, range.end, 'hover:bg-red-500/20 text-red-500/70 hover:text-red-500')}
<button <button
@ -287,7 +292,7 @@ export default function ZoneEditor() {
{/* Mute Zones */} {/* Mute Zones */}
{(viewMode === 'all' || viewMode === 'mute') && muteRanges.length > 0 && ( {(viewMode === 'all' || viewMode === 'mute') && muteRanges.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
<div className="text-xs font-semibold text-orange-500/80 flex items-center gap-2"> <div className="text-xs font-semibold text-blue-400 flex items-center gap-2">
<Volume2 className="w-3.5 h-3.5" /> <Volume2 className="w-3.5 h-3.5" />
Mute Zones ({muteRanges.length}) Mute Zones ({muteRanges.length})
</div> </div>
@ -296,21 +301,20 @@ export default function ZoneEditor() {
<div <div
key={range.id} key={range.id}
onClick={() => setFocusedZone({ type: 'mute', id: range.id })} onClick={() => setFocusedZone({ type: 'mute', id: range.id })}
className={`px-2 py-1.5 rounded border text-xs flex items-center gap-2 group cursor-pointer transition-colors ${getZoneTypeColor('mute')} ${isZoneFocused('mute', range.id) ? 'ring-1 ring-orange-400 border-orange-400/80 bg-orange-500/12' : ''}`} className={`px-2 py-1.5 rounded-lg border text-xs flex items-center gap-2 group cursor-pointer transition-colors ${getZoneTypeColor('mute')} ${isZoneFocused('mute', range.id) ? 'ring-1 ring-blue-400 border-blue-400/80 bg-blue-500/20' : ''}`}
> >
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-medium truncate"> <div className="font-medium truncate">
{formatTimelineLikeTime(range.start)} - {formatTimelineLikeTime(range.end)} {formatTimelineLikeTime(range.start)} - {formatTimelineLikeTime(range.end)}
</div> </div>
<div className="text-editor-text-muted text-[10px]">{range.id}</div>
</div> </div>
{renderPreviewButton(range.start, range.end, 'hover:bg-orange-500/20 text-orange-500/70 hover:text-orange-500')} {renderPreviewButton(range.start, range.end, 'hover:bg-blue-500/20 text-blue-400 hover:text-blue-400')}
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
removeZone('mute', range.id); removeZone('mute', range.id);
}} }}
className="p-1 rounded hover:bg-orange-500/20 text-orange-500/70 hover:text-orange-500 opacity-0 group-hover:opacity-100 transition-opacity" className="p-1 rounded hover:bg-blue-500/20 text-blue-400 hover:text-blue-400 opacity-0 group-hover:opacity-100 transition-opacity"
title="Delete mute zone" title="Delete mute zone"
> >
<Trash2 className="w-3.5 h-3.5" /> <Trash2 className="w-3.5 h-3.5" />
@ -321,12 +325,12 @@ export default function ZoneEditor() {
</div> </div>
)} )}
{/* Gain Zones */} {/* Sound Gain */}
{(viewMode === 'all' || viewMode === 'gain') && gainRanges.length > 0 && ( {(viewMode === 'all' || viewMode === 'gain') && gainRanges.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
<div className="text-xs font-semibold text-amber-500/80 flex items-center gap-2"> <div className="text-xs font-semibold text-amber-500/80 flex items-center gap-2">
<SlidersHorizontal className="w-3.5 h-3.5" /> <SlidersHorizontal className="w-3.5 h-3.5" />
Gain Zones ({gainRanges.length}) Sound Gain ({gainRanges.length})
</div> </div>
{/* Global Gain Slider */} {/* Global Gain Slider */}
@ -350,6 +354,7 @@ export default function ZoneEditor() {
value={globalGainDb} value={globalGainDb}
onChange={(e) => setGlobalGainDb(Math.max(-24, Math.min(24, Number(e.target.value) || 0)))} onChange={(e) => setGlobalGainDb(Math.max(-24, Math.min(24, Number(e.target.value) || 0)))}
className="w-14 px-1.5 py-0.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none" className="w-14 px-1.5 py-0.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
title="Volume adjustment in decibels — +6 dB doubles volume, -6 dB halves it"
/> />
<span className="text-xs text-amber-500/80 font-medium w-6 text-right">dB</span> <span className="text-xs text-amber-500/80 font-medium w-6 text-right">dB</span>
</div> </div>
@ -360,7 +365,7 @@ export default function ZoneEditor() {
<div <div
key={range.id} key={range.id}
onClick={() => setFocusedZone({ type: 'gain', id: range.id })} onClick={() => setFocusedZone({ type: 'gain', id: range.id })}
className={`px-2 py-1.5 rounded border text-xs flex items-center gap-2 group cursor-pointer transition-colors ${getZoneTypeColor('gain')} ${isZoneFocused('gain', range.id) ? 'ring-1 ring-amber-400 border-amber-400/80 bg-amber-500/12' : ''}`} className={`px-2 py-1.5 rounded-lg border text-xs flex items-center gap-2 group cursor-pointer transition-colors ${getZoneTypeColor('gain')} ${isZoneFocused('gain', range.id) ? 'ring-1 ring-amber-400 border-amber-400/80 bg-amber-500/12' : ''}`}
> >
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-medium truncate"> <div className="font-medium truncate">
@ -379,7 +384,7 @@ export default function ZoneEditor() {
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onChange={(e) => updateGainRange(range.id, Number(e.target.value) || 0)} onChange={(e) => updateGainRange(range.id, Number(e.target.value) || 0)}
className="w-16 px-1.5 py-0.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none" className="w-16 px-1.5 py-0.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
title="Gain dB" title="Volume adjustment in decibels — +6 dB doubles volume, -6 dB halves it"
/> />
{renderPreviewButton(range.start, range.end, 'hover:bg-amber-500/20 text-amber-500/70 hover:text-amber-500')} {renderPreviewButton(range.start, range.end, 'hover:bg-amber-500/20 text-amber-500/70 hover:text-amber-500')}
<button <button
@ -398,19 +403,19 @@ export default function ZoneEditor() {
</div> </div>
)} )}
{/* Speed Zones */} {/* Speed Adjust */}
{(viewMode === 'all' || viewMode === 'speed') && speedRanges.length > 0 && ( {(viewMode === 'all' || viewMode === 'speed') && speedRanges.length > 0 && (
<div className="space-y-2"> <div className="space-y-2">
<div className="text-xs font-semibold text-emerald-500/80 flex items-center gap-2"> <div className="text-xs font-semibold text-emerald-500/80 flex items-center gap-2">
<Gauge className="w-3.5 h-3.5" /> <Gauge className="w-3.5 h-3.5" />
Speed Zones ({speedRanges.length}) Speed Adjust ({speedRanges.length})
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
{speedRanges.map((range) => ( {speedRanges.map((range) => (
<div <div
key={range.id} key={range.id}
onClick={() => setFocusedZone({ type: 'speed', id: range.id })} onClick={() => setFocusedZone({ type: 'speed', id: range.id })}
className={`px-2 py-1.5 rounded border text-xs flex items-center gap-2 group cursor-pointer transition-colors ${getZoneTypeColor('speed')} ${isZoneFocused('speed', range.id) ? 'ring-1 ring-emerald-400 border-emerald-400/80 bg-emerald-500/12' : ''}`} className={`px-2 py-1.5 rounded-lg border text-xs flex items-center gap-2 group cursor-pointer transition-colors ${getZoneTypeColor('speed')} ${isZoneFocused('speed', range.id) ? 'ring-1 ring-emerald-400 border-emerald-400/80 bg-emerald-500/12' : ''}`}
> >
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="font-medium truncate"> <div className="font-medium truncate">
@ -429,7 +434,7 @@ export default function ZoneEditor() {
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
onChange={(e) => updateSpeedRange(range.id, Number(e.target.value) || 1)} onChange={(e) => updateSpeedRange(range.id, Number(e.target.value) || 1)}
className="w-16 px-1.5 py-0.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none" className="w-16 px-1.5 py-0.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
title="Speed multiplier" title="Playback speed multiplier — 1.0x is normal, 2.0x is twice as fast"
/> />
{renderPreviewButton(range.start, range.end, 'hover:bg-emerald-500/20 text-emerald-500/70 hover:text-emerald-500')} {renderPreviewButton(range.start, range.end, 'hover:bg-emerald-500/20 text-emerald-500/70 hover:text-emerald-500')}
<button <button

View File

@ -1,6 +1,6 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { useEditorStore } from '../store/editorStore'; import { useEditorStore } from '../store/editorStore';
import { loadBindings } from '../lib/keybindings'; import { loadBindings, DEFAULT_PRESETS } from '../lib/keybindings';
import type { KeyBinding } from '../types/project'; import type { KeyBinding } from '../types/project';
export function useKeyboardShortcuts() { export function useKeyboardShortcuts() {
@ -180,6 +180,8 @@ function toggleCheatsheet(bindings: KeyBinding[]) {
overlay.remove(); overlay.remove();
}; };
const presetName = JSON.stringify(bindings) === JSON.stringify(DEFAULT_PRESETS['left-hand']) ? 'Left-Hand Preset' : 'Standard Preset';
const rows = bindings const rows = bindings
.map( .map(
(b) => (b) =>
@ -187,11 +189,24 @@ function toggleCheatsheet(bindings: KeyBinding[]) {
) )
.join(''); .join('');
overlay.innerHTML = `<div style="background:#1a1d27;border:1px solid #2a2d3a;border-radius:12px;padding:24px 32px;max-width:450px;" onclick="event.stopPropagation()"> overlay.innerHTML = `<div style="background:#1a1d27;border:1px solid #2a2d3a;border-radius:12px;padding:24px 32px;max-width:450px;position:relative;" onclick="event.stopPropagation()">
<div style="font-size:11px;color:#94a3b8;margin-bottom:12px">Active preset: <span style="color:#818cf8;font-weight:500">${presetName}</span></div>
<h3 style="margin:0 0 16px;font-size:14px;font-weight:600;color:#e2e8f0">Keyboard Shortcuts</h3> <h3 style="margin:0 0 16px;font-size:14px;font-weight:600;color:#e2e8f0">Keyboard Shortcuts</h3>
<table style="font-size:13px">${rows}</table> <table style="font-size:13px">${rows}</table>
<p style="margin:16px 0 0;font-size:11px;color:#94a3b8;text-align:center">Customize in Settings &bull; Press ? to close</p> <p style="margin:16px 0 0;font-size:11px;color:#94a3b8;text-align:center">Customize in Settings &bull; Press ? to close</p>
<button id="cheatsheet-close" style="position:absolute;top:12px;right:16px;background:none;border:none;color:#94a3b8;font-size:18px;cursor:pointer;line-height:1;padding:4px;">&times;</button>
</div>`; </div>`;
document.body.appendChild(overlay); document.body.appendChild(overlay);
const closeBtn = overlay.querySelector('#cheatsheet-close') as HTMLButtonElement;
if (closeBtn) closeBtn.onclick = () => overlay.remove();
const escHandler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
overlay.remove();
document.removeEventListener('keydown', escHandler);
}
};
document.addEventListener('keydown', escHandler);
} }

View File

@ -8,6 +8,12 @@
100% { transform: scaleY(0.3); opacity: 0.5; } 100% { transform: scaleY(0.3); opacity: 0.5; }
} }
@keyframes audioBounce {
0% { height: 12px; }
50% { height: var(--bar-peak); }
100% { height: 12px; }
}
.wave-bar { .wave-bar {
animation: waveBar 0.9s ease-in-out infinite; animation: waveBar 0.9s ease-in-out infinite;
transform-origin: bottom; transform-origin: bottom;
@ -46,3 +52,7 @@ body {
video::-webkit-media-controls { video::-webkit-media-controls {
display: none !important; display: none !important;
} }

View File

@ -0,0 +1,26 @@
import { describe, expect, test } from 'vitest';
import { assert } from './assert';
describe('assert', () => {
test('does not throw for true condition', () => {
expect(() => assert(true, 'should not throw')).not.toThrow();
});
test('throws in dev mode for false condition', () => {
expect(() => assert(false, 'should throw')).toThrow('Assertion failed: should throw');
});
test('includes message in error', () => {
try {
assert(false, 'custom message here');
} catch (e: any) {
expect(e.message).toContain('custom message here');
}
});
test('does not throw for truthy values', () => {
expect(() => assert(1 === 1, 'math works')).not.toThrow();
expect(() => assert('hello' === 'hello', 'strings work')).not.toThrow();
});
});

View File

@ -0,0 +1,11 @@
export function assert(condition: boolean, message: string): asserts condition {
if (!condition) {
const error = new Error(`Assertion failed: ${message}`);
if (import.meta.env.DEV) {
console.error('[Assertion]', message, error.stack);
throw error;
} else {
console.warn('[Assertion] (prod silenced):', message);
}
}
}

View File

@ -96,4 +96,48 @@ window.electronAPI = {
await writeTextFile(path, content); await writeTextFile(path, content);
return true; return true;
}, },
activateLicense: (key: string): Promise<any> => {
return invoke('activate_license', { licenseKey: key });
},
verifyLicense: (key: string): Promise<any> => {
return invoke('verify_license', { licenseKey: key });
},
getAppStatus: (): Promise<any> => {
return invoke('get_app_status');
},
deactivateLicense: (): Promise<void> => {
return invoke('deactivate_license');
},
hasLicenseFeature: (feature: string): Promise<boolean> => {
return invoke('has_license_feature', { feature });
},
listModels: (): Promise<ModelInfo[]> => {
return invoke('list_models');
},
deleteModel: (path: string): Promise<void> => {
return invoke('delete_model', { path });
},
logError: (message: string, stack: string, componentStack: string): Promise<void> => {
return invoke('log_error', { message, stack, componentStack });
},
writeAutosave: (data: string): Promise<void> => {
return invoke('write_autosave', { data });
},
readAutosave: (): Promise<string | null> => {
return invoke('read_autosave');
},
deleteAutosave: (): Promise<void> => {
return invoke('delete_autosave');
},
}; };

View File

@ -5,10 +5,30 @@ import './lib/dev-logger';
// Tauri bridge polyfill: must be imported before App so window.electronAPI is available to all components // Tauri bridge polyfill: must be imported before App so window.electronAPI is available to all components
import './lib/tauri-bridge'; import './lib/tauri-bridge';
import App from './App'; import App from './App';
import ErrorBoundary from './components/ErrorBoundary';
import './index.css'; import './index.css';
window.addEventListener('error', (e) => {
if (e.error) {
try {
console.error('[GlobalError]', e.error.message, e.error.stack);
window.electronAPI?.logError?.(e.error.message, e.error.stack || '', '');
} catch {}
}
});
window.addEventListener('unhandledrejection', (e) => {
const reason = e.reason instanceof Error ? e.reason : new Error(String(e.reason));
try {
console.error('[UnhandledRejection]', reason.message, reason.stack);
window.electronAPI?.logError?.(reason.message, reason.stack || '', '');
} catch {}
});
ReactDOM.createRoot(document.getElementById('root')!).render( ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<App /> <ErrorBoundary>
<App />
</ErrorBoundary>
</React.StrictMode>, </React.StrictMode>,
); );

View File

@ -0,0 +1,127 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { useAIStore } from './aiStore';
function mockElectronAPI() {
(window as any).electronAPI = {
encryptString: vi.fn().mockResolvedValue('encrypted-value'),
decryptString: vi.fn().mockResolvedValue('decrypted-key'),
};
}
describe('aiStore', () => {
beforeEach(() => {
mockElectronAPI();
useAIStore.setState({
providers: {
ollama: { provider: 'ollama', baseUrl: 'http://localhost:11434', model: 'llama3' },
openai: { provider: 'openai', apiKey: '', model: 'gpt-4o' },
claude: { provider: 'claude', apiKey: '', model: 'claude-sonnet-4-20250514' },
},
defaultProvider: 'ollama',
customFillerWords: '',
fillerResult: null,
clipSuggestions: [],
isProcessing: false,
processingMessage: '',
_keysHydrated: false,
});
});
describe('setProviderConfig', () => {
test('updates Ollama base URL', () => {
useAIStore.getState().setProviderConfig('ollama', { baseUrl: 'http://custom:11434' });
expect(useAIStore.getState().providers.ollama.baseUrl).toBe('http://custom:11434');
});
test('updates Ollama model', () => {
useAIStore.getState().setProviderConfig('ollama', { model: 'llama3.2' });
expect(useAIStore.getState().providers.ollama.model).toBe('llama3.2');
});
test('updates OpenAI apiKey and encrypts', async () => {
useAIStore.getState().setProviderConfig('openai', { apiKey: 'sk-test123' });
expect(useAIStore.getState().providers.openai.apiKey).toBe('sk-test123');
expect((window as any).electronAPI.encryptString).toHaveBeenCalledWith('sk-test123');
});
test('updates Claude model', () => {
useAIStore.getState().setProviderConfig('claude', { model: 'claude-opus-4-20250514' });
expect(useAIStore.getState().providers.claude.model).toBe('claude-opus-4-20250514');
});
test('preserves existing config when updating partial fields', () => {
useAIStore.getState().setProviderConfig('openai', { apiKey: 'sk-new', model: 'gpt-4o-mini' });
expect(useAIStore.getState().providers.openai.apiKey).toBe('sk-new');
expect(useAIStore.getState().providers.openai.model).toBe('gpt-4o-mini');
});
});
describe('setDefaultProvider', () => {
test('changes default provider', () => {
useAIStore.getState().setDefaultProvider('openai');
expect(useAIStore.getState().defaultProvider).toBe('openai');
});
test('can switch to claude', () => {
useAIStore.getState().setDefaultProvider('claude');
expect(useAIStore.getState().defaultProvider).toBe('claude');
});
});
describe('setCustomFillerWords', () => {
test('sets custom filler words', () => {
useAIStore.getState().setCustomFillerWords('okay, alright, anyway');
expect(useAIStore.getState().customFillerWords).toBe('okay, alright, anyway');
});
test('clears custom filler words', () => {
useAIStore.getState().setCustomFillerWords('test');
useAIStore.getState().setCustomFillerWords('');
expect(useAIStore.getState().customFillerWords).toBe('');
});
});
describe('setFillerResult', () => {
test('sets filler result', () => {
const result = { fillers: [{ word: 'um', start: 1.0, end: 1.3 }], totalCount: 1 };
useAIStore.getState().setFillerResult(result as any);
expect(useAIStore.getState().fillerResult).toEqual(result);
});
test('clears filler result', () => {
useAIStore.getState().setFillerResult({ fillers: [], totalCount: 0 } as any);
useAIStore.getState().setFillerResult(null);
expect(useAIStore.getState().fillerResult).toBeNull();
});
});
describe('setProcessing', () => {
test('sets processing true with message', () => {
useAIStore.getState().setProcessing(true, 'Analyzing transcript...');
expect(useAIStore.getState().isProcessing).toBe(true);
expect(useAIStore.getState().processingMessage).toBe('Analyzing transcript...');
});
test('sets processing false', () => {
useAIStore.getState().setProcessing(true, 'Working...');
useAIStore.getState().setProcessing(false);
expect(useAIStore.getState().isProcessing).toBe(false);
});
});
describe('setClipSuggestions', () => {
test('sets clip suggestions', () => {
const clips = [{ title: 'Best moment', start: 10, end: 40, reason: 'Engaging' }];
useAIStore.getState().setClipSuggestions(clips as any);
expect(useAIStore.getState().clipSuggestions).toEqual(clips);
});
test('clears clip suggestions', () => {
useAIStore.getState().setClipSuggestions([{ title: 'x', start: 0, end: 10, reason: 'y' }] as any);
useAIStore.getState().setClipSuggestions([]);
expect(useAIStore.getState().clipSuggestions).toEqual([]);
});
});
});

View File

@ -3,30 +3,405 @@ import { beforeEach, describe, expect, test } from 'vitest';
import { useEditorStore } from './editorStore'; import { useEditorStore } from './editorStore';
describe('editorStore basics', () => { function seedWords(count: number) {
const words: { word: string; start: number; end: number; confidence: number }[] = [];
for (let i = 0; i < count; i++) {
words.push({ word: `word${i}`, start: i * 0.5, end: i * 0.5 + 0.4, confidence: 0.95 });
}
const segments = [{
id: 0, start: 0, end: count * 0.5,
text: words.map(w => w.word).join(' '),
words,
globalStartIndex: 0,
}];
useEditorStore.getState().setTranscription({ words, segments, language: 'en' });
}
describe('editorStore', () => {
beforeEach(() => { beforeEach(() => {
useEditorStore.getState().reset(); useEditorStore.getState().reset();
}); });
test('clamps global gain to valid bounds', () => { describe('global gain', () => {
const state = useEditorStore.getState(); test('clamps to upper bound', () => {
useEditorStore.getState().setGlobalGainDb(100);
expect(useEditorStore.getState().globalGainDb).toBe(24);
});
state.setGlobalGainDb(100); test('clamps to lower bound', () => {
expect(useEditorStore.getState().globalGainDb).toBe(24); useEditorStore.getState().setGlobalGainDb(-100);
expect(useEditorStore.getState().globalGainDb).toBe(-24);
});
state.setGlobalGainDb(-100); test('rejects NaN by falling back to 0', () => {
expect(useEditorStore.getState().globalGainDb).toBe(-24); useEditorStore.getState().setGlobalGainDb(NaN);
expect(useEditorStore.getState().globalGainDb).toBe(0);
});
test('rejects Infinity', () => {
useEditorStore.getState().setGlobalGainDb(Infinity);
expect(useEditorStore.getState().globalGainDb).toBe(0);
});
test('accepts value in range', () => {
useEditorStore.getState().setGlobalGainDb(6);
expect(useEditorStore.getState().globalGainDb).toBe(6);
});
}); });
test('adds gain range to store', () => { describe('zone ranges', () => {
const state = useEditorStore.getState(); beforeEach(() => {
useEditorStore.getState().setDuration(100);
});
state.addGainRange(1.2, 2.4, 3.5); test('addCutRange creates a zone with correct times', () => {
useEditorStore.getState().addCutRange(1, 5);
const ranges = useEditorStore.getState().cutRanges;
expect(ranges.length).toBe(1);
expect(ranges[0].start).toBe(1);
expect(ranges[0].end).toBe(5);
});
const ranges = useEditorStore.getState().gainRanges; test('addCutRange generates unique ids', () => {
expect(ranges.length).toBe(1); useEditorStore.getState().addCutRange(1, 2);
expect(ranges[0].start).toBe(1.2); useEditorStore.getState().addCutRange(3, 4);
expect(ranges[0].end).toBe(2.4); const ranges = useEditorStore.getState().cutRanges;
expect(ranges[0].gainDb).toBe(3.5); expect(ranges[0].id).not.toBe(ranges[1].id);
});
test('addCutRange rejects start >= end', () => {
useEditorStore.getState().addCutRange(5, 5);
expect(useEditorStore.getState().cutRanges.length).toBe(0);
});
test('addCutRange rejects start > end', () => {
useEditorStore.getState().addCutRange(5, 1);
expect(useEditorStore.getState().cutRanges.length).toBe(0);
});
test('addCutRange rejects duration < 0.01s', () => {
useEditorStore.getState().addCutRange(0, 0.005);
expect(useEditorStore.getState().cutRanges.length).toBe(0);
});
test('addCutRange rejects negative start', () => {
useEditorStore.getState().addCutRange(-1, 5);
expect(useEditorStore.getState().cutRanges.length).toBe(0);
});
test('addCutRange rejects NaN values', () => {
useEditorStore.getState().addCutRange(NaN, 5);
expect(useEditorStore.getState().cutRanges.length).toBe(0);
});
test('addMuteRange creates a zone', () => {
useEditorStore.getState().addMuteRange(2, 6);
const ranges = useEditorStore.getState().muteRanges;
expect(ranges.length).toBe(1);
expect(ranges[0].start).toBe(2);
expect(ranges[0].end).toBe(6);
});
test('addGainRange creates a zone with gain value', () => {
useEditorStore.getState().addGainRange(1, 4, 3.5);
const ranges = useEditorStore.getState().gainRanges;
expect(ranges.length).toBe(1);
expect(ranges[0].gainDb).toBe(3.5);
});
test('addSpeedRange creates a zone with speed value', () => {
useEditorStore.getState().addSpeedRange(0, 10, 1.5);
const ranges = useEditorStore.getState().speedRanges;
expect(ranges.length).toBe(1);
expect(ranges[0].speed).toBe(1.5);
});
test('removeCutRange removes by id', () => {
useEditorStore.getState().addCutRange(1, 2);
const id = useEditorStore.getState().cutRanges[0].id;
useEditorStore.getState().removeCutRange(id);
expect(useEditorStore.getState().cutRanges.length).toBe(0);
});
test('removeCutRange does nothing for missing id', () => {
useEditorStore.getState().addCutRange(1, 2);
useEditorStore.getState().removeCutRange('nonexistent');
expect(useEditorStore.getState().cutRanges.length).toBe(1);
});
test('updateCutRange updates bounds', () => {
useEditorStore.getState().addCutRange(1, 5);
const id = useEditorStore.getState().cutRanges[0].id;
useEditorStore.getState().updateCutRange(id, 2, 8);
const range = useEditorStore.getState().cutRanges[0];
expect(range.start).toBe(2);
expect(range.end).toBe(8);
});
test('removeMuteRange, removeGainRange, removeSpeedRange work', () => {
useEditorStore.getState().addMuteRange(1, 2);
useEditorStore.getState().addGainRange(2, 4, 3);
useEditorStore.getState().addSpeedRange(3, 6, 1.2);
useEditorStore.getState().removeMuteRange(useEditorStore.getState().muteRanges[0].id);
useEditorStore.getState().removeGainRange(useEditorStore.getState().gainRanges[0].id);
useEditorStore.getState().removeSpeedRange(useEditorStore.getState().speedRanges[0].id);
expect(useEditorStore.getState().muteRanges.length).toBe(0);
expect(useEditorStore.getState().gainRanges.length).toBe(0);
expect(useEditorStore.getState().speedRanges.length).toBe(0);
});
test('rejects zones beyond duration', () => {
useEditorStore.getState().setDuration(10);
useEditorStore.getState().addCutRange(5, 20);
expect(useEditorStore.getState().cutRanges.length).toBe(0);
});
test('rejects zone with end beyond duration', () => {
useEditorStore.getState().setDuration(5);
useEditorStore.getState().addCutRange(1, 10);
expect(useEditorStore.getState().cutRanges.length).toBe(0);
});
});
describe('word selection', () => {
beforeEach(() => { seedWords(10); });
test('setSelectedWordIndices updates selection', () => {
useEditorStore.getState().setSelectedWordIndices([0, 1, 2]);
expect(useEditorStore.getState().selectedWordIndices).toEqual([0, 1, 2]);
});
test('setSelectedWordIndices handles empty', () => {
useEditorStore.getState().setSelectedWordIndices([0]);
useEditorStore.getState().setSelectedWordIndices([]);
expect(useEditorStore.getState().selectedWordIndices).toEqual([]);
});
test('updateWordText updates the word at index', () => {
useEditorStore.getState().updateWordText(0, 'hello');
expect(useEditorStore.getState().words[0].word).toBe('hello');
});
test('updateWordText preserves timing', () => {
const origStart = useEditorStore.getState().words[3].start;
useEditorStore.getState().updateWordText(3, 'changed');
expect(useEditorStore.getState().words[3].start).toBe(origStart);
});
test('updateWordText rejects out-of-bounds index', () => {
useEditorStore.getState().updateWordText(999, 'oops');
expect(useEditorStore.getState().words.length).toBe(10);
});
test('updateWordText rejects empty string', () => {
useEditorStore.getState().updateWordText(0, '');
expect(useEditorStore.getState().words[0].word).toBe('word0');
});
test('replaceWordRange replaces words in middle', () => {
const newWords = [
{ word: 'new1', start: 1.5, end: 1.9, confidence: 0.99 },
{ word: 'new2', start: 2.0, end: 2.4, confidence: 0.99 },
];
useEditorStore.getState().replaceWordRange(3, 5, newWords);
const words = useEditorStore.getState().words;
expect(words.length).toBe(10 - (5 - 3 + 1) + 2);
expect(words[3].word).toBe('new1');
expect(words[4].word).toBe('new2');
});
test('getWordAtTime returns correct index', () => {
const idx = useEditorStore.getState().getWordAtTime(1.0);
expect(idx).toBe(2);
});
test('getWordAtTime returns 0 for time before first word', () => {
const idx = useEditorStore.getState().getWordAtTime(-1);
expect(idx).toBe(0);
});
test('getWordAtTime returns -1 for no words', () => {
useEditorStore.getState().reset();
expect(useEditorStore.getState().getWordAtTime(0)).toBe(-1);
});
});
describe('markers', () => {
beforeEach(() => {
useEditorStore.getState().setDuration(120);
});
test('setMarkInTime sets and clears', () => {
useEditorStore.getState().setMarkInTime(10);
expect(useEditorStore.getState().markInTime).toBe(10);
useEditorStore.getState().setMarkInTime(null);
expect(useEditorStore.getState().markInTime).toBeNull();
});
test('setMarkInTime rejects NaN', () => {
useEditorStore.getState().setMarkInTime(NaN);
expect(useEditorStore.getState().markInTime).toBeNull();
});
test('clearMarkRange clears both', () => {
useEditorStore.getState().setMarkInTime(5);
useEditorStore.getState().setMarkOutTime(10);
useEditorStore.getState().clearMarkRange();
expect(useEditorStore.getState().markInTime).toBeNull();
expect(useEditorStore.getState().markOutTime).toBeNull();
});
test('addTimelineMarker adds with correct data', () => {
useEditorStore.getState().addTimelineMarker(5, 'Intro', '#ef4444');
const markers = useEditorStore.getState().timelineMarkers;
expect(markers.length).toBe(1);
expect(markers[0].time).toBe(5);
expect(markers[0].label).toBe('Intro');
expect(markers[0].color).toBe('#ef4444');
});
test('addTimelineMarker defaults empty label to Marker', () => {
useEditorStore.getState().addTimelineMarker(10, '', '#6366f1');
expect(useEditorStore.getState().timelineMarkers[0].label).toBe('Marker');
});
test('addTimelineMarker rejects NaN time', () => {
useEditorStore.getState().addTimelineMarker(NaN, 'test', '#6366f1');
expect(useEditorStore.getState().timelineMarkers.length).toBe(0);
});
test('removeTimelineMarker removes by id', () => {
useEditorStore.getState().addTimelineMarker(5, 'Intro', '#ef4444');
const id = useEditorStore.getState().timelineMarkers[0].id;
useEditorStore.getState().removeTimelineMarker(id);
expect(useEditorStore.getState().timelineMarkers.length).toBe(0);
});
test('updateTimelineMarker updates label and color', () => {
useEditorStore.getState().addTimelineMarker(5, 'Intro', '#ef4444');
const id = useEditorStore.getState().timelineMarkers[0].id;
useEditorStore.getState().updateTimelineMarker(id, { label: 'Chapter 1', color: '#22c55e' });
const m = useEditorStore.getState().timelineMarkers[0];
expect(m.label).toBe('Chapter 1');
expect(m.color).toBe('#22c55e');
});
});
describe('transcription', () => {
test('setTranscription sets words and segments', () => {
seedWords(5);
expect(useEditorStore.getState().words.length).toBe(5);
expect(useEditorStore.getState().segments.length).toBe(1);
});
test('setTranscription clears segments when words are empty', () => {
useEditorStore.getState().setTranscription({ words: [], segments: [], language: 'en' });
expect(useEditorStore.getState().segments.length).toBe(0);
});
test('setTranscriptionModel ignores null', () => {
useEditorStore.getState().setTranscriptionModel('base');
useEditorStore.getState().setTranscriptionModel(null);
expect(useEditorStore.getState().transcriptionModel).toBe('base');
});
test('setTranscriptionModel ignores empty string', () => {
useEditorStore.getState().setTranscriptionModel('base');
useEditorStore.getState().setTranscriptionModel('');
expect(useEditorStore.getState().transcriptionModel).toBe('base');
});
test('setTranscribing toggles state and status', () => {
useEditorStore.getState().setTranscribing(true, 50, 'Loading...');
expect(useEditorStore.getState().isTranscribing).toBe(true);
expect(useEditorStore.getState().transcriptionProgress).toBe(50);
expect(useEditorStore.getState().transcriptionStatus).toBe('Loading...');
});
});
describe('project file', () => {
test('saveProject includes all zone types', () => {
useEditorStore.getState().loadVideo('test.mp4');
useEditorStore.getState().setDuration(100);
useEditorStore.getState().addCutRange(1, 2);
useEditorStore.getState().addMuteRange(2, 3);
useEditorStore.getState().addGainRange(3, 4, 3);
useEditorStore.getState().addSpeedRange(4, 5, 1.5);
const project = useEditorStore.getState().saveProject();
expect(project.cutRanges).toBeDefined();
expect(project.cutRanges!.length).toBe(1);
expect(project.muteRanges).toBeDefined();
expect(project.muteRanges!.length).toBe(1);
expect(project.gainRanges).toBeDefined();
expect(project.gainRanges!.length).toBe(1);
expect(project.speedRanges).toBeDefined();
expect(project.speedRanges!.length).toBe(1);
});
test('setProjectFilePath sets and reads back', () => {
useEditorStore.getState().setProjectFilePath('/path/to/project.aive');
expect(useEditorStore.getState().projectFilePath).toBe('/path/to/project.aive');
});
});
describe('duration and current time', () => {
test('setDuration sets duration value', () => {
useEditorStore.getState().setDuration(120);
expect(useEditorStore.getState().duration).toBe(120);
});
test('setCurrentTime sets time without clamping', () => {
useEditorStore.getState().setDuration(60);
useEditorStore.getState().setCurrentTime(120);
expect(useEditorStore.getState().currentTime).toBe(120);
});
test('setCurrentTime accepts negative values', () => {
useEditorStore.getState().setCurrentTime(-10);
expect(useEditorStore.getState().currentTime).toBe(-10);
});
test('setIsPlaying toggles', () => {
useEditorStore.getState().setIsPlaying(true);
expect(useEditorStore.getState().isPlaying).toBe(true);
useEditorStore.getState().setIsPlaying(false);
expect(useEditorStore.getState().isPlaying).toBe(false);
});
});
describe('loadVideo', () => {
test('loadVideo rejects empty path', () => {
useEditorStore.getState().loadVideo('');
expect(useEditorStore.getState().videoUrl).toBeNull();
});
test('loadVideo resets state', () => {
seedWords(5);
useEditorStore.getState().addCutRange(1, 2);
useEditorStore.getState().loadVideo('new-video.mp4');
expect(useEditorStore.getState().words.length).toBe(0);
expect(useEditorStore.getState().cutRanges.length).toBe(0);
});
});
describe('zone preview padding', () => {
test('sets padding value', () => {
useEditorStore.getState().setZonePreviewPaddingSeconds(3);
expect(useEditorStore.getState().zonePreviewPaddingSeconds).toBe(3);
});
test('rejects NaN', () => {
useEditorStore.getState().setZonePreviewPaddingSeconds(2);
useEditorStore.getState().setZonePreviewPaddingSeconds(NaN);
expect(useEditorStore.getState().zonePreviewPaddingSeconds).toBe(2);
});
test('clamps to upper bound', () => {
useEditorStore.getState().setZonePreviewPaddingSeconds(20);
expect(useEditorStore.getState().zonePreviewPaddingSeconds).toBe(10);
});
}); });
}); });

View File

@ -1,5 +1,6 @@
import { create } from 'zustand'; import { create } from 'zustand';
import { temporal } from 'zundo'; import { temporal } from 'zundo';
import { assert } from '../lib/assert';
import type { import type {
Word, Word,
Segment, Segment,
@ -109,7 +110,7 @@ interface EditorActions {
replaceWordRange: (startIndex: number, endIndex: number, newWords: Word[]) => void; replaceWordRange: (startIndex: number, endIndex: number, newWords: Word[]) => void;
getKeepSegments: () => Array<{ start: number; end: number }>; getKeepSegments: () => Array<{ start: number; end: number }>;
getWordAtTime: (time: number) => number; getWordAtTime: (time: number) => number;
loadProject: (projectData: any) => void; loadProject: (projectData: any) => number;
reset: () => void; reset: () => void;
setZoomConfig: (config: Partial<ZoomConfig>) => void; setZoomConfig: (config: Partial<ZoomConfig>) => void;
addAdditionalClip: (path: string, label?: string) => void; addAdditionalClip: (path: string, label?: string) => void;
@ -203,7 +204,10 @@ export const useEditorStore = create<EditorState & EditorActions>()(
setExportedAudioPath: (path) => set({ exportedAudioPath: path }), setExportedAudioPath: (path) => set({ exportedAudioPath: path }),
setTranscriptionModel: (model) => set({ transcriptionModel: model }), setTranscriptionModel: (model) => {
if (model === null || model === '') return;
set({ transcriptionModel: model });
},
saveProject: (): ProjectFile => { saveProject: (): ProjectFile => {
const { videoPath, words, segments, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, silenceTrimGroups, timelineMarkers, transcriptionModel, language, exportedAudioPath, zoomConfig, additionalClips, backgroundMusic } = get(); const { videoPath, words, segments, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, silenceTrimGroups, timelineMarkers, transcriptionModel, language, exportedAudioPath, zoomConfig, additionalClips, backgroundMusic } = get();
@ -239,6 +243,7 @@ export const useEditorStore = create<EditorState & EditorActions>()(
}, },
loadVideo: (path) => { loadVideo: (path) => {
if (!path) return;
const { backendUrl, zonePreviewPaddingSeconds } = get(); const { backendUrl, zonePreviewPaddingSeconds } = get();
const url = `${backendUrl}/file?path=${encodeURIComponent(path)}`; const url = `${backendUrl}/file?path=${encodeURIComponent(path)}`;
set({ set({
@ -252,6 +257,10 @@ export const useEditorStore = create<EditorState & EditorActions>()(
}, },
setTranscription: (result) => { setTranscription: (result) => {
if (!result.words || result.words.length === 0) {
set({ words: [], segments: [], selectedWordIndices: [] });
return;
}
let globalIdx = 0; let globalIdx = 0;
const annotatedSegments = result.segments.map((seg) => { const annotatedSegments = result.segments.map((seg) => {
const annotated = { ...seg, globalStartIndex: globalIdx }; const annotated = { ...seg, globalStartIndex: globalIdx };
@ -269,8 +278,14 @@ export const useEditorStore = create<EditorState & EditorActions>()(
setCurrentTime: (time) => set({ currentTime: time }), setCurrentTime: (time) => set({ currentTime: time }),
setDuration: (duration) => set({ duration }), setDuration: (duration) => set({ duration }),
setIsPlaying: (playing) => set({ isPlaying: playing }), setIsPlaying: (playing) => set({ isPlaying: playing }),
setMarkInTime: (time) => set({ markInTime: time }), setMarkInTime: (time) => {
setMarkOutTime: (time) => set({ markOutTime: time }), if (time !== null && !isFinite(time)) return;
set({ markInTime: time });
},
setMarkOutTime: (time) => {
if (time !== null && !isFinite(time)) return;
set({ markOutTime: time });
},
clearMarkRange: () => set({ markInTime: null, markOutTime: null }), clearMarkRange: () => set({ markInTime: null, markOutTime: null }),
setSelectedWordIndices: (indices) => set({ selectedWordIndices: indices }), setSelectedWordIndices: (indices) => set({ selectedWordIndices: indices }),
setHoveredWordIndex: (index) => set({ hoveredWordIndex: index }), setHoveredWordIndex: (index) => set({ hoveredWordIndex: index }),
@ -294,7 +309,7 @@ export const useEditorStore = create<EditorState & EditorActions>()(
updateWordText: (index, text) => { updateWordText: (index, text) => {
const { words, segments } = get(); const { words, segments } = get();
if (index < 0 || index >= words.length) return; if (index < 0 || index >= words.length || !text) return;
const newWords = words.map((w, i) => const newWords = words.map((w, i) =>
i === index ? { ...w, word: text } : w i === index ? { ...w, word: text } : w
); );
@ -320,7 +335,8 @@ export const useEditorStore = create<EditorState & EditorActions>()(
}, },
addCutRange: (start, end, trimGroupId) => { addCutRange: (start, end, trimGroupId) => {
const { cutRanges } = get(); const { cutRanges, duration } = get();
if (!isFinite(start) || !isFinite(end) || start < 0 || end - start < 0.01 || end > duration) return;
const newRange: CutRange = { const newRange: CutRange = {
id: `cut_${nextRangeId++}`, id: `cut_${nextRangeId++}`,
start, start,
@ -331,7 +347,8 @@ export const useEditorStore = create<EditorState & EditorActions>()(
}, },
addMuteRange: (start, end) => { addMuteRange: (start, end) => {
const { muteRanges } = get(); const { muteRanges, duration } = get();
if (!isFinite(start) || !isFinite(end) || start < 0 || end - start < 0.01 || end > duration) return;
const newRange: MuteRange = { const newRange: MuteRange = {
id: `mute_${nextRangeId++}`, id: `mute_${nextRangeId++}`,
start, start,
@ -341,7 +358,8 @@ export const useEditorStore = create<EditorState & EditorActions>()(
}, },
addGainRange: (start, end, gainDb) => { addGainRange: (start, end, gainDb) => {
const { gainRanges } = get(); const { gainRanges, duration } = get();
if (!isFinite(start) || !isFinite(end) || start < 0 || end - start < 0.01 || end > duration) return;
const newRange: GainRange = { const newRange: GainRange = {
id: `gain_${nextRangeId++}`, id: `gain_${nextRangeId++}`,
start, start,
@ -352,7 +370,8 @@ export const useEditorStore = create<EditorState & EditorActions>()(
}, },
addSpeedRange: (start, end, speed) => { addSpeedRange: (start, end, speed) => {
const { speedRanges } = get(); const { speedRanges, duration } = get();
if (!isFinite(start) || !isFinite(end) || start < 0 || end - start < 0.01 || end > duration) return;
const newRange: SpeedRange = { const newRange: SpeedRange = {
id: `speed_${nextRangeId++}`, id: `speed_${nextRangeId++}`,
start, start,
@ -437,6 +456,10 @@ export const useEditorStore = create<EditorState & EditorActions>()(
}, },
setGlobalGainDb: (gainDb) => { setGlobalGainDb: (gainDb) => {
if (!isFinite(gainDb)) {
set({ globalGainDb: 0 });
return;
}
set({ globalGainDb: Math.max(-24, Math.min(24, gainDb)) }); set({ globalGainDb: Math.max(-24, Math.min(24, gainDb)) });
}, },
@ -482,11 +505,13 @@ export const useEditorStore = create<EditorState & EditorActions>()(
}, },
addTimelineMarker: (time, label, color) => { addTimelineMarker: (time, label, color) => {
const { timelineMarkers } = get(); if (!isFinite(time) || time < 0) return;
const { timelineMarkers, duration } = get();
if (time > duration) return;
const newMarker: TimelineMarker = { const newMarker: TimelineMarker = {
id: `marker_${nextRangeId++}`, id: `marker_${nextRangeId++}`,
time, time,
label: label || `Marker ${timelineMarkers.length + 1}`, label: label || 'Marker',
color: color || '#6366f1', color: color || '#6366f1',
}; };
set({ timelineMarkers: [...timelineMarkers, newMarker].sort((a, b) => a.time - b.time) }); set({ timelineMarkers: [...timelineMarkers, newMarker].sort((a, b) => a.time - b.time) });
@ -529,6 +554,7 @@ export const useEditorStore = create<EditorState & EditorActions>()(
}), }),
setZonePreviewPaddingSeconds: (seconds) => { setZonePreviewPaddingSeconds: (seconds) => {
if (!isFinite(seconds)) return;
const nextSeconds = Math.max(0, Math.min(10, seconds)); const nextSeconds = Math.max(0, Math.min(10, seconds));
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.localStorage.setItem(ZONE_PREVIEW_PADDING_KEY, String(nextSeconds)); window.localStorage.setItem(ZONE_PREVIEW_PADDING_KEY, String(nextSeconds));
@ -538,6 +564,9 @@ export const useEditorStore = create<EditorState & EditorActions>()(
replaceWordRange: (startIndex, endIndex, newWords) => { replaceWordRange: (startIndex, endIndex, newWords) => {
const { words } = get(); const { words } = get();
assert(startIndex >= 0 && startIndex < words.length, 'replaceWordRange: startIndex out of bounds');
assert(endIndex >= 0 && endIndex < words.length, 'replaceWordRange: endIndex out of bounds');
assert(startIndex <= endIndex, 'replaceWordRange: startIndex > endIndex');
if (startIndex < 0 || endIndex >= words.length || startIndex > endIndex) return; if (startIndex < 0 || endIndex >= words.length || startIndex > endIndex) return;
// Replace words in the range with new words // Replace words in the range with new words
@ -647,6 +676,10 @@ export const useEditorStore = create<EditorState & EditorActions>()(
}, },
setBackgroundMusic: (config) => { setBackgroundMusic: (config) => {
if (!config || !config.path) {
set({ backgroundMusic: null });
return;
}
set({ backgroundMusic: config }); set({ backgroundMusic: config });
}, },
@ -657,9 +690,28 @@ export const useEditorStore = create<EditorState & EditorActions>()(
}, },
loadProject: (data) => { loadProject: (data) => {
const { backendUrl, zonePreviewPaddingSeconds, projectFilePath } = get(); const { backendUrl, zonePreviewPaddingSeconds, projectFilePath, duration } = get();
const url = `${backendUrl}/file?path=${encodeURIComponent(data.videoPath)}`; const url = `${backendUrl}/file?path=${encodeURIComponent(data.videoPath)}`;
const isValidZone = (r: { start: number; end: number }) =>
isFinite(r.start) && isFinite(r.end) && r.start >= 0 && r.end - r.start >= 0.01 && (duration <= 0 || r.end <= duration);
let removed = 0;
const filterZones = <T extends { start: number; end: number }>(ranges: T[]): T[] => {
const result: T[] = [];
for (const r of ranges) {
if (isValidZone(r)) { result.push(r); } else { removed++; }
}
return result;
};
// Backward compat: merge legacy deletedRanges into cutRanges as time-range cuts
const legacyCuts = (data.deletedRanges || []).map((r: any) => ({ id: r.id, start: r.start, end: r.end }));
const cleanedCutRanges = filterZones<CutRange>([...(data.cutRanges || []), ...legacyCuts]);
const cleanedMuteRanges = filterZones<MuteRange>(data.muteRanges || []);
const cleanedGainRanges = filterZones<GainRange>(data.gainRanges || []);
const cleanedSpeedRanges = filterZones<SpeedRange>(data.speedRanges || []);
let globalIdx = 0; let globalIdx = 0;
const annotatedSegments = (data.segments || []).map((seg: Segment) => { const annotatedSegments = (data.segments || []).map((seg: Segment) => {
const annotated = { ...seg, globalStartIndex: globalIdx }; const annotated = { ...seg, globalStartIndex: globalIdx };
@ -676,14 +728,10 @@ export const useEditorStore = create<EditorState & EditorActions>()(
videoUrl: url, videoUrl: url,
words: data.words || [], words: data.words || [],
segments: annotatedSegments, segments: annotatedSegments,
// Backward compat: merge legacy deletedRanges into cutRanges as time-range cuts cutRanges: cleanedCutRanges,
cutRanges: [ muteRanges: cleanedMuteRanges,
...(data.cutRanges || []), gainRanges: cleanedGainRanges,
...(data.deletedRanges || []).map((r: any) => ({ id: r.id, start: r.start, end: r.end })), speedRanges: cleanedSpeedRanges,
],
muteRanges: data.muteRanges || [],
gainRanges: data.gainRanges || [],
speedRanges: data.speedRanges || [],
globalGainDb: typeof data.globalGainDb === 'number' ? data.globalGainDb : 0, globalGainDb: typeof data.globalGainDb === 'number' ? data.globalGainDb : 0,
silenceTrimGroups: data.silenceTrimGroups || [], silenceTrimGroups: data.silenceTrimGroups || [],
timelineMarkers: data.timelineMarkers || [], timelineMarkers: data.timelineMarkers || [],
@ -694,6 +742,8 @@ export const useEditorStore = create<EditorState & EditorActions>()(
additionalClips: data.additionalClips || [], additionalClips: data.additionalClips || [],
backgroundMusic: data.backgroundMusic || null, backgroundMusic: data.backgroundMusic || null,
}); });
return removed;
}, },
reset: () => { reset: () => {

View File

@ -0,0 +1,213 @@
import { beforeEach, describe, expect, test, vi } from 'vitest';
import { useLicenseStore } from './licenseStore';
function mockElectronAPI(overrides: Record<string, any> = {}) {
(window as any).electronAPI = {
getAppStatus: vi.fn().mockResolvedValue({ tag: 'Expired' }),
activateLicense: vi.fn().mockResolvedValue(null),
deactivateLicense: vi.fn().mockResolvedValue(undefined),
hasLicenseFeature: vi.fn().mockResolvedValue(false),
...overrides,
};
}
describe('licenseStore', () => {
beforeEach(() => {
mockElectronAPI();
useLicenseStore.setState({ status: null, isLoaded: false, canEdit: true, canUseAI: false, showDialog: false });
});
describe('canEdit', () => {
test('is true for Licensed status', async () => {
mockElectronAPI({
getAppStatus: vi.fn().mockResolvedValue({ tag: 'Licensed', license: { license_id: 'x', tier: 'pro' } }),
});
await useLicenseStore.getState().checkStatus();
expect(useLicenseStore.getState().canEdit).toBe(true);
});
test('is true for Trial status', async () => {
mockElectronAPI({
getAppStatus: vi.fn().mockResolvedValue({ tag: 'Trial', days_remaining: 20, started_at: Date.now() }),
});
await useLicenseStore.getState().checkStatus();
expect(useLicenseStore.getState().canEdit).toBe(true);
});
test('is false for Expired status', async () => {
mockElectronAPI({
getAppStatus: vi.fn().mockResolvedValue({ tag: 'Expired' }),
});
await useLicenseStore.getState().checkStatus();
expect(useLicenseStore.getState().canEdit).toBe(false);
});
test('is false when status is null', () => {
useLicenseStore.setState({ status: null, canEdit: true, canUseAI: false });
useLicenseStore.getState().setStatus(null);
expect(useLicenseStore.getState().canEdit).toBe(false);
});
test('is true for Licensed status', () => {
useLicenseStore.getState().setStatus({ tag: 'Licensed', license: { license_id: 'x', tier: 'pro', customer_email: 'a@b.com', expires_at: 9999999999, features: [], issued_at: 1, max_activations: 1 } });
expect(useLicenseStore.getState().canEdit).toBe(true);
expect(useLicenseStore.getState().canUseAI).toBe(false);
});
test('is true for Licensed Business status', () => {
useLicenseStore.getState().setStatus({ tag: 'Licensed', license: { license_id: 'x', tier: 'business', customer_email: 'a@b.com', expires_at: 9999999999, features: [], issued_at: 1, max_activations: 5 } });
expect(useLicenseStore.getState().canEdit).toBe(true);
expect(useLicenseStore.getState().canUseAI).toBe(true);
});
test('is false for Trial status', () => {
useLicenseStore.getState().setStatus({ tag: 'Trial', days_remaining: 5, started_at: Date.now() });
expect(useLicenseStore.getState().canEdit).toBe(true);
expect(useLicenseStore.getState().canUseAI).toBe(false);
});
test('is false for Expired status', () => {
useLicenseStore.getState().setStatus({ tag: 'Expired' });
expect(useLicenseStore.getState().canEdit).toBe(false);
expect(useLicenseStore.getState().canUseAI).toBe(false);
});
});
describe('checkStatus', () => {
test('sets status to Licensed when backend returns Licensed', async () => {
const license = { license_id: 'l1', tier: 'pro', customer_email: 'a@b.com', expires_at: 9999999999, features: [], issued_at: 1, max_activations: 1 };
mockElectronAPI({ getAppStatus: vi.fn().mockResolvedValue({ tag: 'Licensed', license }) });
await useLicenseStore.getState().checkStatus();
expect(useLicenseStore.getState().status?.tag).toBe('Licensed');
});
test('sets status to Trial when backend returns Trial', async () => {
mockElectronAPI({ getAppStatus: vi.fn().mockResolvedValue({ tag: 'Trial', days_remaining: 15, started_at: Date.now() }) });
await useLicenseStore.getState().checkStatus();
expect(useLicenseStore.getState().status?.tag).toBe('Trial');
});
test('sets status to Expired when backend returns Expired', async () => {
mockElectronAPI({ getAppStatus: vi.fn().mockResolvedValue({ tag: 'Expired' }) });
await useLicenseStore.getState().checkStatus();
expect(useLicenseStore.getState().status?.tag).toBe('Expired');
});
test('handles API error gracefully', async () => {
mockElectronAPI({ getAppStatus: vi.fn().mockRejectedValue(new Error('network error')) });
await useLicenseStore.getState().checkStatus();
expect(useLicenseStore.getState().status?.tag).toBe('Expired');
expect(useLicenseStore.getState().canEdit).toBe(false);
expect(useLicenseStore.getState().canUseAI).toBe(false);
});
test('handles missing electronAPI', async () => {
delete (window as any).electronAPI;
await useLicenseStore.getState().checkStatus();
expect(useLicenseStore.getState().status?.tag).toBe('Expired');
expect(useLicenseStore.getState().canEdit).toBe(false);
expect(useLicenseStore.getState().canUseAI).toBe(false);
});
test('sets isLoaded to true after check', async () => {
await useLicenseStore.getState().checkStatus();
expect(useLicenseStore.getState().isLoaded).toBe(true);
});
});
describe('activateLicense', () => {
test('sets Licensed on valid key', async () => {
const license = { license_id: 'l2', tier: 'pro', customer_email: 'x@y.com', expires_at: 9999999999, features: ['bg_removal'], issued_at: 1, max_activations: 1 };
mockElectronAPI({ activateLicense: vi.fn().mockResolvedValue(license) });
const result = await useLicenseStore.getState().activateLicense('talkedit_v1_validKey');
expect(result).toBe(true);
expect(useLicenseStore.getState().status?.tag).toBe('Licensed');
expect(useLicenseStore.getState().canEdit).toBe(true);
expect(useLicenseStore.getState().canUseAI).toBe(false);
});
test('returns false on invalid key', async () => {
mockElectronAPI({ activateLicense: vi.fn().mockResolvedValue(null) });
const result = await useLicenseStore.getState().activateLicense('invalid-key');
expect(result).toBe(false);
});
test('returns false on API error', async () => {
mockElectronAPI({ activateLicense: vi.fn().mockRejectedValue(new Error('bad key')) });
const result = await useLicenseStore.getState().activateLicense('bad-key');
expect(result).toBe(false);
});
test('closes dialog on success', async () => {
useLicenseStore.setState({ showDialog: true });
const license = { license_id: 'l3', tier: 'business', customer_email: 'z@z.com', expires_at: 9999999999, features: [], issued_at: 1, max_activations: 5 };
mockElectronAPI({ activateLicense: vi.fn().mockResolvedValue(license) });
await useLicenseStore.getState().activateLicense('talkedit_v1_key');
expect(useLicenseStore.getState().showDialog).toBe(false);
});
});
describe('deactivateLicense', () => {
test('sets Expired when trial is over', async () => {
mockElectronAPI({
deactivateLicense: vi.fn().mockResolvedValue(undefined),
getAppStatus: vi.fn().mockResolvedValue({ tag: 'Expired' }),
});
await useLicenseStore.getState().deactivateLicense();
expect(useLicenseStore.getState().status?.tag).toBe('Expired');
expect(useLicenseStore.getState().canEdit).toBe(false);
expect(useLicenseStore.getState().canUseAI).toBe(false);
});
test('restores Trial when trial is still valid', async () => {
mockElectronAPI({
deactivateLicense: vi.fn().mockResolvedValue(undefined),
getAppStatus: vi.fn().mockResolvedValue({ tag: 'Trial', days_remaining: 5, started_at: Date.now() }),
});
await useLicenseStore.getState().deactivateLicense();
expect(useLicenseStore.getState().status?.tag).toBe('Trial');
expect(useLicenseStore.getState().canEdit).toBe(true);
expect(useLicenseStore.getState().canUseAI).toBe(false);
});
test('handles API error', async () => {
mockElectronAPI({ deactivateLicense: vi.fn().mockRejectedValue(new Error('fail')) });
useLicenseStore.setState({ status: { tag: 'Licensed', license: { license_id: 'x', tier: 'pro', customer_email: 'a@b.com', expires_at: 9999999999, features: [], issued_at: 1, max_activations: 1 } }, canEdit: true, canUseAI: false });
await useLicenseStore.getState().deactivateLicense();
expect(useLicenseStore.getState().status?.tag).toBe('Expired');
expect(useLicenseStore.getState().canEdit).toBe(false);
expect(useLicenseStore.getState().canUseAI).toBe(false);
});
});
describe('hasFeature', () => {
test('returns true when feature exists', async () => {
mockElectronAPI({ hasLicenseFeature: vi.fn().mockResolvedValue(true) });
const result = await useLicenseStore.getState().hasFeature('bg_removal');
expect(result).toBe(true);
});
test('returns false when feature missing', async () => {
mockElectronAPI({ hasLicenseFeature: vi.fn().mockResolvedValue(false) });
const result = await useLicenseStore.getState().hasFeature('nonexistent');
expect(result).toBe(false);
});
test('returns false on API error', async () => {
mockElectronAPI({ hasLicenseFeature: vi.fn().mockRejectedValue(new Error('fail')) });
const result = await useLicenseStore.getState().hasFeature('bg_removal');
expect(result).toBe(false);
});
});
describe('setShowDialog', () => {
test('toggles dialog', () => {
useLicenseStore.getState().setShowDialog(true);
expect(useLicenseStore.getState().showDialog).toBe(true);
useLicenseStore.getState().setShowDialog(false);
expect(useLicenseStore.getState().showDialog).toBe(false);
});
});
});

View File

@ -0,0 +1,110 @@
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export interface LicensePayload {
license_id: string;
customer_email: string;
tier: 'pro' | 'business';
features: string[];
issued_at: number;
expires_at: number;
max_activations: number;
}
export interface TrialState {
started_at: number;
}
export type AppStatus =
| { tag: 'Licensed'; license: LicensePayload }
| { tag: 'Trial'; days_remaining: number; started_at: number }
| { tag: 'Expired' };
interface LicenseState {
status: AppStatus | null;
isLoaded: boolean;
showDialog: boolean;
canEdit: boolean;
canUseAI: boolean;
}
interface LicenseActions {
setStatus: (status: AppStatus | null) => void;
setShowDialog: (show: boolean) => void;
checkStatus: () => Promise<void>;
activateLicense: (key: string) => Promise<boolean>;
deactivateLicense: () => Promise<void>;
hasFeature: (feature: string) => Promise<boolean>;
}
export const useLicenseStore = create<LicenseState & LicenseActions>()(
persist(
(set) => ({
status: null,
isLoaded: false,
showDialog: false,
canEdit: false,
canUseAI: false,
setStatus: (status) => {
const canEdit = status?.tag === 'Licensed' || status?.tag === 'Trial';
const canUseAI = status?.tag === 'Licensed' && status.license.tier === 'business';
set({ status, isLoaded: true, canEdit, canUseAI });
},
setShowDialog: (show) => set({ showDialog: show }),
checkStatus: async () => {
try {
const status = await window.electronAPI?.getAppStatus();
const canEdit = status?.tag === 'Licensed' || status?.tag === 'Trial';
const canUseAI = status?.tag === 'Licensed' && status.license.tier === 'business';
set({ status: status || { tag: 'Expired' }, isLoaded: true, canEdit, canUseAI });
} catch {
set({ status: { tag: 'Expired' }, isLoaded: true, canEdit: false, canUseAI: false });
}
},
activateLicense: async (key: string): Promise<boolean> => {
try {
const license = await window.electronAPI?.activateLicense(key);
if (!license) return false;
set({ status: { tag: 'Licensed', license }, showDialog: false, canEdit: true, canUseAI: license.tier === 'business' });
return true;
} catch {
return false;
}
},
deactivateLicense: async () => {
try {
await window.electronAPI?.deactivateLicense();
const s = await window.electronAPI?.getAppStatus();
const canEdit = s?.tag === 'Licensed' || s?.tag === 'Trial';
const canUseAI = s?.tag === 'Licensed' && s.license.tier === 'business';
set({ status: s || { tag: 'Expired' }, isLoaded: true, canEdit, canUseAI });
} catch {
set({ status: { tag: 'Expired' }, isLoaded: true, canEdit: false, canUseAI: false });
}
},
hasFeature: async (feature: string): Promise<boolean> => {
try {
return await window.electronAPI?.hasLicenseFeature(feature) || false;
} catch {
return false;
}
},
}),
{
name: 'talkedit-license',
partialize: (state) => {
// Only persist Licensed status (trial is ephemeral)
if (state.status?.tag === 'Licensed') {
return { status: state.status };
}
return {};
},
},
),
);

View File

@ -8,6 +8,13 @@ interface ImportMeta {
readonly env: ImportMetaEnv; readonly env: ImportMetaEnv;
} }
interface ModelInfo {
name: string;
path: string;
size_bytes: number;
kind: string;
}
interface DesktopAPI { interface DesktopAPI {
openFile: (options?: Record<string, unknown>) => Promise<string | null>; openFile: (options?: Record<string, unknown>) => Promise<string | null>;
saveFile: (options?: Record<string, unknown>) => Promise<string | null>; saveFile: (options?: Record<string, unknown>) => Promise<string | null>;
@ -20,6 +27,17 @@ interface DesktopAPI {
transcribe: (filePath: string, modelName: string, language?: string) => Promise<any>; transcribe: (filePath: string, modelName: string, language?: string) => Promise<any>;
readFile: (path: string) => Promise<string>; readFile: (path: string) => Promise<string>;
writeFile: (path: string, content: string) => Promise<boolean>; writeFile: (path: string, content: string) => Promise<boolean>;
activateLicense: (key: string) => Promise<any>;
getAppStatus: () => Promise<any>;
verifyLicense: (key: string) => Promise<any>;
deactivateLicense: () => Promise<void>;
hasLicenseFeature: (feature: string) => Promise<boolean>;
listModels: () => Promise<ModelInfo[]>;
deleteModel: (path: string) => Promise<void>;
logError: (message: string, stack: string, componentStack: string) => Promise<void>;
writeAutosave: (data: string) => Promise<void>;
readAutosave: () => Promise<string | null>;
deleteAutosave: () => Promise<void>;
} }
interface Window { interface Window {

View File

@ -1 +1 @@
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/AIPanel.tsx","./src/components/AppendClipPanel.tsx","./src/components/BackgroundMusicPanel.tsx","./src/components/DevPanel.tsx","./src/components/ExportDialog.tsx","./src/components/MarkersPanel.tsx","./src/components/SettingsPanel.tsx","./src/components/SilenceTrimmerPanel.tsx","./src/components/TranscriptEditor.tsx","./src/components/VideoPlayer.tsx","./src/components/VolumePanel.tsx","./src/components/WaveformTimeline.tsx","./src/components/ZoneEditor.tsx","./src/hooks/useKeyboardShortcuts.ts","./src/hooks/useVideoSync.ts","./src/lib/dev-logger.ts","./src/lib/keybindings.ts","./src/lib/tauri-bridge.ts","./src/lib/thumbnails.ts","./src/store/aiStore.ts","./src/store/editorStore.test.ts","./src/store/editorStore.ts","./src/types/project.ts"],"version":"5.9.3"} {"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/AIPanel.tsx","./src/components/AppendClipPanel.tsx","./src/components/BackgroundMusicPanel.tsx","./src/components/DevPanel.tsx","./src/components/ErrorBoundary.tsx","./src/components/ExportDialog.tsx","./src/components/HelpContent.tsx","./src/components/LicenseDialog.tsx","./src/components/MarkersPanel.tsx","./src/components/SettingsPanel.tsx","./src/components/SilenceTrimmerPanel.tsx","./src/components/TranscriptEditor.tsx","./src/components/VideoPlayer.tsx","./src/components/WaveformTimeline.tsx","./src/components/ZoneEditor.tsx","./src/hooks/useKeyboardShortcuts.ts","./src/hooks/useVideoSync.ts","./src/lib/assert.test.ts","./src/lib/assert.ts","./src/lib/dev-logger.ts","./src/lib/keybindings.ts","./src/lib/tauri-bridge.ts","./src/lib/thumbnails.ts","./src/store/aiStore.test.ts","./src/store/aiStore.ts","./src/store/editorStore.test.ts","./src/store/editorStore.ts","./src/store/licenseStore.test.ts","./src/store/licenseStore.ts","./src/types/project.ts"],"version":"5.9.3"}

View File

@ -0,0 +1,8 @@
import { defineConfig } from 'vitest/config';
export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
},
});

197
plan.md
View File

@ -1,81 +1,146 @@
# Plan for Building TalkEdit (Whisper.cpp + Tauri) # TalkEdit — Launch Plan
Based on your original idea summary and our discussions, here's a detailed plan to build a standalone, local audio/video editor app. We'll modify CutScript as the base, migrate to **Tauri 2.0** (Rust backend + React frontend) for tiny, dependency-free installers, and use **Whisper.cpp** for fast, accurate transcription. This keeps the scope minimal, focuses on text-based editing for spoken content, and targets podcasters/YouTubers. ## Niche: "Descript for long-form content"
## 1. Overview TalkEdit's defensible position: **works on hour+ files without degrading**, fully offline, one-time payment. No competitor owns this — Descript chokes on long content, CapCut limits mobile uploads, and both require accounts.
- **Goal**: Create an offline Descript alternative with word-level editing, transcription, and export. Users download one file (~1020MB), install, and run—no Python, FFmpeg, or external deps.
- **Why This Stack**: Tauri bundles everything into a native app; Whisper.cpp (C++ lib) integrates seamlessly with Rust for CPU-efficient transcription. Faster than rebuilding from scratch.
- **Target Users**: Creators editing podcasts/videos; free core + Pro upgrades.
- **Key Differentiators**: Fully local, text-based editing like Google Docs, smart cuts with fades.
## 2. Tech Stack **Current status (May 2026):** All core editing features are built and stable. Polish pass completed. 107 automated tests (95 frontend + 12 Rust). Ready for beta testing.
- **Frontend**: React + Vite + Tailwind CSS + shadcn/ui (from CutScript; minimal changes).
- **Backend**: Tauri 2.0 (Rust) handles file I/O, FFmpeg calls, Whisper.cpp integration.
- **Transcription**: Whisper.cpp (via Rust bindings like `whisper-cpp-sys` or `whisper-rs`).
- **Audio/Video Processing**: FFmpeg (bundled or called via Rust wrappers like `ffmpeg-next`).
- **State Management**: Zustand (from CutScript).
- **Packaging**: Tauri's `tauri build` for cross-platform installers.
- **AI Features**: Local models only (no APIs); optional Ollama for fillers.
## 3. Step-by-Step Development Plan ---
1. **Set Up Tauri in CutScript** (12 weeks):
- Install `tauri-cli` globally.
- In CutScript root: `npx tauri init` (choose Rust backend, link to existing React frontend).
- Migrate Electron main.js to Tauri's `src/main.rs` (handle window, file dialogs).
- Update `tauri.conf.json` for app metadata, bundle settings.
2. **Integrate Whisper.cpp in Rust** (23 weeks): ## Phase 1: Polish ✅ COMPLETED
- Add `whisper-cpp` as a dependency in `Cargo.toml`.
- Create a Rust module for transcription: Load models, process audio, return word-level timestamps.
- Replace Python backend calls with Tauri commands (e.g., `invoke` from frontend to Rust for transcription).
- Handle model downloads on first run (store in app data dir).
3. **Migrate Audio/Video Logic** (2 weeks): ### Reliability & error handling ✅
- Port FFmpeg calls to Rust (use `ffmpeg-next` for cutting/export). - [x] Backend health check — polls `/health` every 30s, shows reconnecting banner
- Implement segment calculation: From edited transcript, build keep_segments with padding/fades. - [x] Export failure reporting — surfaces FFmpeg stderr with copy-to-clipboard
- Add audio cleaning (noise reduction via bundled tools or Rust libs). - [x] React ErrorBoundary catches render crashes, shows fallback with reload
- [x] Global JS error logging — `window.onerror` + `onunhandledrejection` logged to Rust backend
4. **Frontend Polish** (12 weeks): ### UX polish ✅
- Update UI for Tauri (file dialogs via `tauri-plugin-dialog`). - [x] Tooltips on every button/control across all panels
- Refine transcript editor: Better timestamp syncing, manual adjustments. - [x] Loading spinners for waveform, waveform retry button
- Add export options (MP4 with subs, audio-only). - [x] Export progress bar (visual, not just text)
- [x] Help panel with full feature documentation
- [x] Keyboard cheatsheet overlay with close button and preset indicator
- [x] First-run welcome overlay with 3-step guide
- [x] `?` keyboard shortcut opens cheatsheet (accessible from Help panel)
- [x] Empty states: MarkersPanel, AIPanel, WaveformTimeline
- [x] Error states: AIPanel with retry, WaveformTimeline with retry
- [x] Auto-save crash recovery every 60s, restore prompt on next launch
- [x] Confirmation dialogs for zone/marker deletion
- [x] Disabled state for all buttons during export/transcription
- [x] Export button disabled when no video loaded
5. **Testing & Packaging** (1 week): ### Consistency ✅
- Test on Windows/macOS/Linux; ensure Whisper runs offline. - [x] Mute zone color unified (blue everywhere)
- Bundle with `tauri build`; verify no external deps. - [x] Disabled opacity unified (40% everywhere)
- Add auto-updater for Pro features. - [x] Zone list items border radius unified (`rounded-lg`)
- [x] Toolbar button groups separated with visual dividers
- [x] Labels simplified: "Sound Gain", "Speed Adjust", "Trim Silence", "Chapter Marks", "Edit Zones", "Add Clips", "Bkg. Music", "AI Tools"
- [x] Model selector moved to AIPanel reprocess tab
- [x] Orphaned VolumePanel.tsx removed
6. **Launch & Iterate** (Ongoing): ### Trial & licensing
- Open-source core on GitHub. - [x] Trial duration: 7 days
- Market on Product Hunt, Reddit; gather feedback. - [x] Trial bar on welcome screen with days remaining
- [x] Sentinel file prevents deleting trial.json to reset trial
- [x] XOR integrity check prevents editing trial.json timestamp
- [x] `canEdit` defaults to `false` (locked until status check confirms)
- [x] Email confirmation step before license activation (deters key sharing)
- [x] `verify_license` command (verify without caching)
- [x] Expired banner explains what still works (export, loading)
## 4. MVP Features (Minimal but Useful) ### Robustness ✅
Focus on what creators need for spoken content: - [x] React ErrorBoundary
- **Drag-and-drop import**: Audio/video files; auto-extract audio. - [x] Store-level input validation (reject NaN, clamp bounds, enforce min zone duration)
- **One-click transcription**: Whisper.cpp with model choice (Fast - less accurate: tiny/base; Slow - more accurate: small/medium/large). - [x] Runtime assertions in critical paths (TranscriptEditor, WaveformTimeline, ExportDialog)
- **Text-based editing**: Scrollable transcript; click word → jump to video; select/delete words → auto-cut audio with 150ms fades. - [x] Auto-save crash recovery
- **Smart cleanup**: Remove fillers ("um", pauses >0.8s) via local AI. - [x] CI pipeline (GitHub Actions: Rust + Frontend + Python)
- **Preview & Export**: Synced preview; export MP4/audio with optional SRT subs. - [x] Bad project state recovery (auto-prunes invalid zones on load, Dev Panel reset button)
- **Undo/Redo**: Full edit history. - [x] 95 frontend tests (editorStore, licenseStore, aiStore, assert)
- [x] 12 Rust tests (licensing, models)
- [x] Canvas zone handles enlarged (r=6), hit area increased
- [x] Search match contrast improved
- [x] Split panes keyboard-accessible (arrow keys, tabIndex, ARIA)
No multi-track, voice cloning, or collaboration—keep it simple. ---
## 4. Notes ## Phase 2: Beta Launch (🚧 next — 24 weeks)
- Consider adding Parakeet TDT as a transcription option in the future for users who want alternatives to Whisper.
## 5. Monetization Model **Goal:** Get working builds into real podcasters' hands. Validate the core promise (long-form, offline) before investing in edge-case features.
- **Free Forever**: Core editing/transcription (unlimited local use).
- **Pro License** ($2949 one-time): Batch processing, high-quality voices (if adding TTS), custom presets, priority support.
- **Optional Add-Ons**: Cloud credits for long videos (rarely needed).
## 6. Timeline & Milestones ### Must-have for beta
- **Weeks 14**: Tauri setup + Whisper integration.
- **Weeks 56**: Audio logic migration + frontend tweaks.
- **Weeks 78**: Testing, packaging, launch prep.
- **Total**: 610 weeks to MVP (solo dev + AI).
## 7. Risks & Tips - [ ] **Smart chunking for transcription** — files >2hr. Without this the niche promise is unproven. Breaks transcription into overlapping chunks, reassembles with correct timestamps.
- **Risks**: Whisper.cpp compilation issues; Rust learning curve if new to it. - [ ] **Hardware detection & model selection** — detect CUDA/ROCm/MPS at startup; expose model backend choice in Settings so beta users can configure their system.
- **Tips**: Start with small models (base ~70MB); test timestamp accuracy early. Use Tauri's docs for migration. If stuck, fall back to bundling Python for Whisper (but avoid for true standalone). - [ ] **GitHub v1.0.0 release** — tag, binary builds (AppImage + .deb), release notes.
- **Resources**: Tauri docs, Whisper.cpp GitHub, Rust audio crates.</content>
<parameter name="filePath">/home/dillon/_code/audio_editor/plan.md ### Sales & distribution
- [ ] **Stripe integration** — payment processing for one-time purchases (Pro $39, Business $79). License key generation + email delivery on payment success.
- [ ] **Landing page + download site** — simple site with: feature overview, pricing tiers, download links (AppImage/.deb), license activation flow. No auth system needed — Stripe handles payments, license keys unlock the app.
### Beta program
- [ ] **Free licenses to 20 podcasters** — in exchange for feedback + permission to quote. Target: r/podcasting regulars, small-to-medium shows (30min2hr episodes).
- [ ] **Bug/feedback pipeline** — GitHub Issues template for beta testers. Weekly triage.
- [ ] **Messaging for beta landing page:**
1. "The offline video editor that doesn't slow down on long files"
2. "No subscription. One price, owned forever."
3. "AI-powered editing — bring your own API key (Ollama, OpenAI, Claude)"
---
## Phase 3: Post-Beta Enhancements (user-driven priority)
**Goal:** Build what beta testers actually ask for. Deferred items below are ordered by likely demand, not engineering convenience.
### Bundled local LLM
- [ ] Integrate llama.cpp Rust bindings
- [ ] Auto-download Qwen3 on first AI use (4B: 2.5GB / 1.7B: 1GB)
- [ ] Hardware detection at runtime, model selection in Settings
### Long-form content
- [ ] Project stitching — load multiple `.aive` projects, combine into one export
### Export
- [ ] Batch export — multiple projects/cuts in sequence
### AI features
- [ ] Smart Shorts finder — scan transcript for 1090s segments
- [ ] AI auto-chapters — topic detection from transcript
- [ ] AI show notes — title, description, key moments
- [ ] AI dead-air finder — content-based silence detection
---
## Phase 4: Public Launch
**Goal:** Convert beta momentum + testimonials into a public release.
### Messaging pillars (updated)
1. "The offline video editor that doesn't slow down on long files"
2. "No subscription. One price, owned forever."
3. "Zero-setup AI" — bundled Qwen3, no API keys *(activate when Phase 3 ships)*
4. "Your podcast → 10 TikToks in one click" — Smart Shorts finder *(activate when Phase 3 ships)*
### Channels
- [ ] r/podcasting, r/VideoEditing, r/selfhosted — anchored by beta tester testimonials
- [ ] Product Hunt, Hacker News "Show HN"
- [ ] YouTube demo (3-5 min walkthrough) — feature the beta tester stories
- [ ] Pricing goes live publicly
### Pricing
- 7-day free trial (no CC, no account)
- Pro: $39 one-time
- Business: $79 one-time (priority support, volume licensing)
---
## Non-goals (explicitly deferred)
- Cloud sync / collaboration
- Voice cloning / TTS
- Full multi-track NLE timeline
- Mobile app
- Subscription model
- Image/video generation models

342
polish_plan.md Normal file
View File

@ -0,0 +1,342 @@
# TalkEdit — Testing &amp; Robustness Plan
Tests are critical before launch to prevent regressions and ensure the app is stable. Below is every function in the codebase that needs test coverage, organized by module.
---
## 1. Rust backend (`src-tauri/src/`)
### licensing.rs — already has partial coverage (4 tests)
- [ ] `verify_license_key` — valid key, invalid format, invalid signature, expired key ✅ (exists)
- [ ] `load_cached_license` — file exists, file missing, malformed file
- [ ] `cache_license` — write and read back
- [ ] `remove_license` — removes file, no-ops if missing
- [ ] `get_or_start_trial` — creates new trial file, loads existing, handles corrupt file
- [ ] `get_trial_info` — active trial (29 days), expired trial (0 days), exactly at boundary
- [ ] `get_trial_days_remaining` — active returns Some, expired returns None,
- [ ] `clear_trial` — removes file, no-ops if missing
- [ ] `get_app_status` — licensed (cached license), trial active, expired (no license, no trial)
- [ ] `has_feature` — feature exists, feature missing, empty features list
### models.rs — no existing tests
- [ ] `list_models` — whisper models found, llm models found, mixed, empty dirs
- [ ] `delete_model` — deletes file, deletes directory, path doesn't exist
- [ ] `huggingface_cache_dir` — HF_HOME set, XDG_CACHE_HOME set, defaults to ~/.cache
### transcription.rs — no existing tests
- [ ] `ensure_model_downloaded` — returns success (stub function)
### paths.rs — no existing tests
- [ ] `project_root` — dev layout, packaged (TAURI_RESOURCE_DIR), fallback
- [ ] `python_exe` — bundled path, venv paths, fallback to .venv312
- [ ] `backend_script` — joins project_root/backend
- [ ] `root_script` — joins project_root
---
## 2. Frontend store (`frontend/src/store/`)
### editorStore.ts (Zustand) — partially covered (2 tests)
- [ ] `reset` ✅ (in beforeEach)
- [ ] `setGlobalGainDb` — clamps to -24/+24 ✅ (exists)
- [ ] `addGainRange` — adds with correct start/end/gain ✅ (exists)
- [ ] `addCutRange` — adds with correct start/end, handles overlapping ranges
- [ ] `addMuteRange` — adds with correct start/end
- [ ] `addSpeedRange` — adds with correct start/end/speed
- [ ] `removeCutRange` — removes existing, no-ops on missing
- [ ] `removeMuteRange` — removes existing, no-ops on missing
- [ ] `removeGainRange` — removes existing, no-ops on missing
- [ ] `removeSpeedRange` — removes existing, no-ops on missing
- [ ] `updateCutRange` — updates bounds, prevents negative duration
- [ ] `updateMuteRange` — updates bounds
- [ ] `updateGainRangeBounds` — updates bounds
- [ ] `updateSpeedRangeBounds` — updates bounds
- [ ] `updateGainRange` — updates gain value
- [ ] `updateSpeedRange` — updates speed value
- [ ] `setSelectedWordIndices` — single, multiple, empty, out of range handled
- [ ] `replaceWordRange` — replaces words in middle, replaces at start, handles invalid indices
- [ ] `updateWordText` — updates word, preserves timing, no-ops on bad index
- [ ] `getWordAtTime` — exact match, between words, before first word, after last word, no words
- [ ] `loadVideo` — sets videoUrl, resets state, handles missing file
- [ ] `setCurrentTime` — sets time, clamps to 0-duration
- [ ] `setTranscribing` — toggles flag, sets status
- [ ] `setTranscription` — sets words and segments, handles empty arrays
- [ ] `setMarkInTime` / `setMarkOutTime` — sets and clears
- [ ] `clearMarkRange` — clears both marks
- [ ] `addTimelineMarker` — adds with label/color/time
- [ ] `removeTimelineMarker` — removes by id
- [ ] `updateTimelineMarker` — updates label/color
- [ ] `setZonePreviewPaddingSeconds` — sets and clamps
- [ ] `setBackgroundMusic` / `updateBackgroundMusic` — sets and updates
- [ ] `setAdditionalClips` — adds, removes, reorders
- [ ] `setSilenceTrimGroups` — sets groups
### licenseStore.ts — no existing tests
- [ ] `canEdit` — true for Licensed, true for Trial, false for Expired, false for null
- [ ] `checkStatus` — calls getAppStatus, sets correct state, handles error (falls to Expired)
- [ ] `activateLicense` — valid key sets Licensed, invalid key returns false
- [ ] `deactivateLicense` — reverts to trial if valid, falls to Expired otherwise
- [ ] `hasFeature` — returns true for matching, false for missing
- [ ] `setShowDialog` — toggles dialog visibility
- [ ] Persist middleware — Licensed status persists, Trial/Expired does not
### aiStore.ts — no existing tests
- [ ] `setProviderConfig` — updates provider, encrypts API keys
- [ ] `setDefaultProvider` — changes default
- [ ] `setCustomFillerWords` — sets and clears
- [ ] `setFillerResult` — sets and clears
- [ ] `setProcessing` — toggles with message
---
## 3. Frontend hooks (`frontend/src/hooks/`)
### useKeyboardShortcuts.ts — no existing tests
- [ ] Keyboard event dispatch — space plays/pauses, J/K/L speed controls, I/O marks, Delete cuts, Ctrl+Z undo
- [ ] `toggleCheatsheet` — creates overlay with correct content, toggles off
- [ ] Skip logic — skips correctly forward and back from current playhead
### useVideoSync.ts — no existing tests
- [ ] Synchronization of store `isPlaying` with video element, audio element
- [ ] `togglePlay` — starts playing, pauses
- [ ] `seekTo` — seeks video to correct time, seeks audio to correct time
- [ ] Handles video element ref being null (doesn't crash)
---
## 4. Frontend lib (`frontend/src/lib/`)
### keybindings.ts — no existing tests
- [ ] `loadBindings` — loads from localStorage, falls back to standard preset when missing
- [ ] `saveBindings` — persists and reloads correctly
- [ ] `applyPreset` — 'standard' and 'left-hand' both apply all required bindings
- [ ] `detectConflicts` — detects duplicate keys, returns empty when no conflicts
---
## 5. Backend services (`backend/services/`)
### video_editor.py — no existing tests
- [ ] `apply_cut_segments` — keeps correct segments from transcript word list, FFmpeg concat cmd
- [ ] `apply_mute_ranges` — cuts audio for muted ranges
- [ ] `apply_gain_ranges` — adjusts volume (positive and negative) for FFmpeg filter chains
- [ ] `apply_speed_ranges` — time-stretches or compresses segments
- [ ] `mix_background_music` — mixes with ducking enabled, mixes without ducking, handles no music
- [ ] `build_export_filters` — stitches together all zone types into correct filter order
### audio_cleaner.py — no existing tests
- [ ] `detect_silence` — detects pauses above threshold, returns correct time ranges
- [ ] `remove_silence` — splits by silence, re-concatenates keep segments
### ai_provider.py — no existing tests
- [ ] `complete` — Ollama completion succeeds, OpenAI completion succeeds, Claude completion succeeds
- [ ] `complete` — handles missing provider, timeout, bad JSON response
- [ ] `list_ollama_models` — returns models list, handles connection error
### transcription.py — no existing tests
- [ ] `transcribe_file` — returns words with correct format (word, start, end, confidence)
- [ ] `transcribe_segment` — re-transcribes a range with offset-adjusted timestamps
- [ ] `_load_model` — caches model, returns existing from cache, handles GPU/CPU
### caption_generator.py — no existing tests
- [ ] `generate_srt` — correct SRT format, sequential numbering, proper timestamps
- [ ] `generate_vtt` — correct VTT format with header, chroma key tags
- [ ] `generate_ass` — correct ASS subtitle format
### gpu_utils.py — no existing tests
- [ ] `get_optimal_device` — returns CUDA when available, returns CPU otherwise
### audio_processing.py — no existing tests
- [ ] `extract_audio` — extracts wav from video, handles audio-only input, temp file cleanup
---
## 6. Frontend components — integration tests
### TranscriptEditor.tsx
- [ ] Word selection: click, shift+click range, drag select
- [ ] Ctrl+click seeks video to correct time
- [ ] Double-click enters edit mode, Enter commits, Escape cancels
- [ ] Zone mode drag creates correct zone type
- [ ] Search finds matches, navigates with Enter/Shift+Enter
- [ ] "Restore" button appears on hover over words in a zone, removes the zone
- [ ] Re-transcribe calls backend and updates words
- [ ] Selection toolbar buttons create correct zone types
- [ ] When `canEdit` is false, buttons are disabled and zone creation is blocked
### WaveformTimeline.tsx
- [ ] Canvas renders waveform when audio data loads
- [ ] Click seeks to correct time
- [ ] Zone drag creates zone on mouse up
- [ ] Zone selection and resize with handles
- [ ] Delete key removes selected zone
- [ ] Zoom and scroll work correctly
- [ ] Zone toggle buttons show/hide overlay layers
- [ ] Loading spinner shows when no waveform data
- [ ] Error message with retry button on load failure
### ExportDialog.tsx
- [ ] Fast mode card and re-encode card toggle correctly
- [ ] Resolution selector only visible in re-encode mode
- [ ] Format selector disables WAV for video files
- [ ] Export button triggers export with correct parameters
- [ ] Progress bar updates during export
- [ ] Loudness normalization checkbox shows LUFS target selector
### AIPanel.tsx
- [ ] Filler words tab: detect button sends request, displays results
- [ ] Apply All creates cut ranges for all fillers
- [ ] Clips tab: find clips shows suggestions
- [ ] Reprocess tab: model selector + reprocess button
- [ ] Error state with retry on API failure
### App.tsx — layout and toolbar
- [ ] Welcome screen shows when no video loaded
- [ ] Trial bar shows on welcome screen for Trial/Expired states
- [ ] Toolbar buttons toggle modes correctly (Cut, Mute, Gain, Speed)
- [ ] Toolbar buttons open correct panels (Zones, Silence, Markers, Music, Append, AI, Export, Settings, Help)
- [ ] File menu opens/closes, items work
- [ ] Split pane dividers are draggable and keyboard-accessible
- [ ] First-run welcome overlay shows once
- [ ] Hotkeys work: Escape clears modes, ? opens cheatsheet
---
## 7. Error handling regression tests
- [ ] Backend crash: show reconnect banner, not broken UI
- [ ] Transcription failure: show error, allow retry with different model
- [ ] Export failure: show FFmpeg stderr, allow copy
- [ ] Model download timeout: show error, allow retry
- [ ] File not found: handled gracefully on open/load
- [ ] Permission denied: handled gracefully on save/export
- [ ] Concurrent operations: block export during transcription, block transcription during export
---
## 8. Licensing & trial flow tests
- [ ] Fresh install: shows 30-day trial
- [ ] Day 29: still allows editing
- [ ] Day 31: shows expired, editing locked, export still works
- [ ] Activate valid license: switches to Licensed, clears trial
- [ ] Activate invalid license: shows error, stays on trial
- [ ] Deactivate license: returns to trial if valid, expires if trial over
- [ ] Expired banner shows correct message and activate link
- [ ] `canEdit` prop correctly gates all editing controls across all components
---
## Test infrastructure
| Layer | Framework | Run command |
|-------|-----------|-------------|
| Rust | `cargo test` (built-in) | `cd src-tauri && cargo test` |
| Frontend (Vitest) | Vitest + jsdom | `cd frontend && npx vitest run` |
| Frontend (components) | Playwright or Vitest + testing-library | `cd frontend && npx vitest run` |
| Python backend | pytest | `cd backend && python -m pytest` |
### Setup needed
- [ ] Frontend: `npm install -D vitest @testing-library/react @testing-library/jest-dom jsdom` (vitest likely already installed)
- [ ] Frontend: add `"test": "vitest run"` to `package.json` if not present
- [ ] Python: ensure pytest is installed (`pip install pytest pytest-asyncio`)
- [ ] CI: add GitHub Actions workflow for `cargo test && vitest run && pytest`
---
## Priority order
1. **store tests** (editorStore, licenseStore, aiStore) — core data integrity
2. **Rust licensing tests** — payment/trial logic must never break
3. **Rust models tests** — filesystem operations must be safe
4. **Backend service unit tests** — export pipeline, transcription, AI
5. **Component integration tests** — user-facing behavior
6. **Error handling regression tests** — robustness
---
## Robustness beyond tests
### React Error Boundary
The app has no error boundary — a single JS error in any component crashes the entire UI to a white screen. Wrap the app in a `<ErrorBoundary>` that catches render errors and shows a fallback with "Something went wrong" + a reload button.
- [ ] Create `ErrorBoundary.tsx` component (`componentDidCatch` pattern)
- [ ] Wrap the entire `<App />` in `main.tsx`
- [ ] Fallback shows: error message, stack trace (collapsed), "Reload" button, "Reset & Clear State" button
### Global JS error logging
Uncaught errors in async code and event handlers silently break the app. Add a `window.onerror` and `window.onunhandledrejection` handler that logs to the Tauri backend and shows a toast notification.
- [ ] Add global error handler in `main.tsx` that intercepts all uncaught errors
- [ ] Log to Rust backend via `invoke('log_error', { message, stack })`
- [ ] Show a non-blocking toast notification (bottom-right) for non-fatal errors
- [ ] Fatal errors still go to the ErrorBoundary
### Input validation layer
The app trusts user input too much. Invalid values in number inputs, empty file paths, or negative durations can cause crashes or silent failures. Add validation at the store level.
- [ ] `editorStore.ts` — validate all setters: clamp numbers, reject empty strings for paths, enforce min/max on dB and speed values
- [ ] `licenseStore.ts` — validate license key format before sending to Rust (prefix check, base64 pattern)
- [ ] `aiStore.ts` — validate API key formatting, model name not empty
- [ ] Export options — validate resolution, format, loudness target against allowed values before sending to backend
### Frontend runtime assertions
The app makes assumptions about data shapes (e.g. `words[sorted[0]].start` assumes the index exists). Add assertion checks in critical paths that log a clear error instead of silently producing NaN or undefined.
- [ ] Add an `assert` utility function: `assert(condition, message)` that throws a descriptive error in dev, warns in prod
- [ ] Guard all array index access in TranscriptEditor, WaveformTimeline, ExportDialog
- [ ] Guard null/undefined checks on store actions that expect existing data
### Auto-save crash recovery
If the app crashes or the system loses power during editing, current work is lost. Add periodic auto-save to a temp file that gets restored on next launch.
- [ ] Every 60 seconds, save the full editor state to `app_data_dir/autosave.json`
- [ ] On launch, check if autosave.json exists and is newer than the last manual save
- [ ] Show a "Recover unsaved work?" prompt with date/time of autosave
- [ ] Clean up autosave after a manual save or after recovery is accepted/dismissed
### Backend health check & self-diagnostics
When the Python backend dies mid-session, the app doesn't know until a request fails. Add periodic health checks and a diagnostics panel.
- [ ] Poll `GET /health` every 15 seconds from the frontend
- [ ] If backend is unreachable: show a non-blocking banner "Backend disconnected — retrying..."
- [ ] When backend comes back online, dismiss the banner automatically
- [ ] Add a `/diagnostics` endpoint that reports: Python version, available FFmpeg, GPU detection, model cache sizes, disk space
- [ ] Wire to a "System Info" section in Settings or DevPanel
### CI pipeline with automated checks
Currently no CI exists. A GitHub Actions workflow would catch regressions on every push.
- [ ] Add `.github/workflows/ci.yml`:
- `cargo test` — all Rust tests
- `cargo clippy -- -D warnings` — enforce Rust lint rules
- `npx vitest run` — all frontend tests
- `npx tsc --noEmit` — TypeScript type check
- `python -m pytest backend/tests/` — Python backend tests
- `cargo build --release` — verify release build succeeds
- [ ] Run on push to `main` and on PRs
- [ ] Add Rust `#[deny(clippy::all)]` to catch common mistakes at build time
### Fuzz testing for Rust deserialization
The app deserializes user-provided JSON (project files, API responses) — any malformed input could crash the Rust backend. Add fuzz tests for the critical deserialization paths.
- [ ] Fuzz `TranscriptionResult` deserialization — malformed word/segment JSON
- [ ] Fuzz `.aive` project file loading — corrupted JSON, missing fields, wrong types
- [ ] Fuzz `LicensePayload` deserialization — tampered license payloads
- [ ] Use `cargo-fuzz` or `proptest` crate for property-based testing
### Performance telemetry (opt-in)
Without real-world data, you can't know where the app is slow. Add lightweight timing around operations that users complain about.
- [ ] Log timing for: transcription time, export time, AI completion time, model download time
- [ ] Store in localStorage (not sent anywhere — privacy-first)
- [ ] Show in DevPanel: last N operation timings
- [ ] Use this data to identify slow paths before users report them
### Recovery from bad project state
A corrupted `.aive` file or a partially-loaded project can leave the app in an unusable state. Add recovery paths.
- [ ] On project load failure: show error, offer "Load anyway with partial data" or "Cancel"
- [ ] Add "Reset Editor State" button in DevPanel (clears all state back to empty)
- [ ] Store action: validate all zone ranges are within video duration, auto-remove invalid ones on save

View File

@ -2,3 +2,4 @@
# will have compiled files and executables # will have compiled files and executables
/target/ /target/
/gen/schemas /gen/schemas
binaries/

110
src-tauri/Cargo.lock generated
View File

@ -79,7 +79,9 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
name = "app" name = "app"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"base64 0.22.1",
"dirs 5.0.1", "dirs 5.0.1",
"ed25519-dalek",
"hound", "hound",
"log", "log",
"serde", "serde",
@ -146,6 +148,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]] [[package]]
name = "bit-set" name = "bit-set"
version = "0.8.0" version = "0.8.0"
@ -450,6 +458,12 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]] [[package]]
name = "convert_case" name = "convert_case"
version = "0.4.0" version = "0.4.0"
@ -599,6 +613,33 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures",
"curve25519-dalek-derive",
"digest",
"fiat-crypto",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek-derive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "darling" name = "darling"
version = "0.23.0" version = "0.23.0"
@ -633,6 +674,16 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"zeroize",
]
[[package]] [[package]]
name = "deranged" name = "deranged"
version = "0.5.8" version = "0.5.8"
@ -826,6 +877,30 @@ version = "1.0.20"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
[[package]]
name = "ed25519"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"pkcs8",
"signature",
]
[[package]]
name = "ed25519-dalek"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek",
"ed25519",
"serde",
"sha2",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "embed-resource" name = "embed-resource"
version = "3.0.8" version = "3.0.8"
@ -907,6 +982,12 @@ dependencies = [
"log", "log",
] ]
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]] [[package]]
name = "field-offset" name = "field-offset"
version = "0.3.6" version = "0.3.6"
@ -2536,6 +2617,16 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der",
"spki",
]
[[package]] [[package]]
name = "pkg-config" name = "pkg-config"
version = "0.3.32" version = "0.3.32"
@ -3397,6 +3488,15 @@ version = "1.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"rand_core 0.6.4",
]
[[package]] [[package]]
name = "simd-adler32" name = "simd-adler32"
version = "0.3.8" version = "0.3.8"
@ -3491,6 +3591,16 @@ dependencies = [
"system-deps", "system-deps",
] ]
[[package]]
name = "spki"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der",
]
[[package]] [[package]]
name = "stable_deref_trait" name = "stable_deref_trait"
version = "1.2.1" version = "1.2.1"

View File

@ -1,15 +1,13 @@
[package] [package]
name = "app" name = "app"
version = "0.1.0" version = "0.1.0"
description = "A Tauri App" description = "TalkEdit - AI-powered video editor"
authors = ["you"] authors = ["you"]
license = "" license = ""
repository = "" repository = ""
edition = "2021" edition = "2021"
rust-version = "1.77.2" rust-version = "1.77.2"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[lib] [lib]
name = "app_lib" name = "app_lib"
crate-type = ["staticlib", "cdylib", "rlib"] crate-type = ["staticlib", "cdylib", "rlib"]
@ -29,3 +27,5 @@ dirs = "5.0"
ureq = "2.9" ureq = "2.9"
hound = "3.5" hound = "3.5"
tempfile = "3.10" tempfile = "3.10"
ed25519-dalek = "2"
base64 = "0.22"

View File

@ -11,8 +11,8 @@
"dialog:allow-open", "dialog:allow-open",
"dialog:allow-save", "dialog:allow-save",
"fs:default", "fs:default",
{ "identifier": "fs:allow-read-text-file", "allow": [{ "path": "$HOME/**" }] }, { "identifier": "fs:allow-read-text-file", "allow": [{ "path": "$HOME/**" }, { "path": "**" }] },
{ "identifier": "fs:allow-write-text-file", "allow": [{ "path": "$HOME/**" }] }, { "identifier": "fs:allow-write-text-file", "allow": [{ "path": "$HOME/**" }, { "path": "**" }] },
"fs:allow-app-read-recursive", "fs:allow-app-read-recursive",
"fs:allow-app-write-recursive" "fs:allow-app-write-recursive"
] ]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 719 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.0 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.0 KiB

After

Width:  |  Height:  |  Size: 629 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 922 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 968 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 37 KiB

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 10 KiB

1
src-tauri/icons/icon.svg Normal file
View File

@ -0,0 +1 @@
<svg width="48" height="48" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6 10h12a6 6 0 0 1 0 12H8l-2 4V10Z" stroke="#818cf8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" opacity="0.7"></path><path d="M6 10h12a6 6 0 0 1 0 12H8l-2 4V10Z" stroke="#6366f1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path><path d="M10 14v4M13 13v6M16 14v4" stroke="#6366f1" stroke-width="1.5" stroke-linecap="round"></path><path d="M22 16h6M22 19h4M22 22h5" stroke="#818cf8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.6"></path></svg>

After

Width:  |  Height:  |  Size: 622 B

View File

@ -1,5 +1,7 @@
// --- Commands --- // --- Commands ---
use tauri::Manager;
mod paths; mod paths;
mod transcription; mod transcription;
mod video_editor; mod video_editor;
@ -8,6 +10,8 @@ mod diarization;
mod ai_provider; mod ai_provider;
mod caption_generator; mod caption_generator;
mod background_removal; mod background_removal;
mod licensing;
mod models;
#[tauri::command] #[tauri::command]
fn get_projects_directory() -> Result<String, String> { fn get_projects_directory() -> Result<String, String> {
@ -204,6 +208,106 @@ async fn save_captions(content: String, output_path: String) -> Result<String, S
.map_err(|e| format!("Task error: {:?}", e))? .map_err(|e| format!("Task error: {:?}", e))?
} }
/// List downloaded models (Whisper + LLM) with sizes.
#[tauri::command]
fn log_error(message: String, stack: String, component_stack: String) {
log::error!(
"[Frontend Error] {} — Stack: {} — Component: {}",
message,
stack,
component_stack,
);
}
/// List downloaded models (Whisper + LLM) with sizes.
#[tauri::command]
fn list_models(app_handle: tauri::AppHandle) -> Result<Vec<models::ModelInfo>, String> {
let data_dir = app_handle
.path()
.app_data_dir()
.map_err(|e| format!("No app data directory: {e}"))?;
Ok(models::list_models(&data_dir))
}
/// Delete a downloaded model by path.
#[tauri::command]
fn delete_model(path: String) -> Result<(), String> {
models::delete_model(&path)
}
/// Get the combined app status: licensed, trial, or expired.
#[tauri::command]
fn get_app_status(app_handle: tauri::AppHandle) -> Result<licensing::AppStatus, String> {
let data_dir = app_handle
.path()
.app_data_dir()
.map_err(|e| format!("No app data directory: {e}"))?;
Ok(licensing::get_app_status(&data_dir))
}
/// Verify a license key signature without caching. Returns the payload.
#[tauri::command]
fn verify_license(license_key: String) -> Result<licensing::LicensePayload, String> {
licensing::verify_license_key(&license_key)
.map_err(|e| e.to_string())
}
/// Verify and activate a license key.
#[tauri::command]
fn activate_license(app_handle: tauri::AppHandle, license_key: String) -> Result<licensing::LicensePayload, String> {
let data_dir = app_handle
.path()
.app_data_dir()
.map_err(|e| format!("No app data directory: {e}"))?;
let payload = licensing::verify_license_key(&license_key)
.map_err(|e| e.to_string())?;
licensing::cache_license(&data_dir, &license_key)
.map_err(|e| e.to_string())?;
// Clear trial state since user has a valid license
licensing::clear_trial(&data_dir);
Ok(payload)
}
/// Remove the cached license (deactivate). Trial will resume if still valid.
#[tauri::command]
fn deactivate_license(app_handle: tauri::AppHandle) -> Result<(), String> {
let data_dir = app_handle
.path()
.app_data_dir()
.map_err(|e| format!("No app data directory: {e}"))?;
licensing::remove_license(&data_dir);
Ok(())
}
/// Start the free trial if not already started. Returns trial info.
#[tauri::command]
fn start_trial(app_handle: tauri::AppHandle) -> Result<licensing::TrialState, String> {
let data_dir = app_handle
.path()
.app_data_dir()
.map_err(|e| format!("No app data directory: {e}"))?;
let trial = licensing::get_or_start_trial(&data_dir);
Ok(trial)
}
/// Check if a specific feature is enabled (requires valid license).
#[tauri::command]
fn has_license_feature(app_handle: tauri::AppHandle, feature: String) -> Result<bool, String> {
let data_dir = app_handle
.path()
.app_data_dir()
.map_err(|e| format!("No app data directory: {e}"))?;
match licensing::load_cached_license(&data_dir) {
Ok(payload) => Ok(licensing::has_feature(&payload, &feature)),
Err(_) => Ok(false),
}
}
/// Check if background removal is available /// Check if background removal is available
#[tauri::command] #[tauri::command]
async fn is_background_removal_available() -> Result<bool, String> { async fn is_background_removal_available() -> Result<bool, String> {
@ -224,6 +328,39 @@ async fn remove_background_on_export(input_path: String, output_path: String, re
.map_err(|e| format!("Task error: {:?}", e))? .map_err(|e| format!("Task error: {:?}", e))?
} }
/// Write autosave data to the app data directory
#[tauri::command]
fn write_autosave(app_handle: tauri::AppHandle, data: String) -> Result<(), String> {
let data_dir = app_handle.path().app_data_dir().map_err(|e| format!("No app data directory: {e}"))?;
let path = data_dir.join("autosave.json");
std::fs::write(&path, data).map_err(|e| format!("Failed to write autosave: {e}"))?;
Ok(())
}
/// Read autosave data if it exists
#[tauri::command]
fn read_autosave(app_handle: tauri::AppHandle) -> Result<Option<String>, String> {
let data_dir = app_handle.path().app_data_dir().map_err(|e| format!("No app data directory: {e}"))?;
let path = data_dir.join("autosave.json");
match std::fs::read_to_string(&path) {
Ok(data) => Ok(Some(data)),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(format!("Failed to read autosave: {e}")),
}
}
/// Delete the autosave file
#[tauri::command]
fn delete_autosave(app_handle: tauri::AppHandle) -> Result<(), String> {
let data_dir = app_handle.path().app_data_dir().map_err(|e| format!("No app data directory: {e}"))?;
let path = data_dir.join("autosave.json");
match std::fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(format!("Failed to delete autosave: {e}")),
}
}
// --- App entry point --- // --- App entry point ---
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
@ -239,6 +376,36 @@ pub fn run() {
.build(), .build(),
)?; )?;
} }
// Check for cached license or trial at startup
if let Ok(data_dir) = app.path().app_data_dir() {
match licensing::get_app_status(&data_dir) {
licensing::AppStatus::Licensed { license: payload } => {
log::info!(
"License: {} ({}), expires {}",
payload.customer_email,
payload.tier,
payload.expires_at,
);
}
licensing::AppStatus::Trial { days_remaining, .. } => {
log::info!("Trial active: {days_remaining} days remaining");
}
licensing::AppStatus::Expired => {
log::info!("Trial expired — license activation required");
}
}
}
// Add bundled ffmpeg/ffprobe to PATH so Python subprocesses find them
if let Some(ffmpeg_dir) = paths::bundled_ffmpeg().and_then(|p| p.parent().map(|p| p.to_path_buf())) {
if let Ok(current_path) = std::env::var("PATH") {
let new_path = format!("{}:{}", ffmpeg_dir.display(), current_path);
std::env::set_var("PATH", &new_path);
}
log::info!("Added bundled FFmpeg directory to PATH: {}", ffmpeg_dir.display());
}
Ok(()) Ok(())
}) })
.invoke_handler(tauri::generate_handler![ .invoke_handler(tauri::generate_handler![
@ -263,6 +430,18 @@ pub fn run() {
save_captions, save_captions,
is_background_removal_available, is_background_removal_available,
remove_background_on_export, remove_background_on_export,
get_app_status,
activate_license,
deactivate_license,
verify_license,
start_trial,
has_license_feature,
list_models,
delete_model,
log_error,
write_autosave,
read_autosave,
delete_autosave,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

293
src-tauri/src/licensing.rs Normal file
View File

@ -0,0 +1,293 @@
use base64::engine::general_purpose::STANDARD_NO_PAD as BASE64;
use base64::Engine;
use ed25519_dalek::{Signature, VerifyingKey};
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
pub const TALKEDIT_PUBLIC_KEY: [u8; 32] = [228, 216, 102, 187, 61, 187, 236, 140, 37, 32, 158, 153, 35, 80, 20, 129, 172, 167, 96, 115, 141, 56, 244, 123, 237, 7, 255, 18, 92, 114, 152, 31];
pub const TRIAL_DURATION_SECS: u64 = 7 * 86400;
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct LicensePayload {
pub license_id: String,
pub customer_email: String,
pub tier: String,
pub features: Vec<String>,
pub issued_at: u64,
pub expires_at: u64,
pub max_activations: u32,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct TrialState {
pub started_at: u64,
}
/// On-disk format with integrity seed to deter tampering.
#[derive(Debug, Serialize, Deserialize)]
struct TrialFile {
started_at: u64,
seed: u64,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "tag")]
pub enum AppStatus {
Licensed { license: LicensePayload },
Trial { days_remaining: u32, started_at: u64 },
Expired,
}
#[derive(Debug)]
pub enum LicenseError {
InvalidFormat,
InvalidSignature,
Expired,
DecodeError(String),
CryptoError(String),
}
impl std::fmt::Display for LicenseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::InvalidFormat => write!(f, "Invalid license key format"),
Self::InvalidSignature => write!(f, "License signature is invalid"),
Self::Expired => write!(f, "License has expired"),
Self::DecodeError(e) => write!(f, "License decode error: {e}"),
Self::CryptoError(e) => write!(f, "Crypto error: {e}"),
}
}
}
// --- License key verification ---
pub fn verify_license_key(license_key: &str) -> Result<LicensePayload, LicenseError> {
let stripped = license_key
.strip_prefix("talkedit_v1_")
.ok_or(LicenseError::InvalidFormat)?;
let dot_pos = stripped.rfind('.').ok_or(LicenseError::InvalidFormat)?;
let payload_b64 = &stripped[..dot_pos];
let sig_b64 = &stripped[dot_pos + 1..];
if payload_b64.is_empty() || sig_b64.is_empty() {
return Err(LicenseError::InvalidFormat);
}
let payload_bytes = BASE64
.decode(payload_b64)
.map_err(|e| LicenseError::DecodeError(e.to_string()))?;
let sig_bytes = BASE64
.decode(sig_b64)
.map_err(|e| LicenseError::DecodeError(e.to_string()))?;
let verifying_key = VerifyingKey::from_bytes(&TALKEDIT_PUBLIC_KEY)
.map_err(|e| LicenseError::CryptoError(e.to_string()))?;
let signature = Signature::from_slice(&sig_bytes)
.map_err(|e| LicenseError::DecodeError(e.to_string()))?;
verifying_key
.verify_strict(&payload_bytes, &signature)
.map_err(|_| LicenseError::InvalidSignature)?;
let payload: LicensePayload = serde_json::from_slice(&payload_bytes)
.map_err(|e| LicenseError::DecodeError(e.to_string()))?;
let now = now_secs();
if now > payload.expires_at {
return Err(LicenseError::Expired);
}
Ok(payload)
}
// --- License file I/O ---
fn now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
pub fn license_file_path(app_data_dir: &PathBuf) -> PathBuf {
app_data_dir.join("license.key")
}
pub fn trial_file_path(app_data_dir: &PathBuf) -> PathBuf {
app_data_dir.join("trial.json")
}
pub fn trial_sentinel_path(app_data_dir: &PathBuf) -> PathBuf {
app_data_dir.join(".trial_lock")
}
// Simple integrity check constant — not crypto-grade, but deters casual editing.
const TRIAL_SEED: u64 = 0x9F3A_2E7D_C1B8_5604;
pub fn get_or_start_trial(app_data_dir: &PathBuf) -> TrialState {
let path = trial_file_path(app_data_dir);
let sentinel = trial_sentinel_path(app_data_dir);
let now = now_secs();
// If sentinel exists but trial was deleted, refuse to create a new one.
let sentinel_exists = sentinel.exists();
if let Ok(content) = std::fs::read_to_string(&path) {
if let Ok(wrapped) = serde_json::from_str::<TrialFile>(&content) {
// Verify integrity
if (wrapped.started_at ^ TRIAL_SEED) == wrapped.seed {
return TrialState { started_at: wrapped.started_at };
}
}
}
if sentinel_exists {
// Trial was tampered with — return an expired trial
return TrialState { started_at: 0 };
}
// Start new trial
let trial = TrialState { started_at: now };
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(&sentinel, "1");
let _ = std::fs::write(
&path,
serde_json::to_string(&TrialFile {
started_at: trial.started_at,
seed: trial.started_at ^ TRIAL_SEED,
})
.unwrap(),
);
trial
}
pub fn load_cached_license(app_data_dir: &PathBuf) -> Result<LicensePayload, LicenseError> {
let path = license_file_path(app_data_dir);
let content = std::fs::read_to_string(&path).map_err(|_| LicenseError::InvalidFormat)?;
verify_license_key(content.trim())
}
pub fn cache_license(app_data_dir: &PathBuf, license_key: &str) -> Result<(), LicenseError> {
let path = license_file_path(app_data_dir);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)
.map_err(|e| LicenseError::DecodeError(e.to_string()))?;
}
std::fs::write(&path, license_key)
.map_err(|e| LicenseError::DecodeError(e.to_string()))
}
pub fn remove_license(app_data_dir: &PathBuf) {
let path = license_file_path(app_data_dir);
let _ = std::fs::remove_file(&path);
}
// --- Trial logic ---
pub fn get_trial_info(trial: &TrialState) -> (u64, u64, bool) {
let now = now_secs();
let elapsed = now.saturating_sub(trial.started_at);
let remaining = TRIAL_DURATION_SECS.saturating_sub(elapsed);
let days_remaining = remaining / 86400;
let is_active = remaining > 0;
(days_remaining, remaining, is_active)
}
/// Check if trial is still valid and return remaining days.
pub fn get_trial_days_remaining(trial: &TrialState) -> Option<u32> {
let (days, _, active) = get_trial_info(trial);
if active {
Some(days as u32)
} else {
None
}
}
pub fn clear_trial(app_data_dir: &PathBuf) {
let _ = std::fs::remove_file(trial_file_path(app_data_dir));
let _ = std::fs::remove_file(trial_sentinel_path(app_data_dir));
}
/// Get the overall app status: Licensed > Trial > Expired.
pub fn get_app_status(app_data_dir: &PathBuf) -> AppStatus {
// 1. Check for valid license
if let Ok(license) = load_cached_license(app_data_dir) {
return AppStatus::Licensed { license };
}
// 2. Check for active trial
let trial = get_or_start_trial(app_data_dir);
if let Some(days) = get_trial_days_remaining(&trial) {
return AppStatus::Trial {
days_remaining: days,
started_at: trial.started_at,
};
}
// 3. Everything expired
AppStatus::Expired
}
pub fn has_feature(payload: &LicensePayload, feature: &str) -> bool {
payload.features.iter().any(|f| f == feature)
}
#[cfg(test)]
mod tests {
use super::*;
const TEST_KEY: &str = "talkedit_v1_eyJsaWNlbnNlX2lkIjoidGVzdF8wMDEiLCJjdXN0b21lcl9lbWFpbCI6InRlc3RAZXhhbXBsZS5jb20iLCJ0aWVyIjoicHJvIiwiZmVhdHVyZXMiOlsiYnVuZGxlZF9kZXBzIiwiYXV0b191cGRhdGVzIiwiYmdfcmVtb3ZhbCJdLCJpc3N1ZWRfYXQiOjE3NzgwMDAwMDAsImV4cGlyZXNfYXQiOjE4MDk1MzYwMDAsIm1heF9hY3RpdmF0aW9ucyI6M30.1Hw9FT6USo+05lB0NSJmTvCgAby9ep/BFWv95CvACn0wNpJl5Z6uGDIuIEe077t+CszqGJ8Lci+ZlZyb41foDQ";
#[test]
fn test_verify_valid_license() {
let result = verify_license_key(TEST_KEY);
assert!(result.is_ok(), "Expected OK, got: {:?}", result);
let payload = result.unwrap();
assert_eq!(payload.customer_email, "test@example.com");
assert_eq!(payload.tier, "pro");
assert!(payload.features.contains(&"bg_removal".to_string()));
}
#[test]
fn test_verify_invalid_signature() {
let dot = TEST_KEY.rfind('.').unwrap();
let prefix = &TEST_KEY[..dot + 1];
let bad_sig = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
let tampered = format!("{prefix}{bad_sig}");
let result = verify_license_key(&tampered);
assert!(matches!(result, Err(LicenseError::InvalidSignature)));
}
#[test]
fn test_verify_bad_format() {
let result = verify_license_key("not-a-license-key");
assert!(matches!(result, Err(LicenseError::InvalidFormat)));
}
#[test]
fn test_has_feature() {
let payload = verify_license_key(TEST_KEY).unwrap();
assert!(has_feature(&payload, "bg_removal"));
assert!(!has_feature(&payload, "nonexistent_feature"));
}
#[test]
fn test_trial_dates() {
let trial = TrialState { started_at: now_secs() };
let days = get_trial_days_remaining(&trial);
assert!(days.is_some());
assert_eq!(days.unwrap(), 7);
}
#[test]
fn test_expired_trial() {
let trial = TrialState { started_at: 0 }; // epoch = 1970, definitely expired
let days = get_trial_days_remaining(&trial);
assert!(days.is_none());
}
}

190
src-tauri/src/models.rs Normal file
View File

@ -0,0 +1,190 @@
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Serialize, Deserialize)]
pub struct ModelInfo {
pub name: String,
pub path: String,
pub size_bytes: u64,
pub kind: String,
}
fn huggingface_cache_dir() -> PathBuf {
// Follows huggingface_hub default cache location
if let Ok(custom) = std::env::var("HF_HOME") {
return PathBuf::from(custom).join("hub");
}
if let Ok(custom) = std::env::var("XDG_CACHE_HOME") {
return PathBuf::from(custom).join("huggingface").join("hub");
}
dirs::home_dir()
.unwrap_or_default()
.join(".cache")
.join("huggingface")
.join("hub")
}
fn scan_whisper_models() -> Vec<ModelInfo> {
let cache_dir = huggingface_cache_dir();
if !cache_dir.exists() {
return vec![];
}
let mut models = vec![];
let pattern = "models--Systran--faster-whisper-";
let Ok(entries) = std::fs::read_dir(&cache_dir) else {
return vec![];
};
for entry in entries.flatten() {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if !name_str.starts_with(pattern) {
continue;
}
let model_name = name_str
.strip_prefix(pattern)
.unwrap_or(&name_str)
.to_string();
// The actual model files are in snapshots/ subdirectory
let snapshots_dir = entry.path().join("snapshots");
let mut total_size = 0u64;
if let Ok(snap_entries) = std::fs::read_dir(&snapshots_dir) {
for snap in snap_entries.flatten() {
total_size += dir_size(&snap.path());
}
}
// If no snapshots dir, try blobs/
if total_size == 0 {
let blobs_dir = entry.path().join("blobs");
if blobs_dir.exists() {
total_size = dir_size(&blobs_dir);
}
}
models.push(ModelInfo {
name: model_name,
path: entry.path().to_string_lossy().to_string(),
size_bytes: total_size,
kind: "whisper".to_string(),
});
}
models
}
fn scan_llm_models(app_data_dir: &PathBuf) -> Vec<ModelInfo> {
let models_dir = app_data_dir.join("models");
if !models_dir.exists() {
return vec![];
}
let mut models = vec![];
let Ok(entries) = std::fs::read_dir(&models_dir) else {
return vec![];
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().map(|e| e == "gguf").unwrap_or(false) {
let meta = std::fs::metadata(&path).ok();
models.push(ModelInfo {
name: entry.file_name().to_string_lossy().to_string(),
path: path.to_string_lossy().to_string(),
size_bytes: meta.map(|m| m.len()).unwrap_or(0),
kind: "llm".to_string(),
});
}
}
models
}
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 path = entry.path();
if path.is_dir() {
total += dir_size(&path);
} else if let Ok(meta) = std::fs::metadata(&path) {
total += meta.len();
}
}
}
total
}
pub fn list_models(app_data_dir: &PathBuf) -> Vec<ModelInfo> {
let mut models = scan_whisper_models();
models.extend(scan_llm_models(app_data_dir));
models
}
pub fn delete_model(path: &str) -> Result<(), String> {
let path = std::path::Path::new(path);
if !path.exists() {
return Err("Model path not found".to_string());
}
if path.is_dir() {
std::fs::remove_dir_all(path)
.map_err(|e| format!("Failed to delete model: {e}"))?;
} else {
std::fs::remove_file(path)
.map_err(|e| format!("Failed to delete model: {e}"))?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
#[test]
fn test_dir_size_empty() {
let size = dir_size(&PathBuf::from("/nonexistent/path/12345"));
assert_eq!(size, 0);
}
#[test]
fn test_scan_whisper_models_empty() {
let models = scan_whisper_models();
// In CI there won't be any whisper models
// Just verify it doesn't panic
assert!(models.len() >= 0);
}
#[test]
fn test_scan_llm_models_empty() {
let models = scan_llm_models(&PathBuf::from("/nonexistent/app_data"));
assert!(models.is_empty());
}
#[test]
fn test_list_models_empty() {
let models = list_models(&PathBuf::from("/nonexistent/app_data"));
// No models should be found in a non-existent directory
let whisper_models = models.iter().filter(|m| m.kind == "whisper").count();
let llm_models = models.iter().filter(|m| m.kind == "llm").count();
assert_eq!(llm_models, 0);
// whisper models may or may not exist on dev machine
assert!(whisper_models >= 0);
}
#[test]
fn test_delete_model_nonexistent() {
let result = delete_model("/nonexistent/model/path.gguf");
assert!(result.is_err());
}
#[test]
fn test_delete_model_empty_path() {
let result = delete_model("");
assert!(result.is_err());
}
}

View File

@ -62,7 +62,38 @@ pub fn backend_script(name: &str) -> PathBuf {
project_root().join("backend").join(name) project_root().join("backend").join(name)
} }
/// Absolute path to a script at the project root. /// Locate bundled ffmpeg binary (sidecar).
pub fn bundled_ffmpeg() -> Option<PathBuf> {
let exe = std::env::current_exe().ok()?;
let dir = exe.parent()?;
// Tauri places externalBin next to the executable
let candidates = [
dir.join("ffmpeg"),
dir.join("binaries").join("ffmpeg"),
];
for c in &candidates {
if c.exists() {
return Some(c.clone());
}
}
None
}
/// Locate bundled ffprobe binary (sidecar).
pub fn bundled_ffprobe() -> Option<PathBuf> {
let exe = std::env::current_exe().ok()?;
let dir = exe.parent()?;
let candidates = [
dir.join("ffprobe"),
dir.join("binaries").join("ffprobe"),
];
for c in &candidates {
if c.exists() {
return Some(c.clone());
}
}
None
}
pub fn root_script(name: &str) -> PathBuf { pub fn root_script(name: &str) -> PathBuf {
project_root().join(name) project_root().join(name)
} }

View File

@ -6,7 +6,7 @@
"build": { "build": {
"frontendDist": "../frontend/dist", "frontendDist": "../frontend/dist",
"devUrl": "http://localhost:5173", "devUrl": "http://localhost:5173",
"beforeDevCommand": "cd frontend && npm run dev", "beforeDevCommand": "lsof -ti:5173 | xargs kill -9 2>/dev/null; cd frontend && npm run dev",
"beforeBuildCommand": "cd frontend && npm run build" "beforeBuildCommand": "cd frontend && npm run build"
}, },
"app": { "app": {
@ -35,6 +35,10 @@
"icons/128x128@2x.png", "icons/128x128@2x.png",
"icons/icon.icns", "icons/icon.icns",
"icons/icon.ico" "icons/icon.ico"
],
"externalBin": [
"binaries/ffmpeg",
"binaries/ffprobe"
] ]
} }
} }

View File

@ -45,7 +45,16 @@ def main():
device = "cpu" device = "cpu"
compute_type = "int8" compute_type = "int8"
model = WhisperModel(model_name, device=device, compute_type=compute_type) try:
model = WhisperModel(model_name, device=device, compute_type=compute_type)
except RuntimeError as e:
if "out of memory" in str(e).lower() and device == "cuda":
print(f"CUDA OOM, falling back to CPU (int8)", file=sys.stderr)
device = "cpu"
compute_type = "int8"
model = WhisperModel(model_name, device=device, compute_type=compute_type)
else:
raise
# Transcribe with progress reporting # Transcribe with progress reporting
print(f"Starting transcription of {wav_path} with model {model_name}", file=sys.stderr) print(f"Starting transcription of {wav_path} with model {model_name}", file=sys.stderr)