Compare commits
26 Commits
1678d28db7
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| b558ef8a7f | |||
| f1e6c010eb | |||
| 124f215a0a | |||
| 1993aabeac | |||
| 573ac9c9f5 | |||
| 5d52c8aec5 | |||
| 8bd1ad5b69 | |||
| 850b373d42 | |||
| 2212d7b265 | |||
| 813877a7b4 | |||
| e4484a57f9 | |||
| 10437c02ca | |||
| 4004312994 | |||
| 9a301fe2a2 | |||
| 6ac1d68887 | |||
| acf7f2e64c | |||
| a96e42c9f9 | |||
| fd6697b48e | |||
| 09ebcbc9ec | |||
| 88cd9a21d0 | |||
| 91217f6db0 | |||
| 835719a907 | |||
| 810957747b | |||
| 4d4dfa7f7c | |||
| cde635a660 | |||
| 21e4255325 |
4
.github/copilot-instructions.md
vendored
4
.github/copilot-instructions.md
vendored
@ -73,6 +73,10 @@ Use project virtualenvs where available (`.venv312`, `.venv`, or `venv`) for bac
|
||||
- **color-scheme:dark**: All `<select>` elements in ExportDialog use `[color-scheme:dark]` to ensure readable native dropdown popups on Linux WebKit.
|
||||
- **Re-transcribe selection (#013)**: Backend `POST /transcribe/segment` extracts audio via FFmpeg, runs Whisper, adjusts timestamps. Frontend: "Re-transcribe" button on selected words in TranscriptEditor; `replaceWordRange()` store action swaps words + rebuilds segments by speaker.
|
||||
- **Transcript-only export (#024)**: "Export Transcript Only" in ExportDialog with .txt/.srt options. **Pure frontend** — generates content in-browser, writes via Tauri `writeFile`. No backend dependency. Respects word cuts.
|
||||
- **Named timeline markers (#016)**: `TimelineMarker` type in `project.ts`. Store actions: `addTimelineMarker`, `updateTimelineMarker`, `removeTimelineMarker`. Colored pins on waveform canvas. MarkersPanel UI for add/edit/delete. Persisted in project.
|
||||
- **Chapters (#017)**: `getChapters()` store action derives from sorted markers. "Copy as YouTube timestamps" in MarkersPanel. Zero backend.
|
||||
- **Clip thumbnail strip (#022)**: `lib/thumbnails.ts` — frontend canvas capture from `<video>`. Toggle button in WaveformTimeline. Clickable frames at 10s intervals.
|
||||
- **Customizable hotkeys (#041)**: `lib/keybindings.ts` with two presets (standard + left-hand). `useKeyboardShortcuts.ts` reads bindings dynamically. Settings panel includes key remapper with conflict detection and per-key reset. `?` key shows dynamic cheatsheet.
|
||||
|
||||
## Update Rules (Important)
|
||||
|
||||
|
||||
45
.github/workflows/ci.yml
vendored
Normal file
45
.github/workflows/ci.yml
vendored
Normal 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
|
||||
86
.github/workflows/release.yml
vendored
Normal file
86
.github/workflows/release.yml
vendored
Normal file
@ -0,0 +1,86 @@
|
||||
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
|
||||
- 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
|
||||
260
FEATURES.md
260
FEATURES.md
@ -1,91 +1,181 @@
|
||||
# TalkEdit — Feature Roadmap
|
||||
# TalkEdit — Features & Roadmap
|
||||
|
||||
Features are grouped by priority. Check off items as they are implemented.
|
||||
|
||||
---
|
||||
|
||||
## 🔴 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
|
||||
|
||||
- [ ] [#016] **Named timeline markers** — drop named marker pins on the waveform (like Resolve markers). Store as `{ id, time, label, color }` in the project. Rendered as colored triangles on the timeline canvas.
|
||||
|
||||
- [ ] [#017] **Chapters** — group markers into named chapter ranges. Useful for podcasts and lectures. Exportable as YouTube chapter timestamps in the description.
|
||||
|
||||
- [ ] [#041] **Customizable hotkeys / keymap editor (left-hand focused)** — allow users to view, remap, and reset keyboard shortcuts (transport, edit, save/export, zone tools), with a default preset optimized for left-hand reach (Q/W/E/R/A/S/D/F/Z/X/C/V + modifiers). Include conflict detection, an alternate standard preset, and one-click "restore defaults".
|
||||
|
||||
- [ ] [#022] **Clip thumbnail strip** — video frame thumbnails along the timeline so users can navigate visually, not only by waveform. Backend: `ffmpeg` thumbnail extraction at regular intervals.
|
||||
|
||||
---
|
||||
|
||||
## 🟢 Lower Impact — Expansion and advanced scope
|
||||
|
||||
- [ ] [#020] **Video zoom / punch-in** — scale and position the video (crop, zoom, pan). Used constantly on talking-head videos for emphasis. Backend: `ffmpeg -vf crop/scale/zoompan`.
|
||||
|
||||
- [ ] [#021] **Multi-clip / append** — load a second video and append it to the timeline. Even without a full multi-track timeline, "append clip" is a heavily used workflow.
|
||||
|
||||
- [ ] [#019] **Background music track** — a second audio track for background music with volume ducking. Major gap in Descript that TalkEdit could own. Backend: `ffmpeg` amix + `asendcmd` for auto-ducking.
|
||||
|
||||
- [ ] [#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)**
|
||||
|
||||
---
|
||||
|
||||
## 💡 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.
|
||||
**Niche:** "Descript for long-form content" — works on hour+ files without degrading, fully offline, one-time payment.
|
||||
|
||||
---
|
||||
|
||||
## ✅ Already Implemented
|
||||
|
||||
- [#025] Word-level transcript editing (select, drag, shift-click, delete)
|
||||
- [#026] Ctrl+click word → seek timeline to that position
|
||||
- [#027] Waveform timeline with zoom (Ctrl+scroll), scroll, drag-to-scrub playhead
|
||||
- [#028] Auto-scroll waveform when playhead goes off-screen
|
||||
- [#029] AI filler word detection and removal (Ollama / OpenAI / Claude)
|
||||
- [#030] AI clip suggestions for social media
|
||||
- [#031] Noise reduction (DeepFilterNet or FFmpeg ANLMDN)
|
||||
- [#032] Export: fast stream-copy or full reencode (MP4/MOV/WebM/WAV, 720p/1080p/4K). WAV available for audio-only inputs.
|
||||
- [#033] Captions: SRT, VTT, ASS burn-in with font/color/position options
|
||||
- [#034] Speaker diarization
|
||||
- [#035] Project save / load (.aive JSON format)
|
||||
- [#036] Undo / redo (100-level history via Zundo)
|
||||
- [#037] Multi-format input (MP4, MKV, MOV, AVI, WebM, M4A)
|
||||
- [#038] Keyboard shortcuts (Space, J/K/L, arrows, Ctrl+Z/Shift+Z, Ctrl+S, Ctrl+E)
|
||||
- [#039] Settings panel: AI provider config (Ollama, OpenAI, Claude)
|
||||
- [#040] Cut/mute range creation on timeline with draggable zone edits and Delete-to-remove
|
||||
### Core editing
|
||||
- [x] [#001] **Cut / Mute sections** — remove or silence segments from output
|
||||
- [x] [#002] **Silence / pause trimmer** — batch detect and remove silent pauses
|
||||
- [x] [#006] **Volume / gain control** — per-zone and global gain adjustment
|
||||
- [x] [#007] **Speed adjustment** — per-zone playback speed changes (0.25x–4x)
|
||||
- [x] [#008] **Cut preview** — preview zones before export with configurable padding
|
||||
- [x] [#009] **Timeline shows output length** — adjusted timeline with cut compression
|
||||
- [x] [#011] **Mark In / Out** — I/O keys to set selection range on timeline
|
||||
|
||||
### Transcript
|
||||
- [x] [#010] **Transcript search (Ctrl+F)** — find words, navigate matches
|
||||
- [x] [#012] **Low-confidence word highlighting** — orange dotted underline with confidence %
|
||||
- [x] [#013] **Re-transcribe selection** — re-run Whisper on a selected word range
|
||||
- [x] [#015] **Word text correction** — double-click any word to edit text in-place
|
||||
- [x] [#016] **Named timeline markers** — colored pins with labels, editable
|
||||
- [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, 720p–4K)
|
||||
- [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 10–90s 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 5–10 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 (3–5 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
183
README.md
@ -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
|
||||
- **FastAPI** Python backend (spawned as child process)
|
||||
- **WhisperX** for word-level transcription with alignment
|
||||
- **FFmpeg** for video processing (stream-copy and re-encode)
|
||||
- **Ollama / OpenAI / Claude** for AI features (filler removal, clip creation)
|
||||
- **Text-based editing** — delete, reorder, or correct words in the transcript to edit the underlying video. No razor tool, no timeline slicing.
|
||||
- **Word-level transcription** — Whisper.cpp with per-word timestamps and confidence scores. Low-confidence words get a visual warning.
|
||||
- **Four zone types** — Cut, Mute, Sound Gain, and Speed Adjust. Create zones on the waveform timeline and drag edges to refine.
|
||||
- **Waveform timeline** — zoomable, scrollable waveform with playhead scrubbing, zone visualization, markers, chapters, and thumbnail strips.
|
||||
- **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
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Node.js 18+
|
||||
- Python 3.10+
|
||||
- FFmpeg (in PATH)
|
||||
- (Optional) Ollama for local AI features
|
||||
- **Node.js** 18+
|
||||
- **Python** 3.10+
|
||||
- **FFmpeg** (in PATH)
|
||||
- **Rust** toolchain (for Tauri)
|
||||
- **Ollama** (optional, for local AI features)
|
||||
|
||||
### Install
|
||||
|
||||
@ -36,65 +68,89 @@ cd backend && pip install -r requirements.txt && cd ..
|
||||
### Run (Development)
|
||||
|
||||
```bash
|
||||
# Start Tauri dev environment (includes backend + frontend)
|
||||
# Start everything: backend + frontend + Tauri
|
||||
npm run dev:tauri
|
||||
```
|
||||
|
||||
Or run them separately:
|
||||
Or run components separately:
|
||||
|
||||
```bash
|
||||
# Terminal 1: Backend
|
||||
cd backend && python -m uvicorn main:app --reload --port 8642
|
||||
# Terminal 1: Python backend
|
||||
npm run dev:backend
|
||||
|
||||
# Terminal 2: Frontend + Tauri
|
||||
cd frontend && cargo tauri dev
|
||||
```
|
||||
|
||||
### Build
|
||||
|
||||
```bash
|
||||
npm run build:tauri
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
talkedit/
|
||||
├── src-tauri/ # Tauri Rust runtime
|
||||
├── src-tauri/ # Tauri 2.0 Rust runtime
|
||||
│ ├── Cargo.toml
|
||||
│ ├── src/
|
||||
│ │ ├── main.rs # App entry & backend spawner
|
||||
│ │ └── commands/ # Tauri IPC handlers
|
||||
├── frontend/ # React + Vite + Tailwind
|
||||
│ └── src/
|
||||
│ ├── components/ # VideoPlayer, TranscriptEditor, etc.
|
||||
│ ├── store/ # Zustand state (editorStore, aiStore)
|
||||
│ ├── lib/tauri-bridge.ts # Tauri API polyfill
|
||||
│ └── types/ # TypeScript interfaces
|
||||
├── backend/ # FastAPI Python backend
|
||||
│ ├── main.rs # App entry, backend spawner
|
||||
│ ├── lib.rs # Command handlers (IPC bridge)
|
||||
│ ├── transcription.rs # Whisper.cpp integration
|
||||
│ ├── video_editor.rs # FFmpeg-based editing
|
||||
│ ├── 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
|
||||
│ ├── routers/ # API endpoints
|
||||
│ ├── services/ # Core logic (transcription, editing, AI)
|
||||
│ └── utils/ # GPU, cache, audio helpers
|
||||
└── shared/ # Project schema
|
||||
│ ├── routers/ # API endpoints
|
||||
│ │ ├── transcribe.py
|
||||
│ │ ├── ai.py
|
||||
│ │ ├── 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
|
||||
|
||||
@ -102,28 +158,19 @@ talkedit/
|
||||
|-----|--------|
|
||||
| Space | Play / Pause |
|
||||
| J / K / L | Reverse / Pause / Forward |
|
||||
| I / O | Mark In / Mark Out |
|
||||
| ← / → | Seek ±5 seconds |
|
||||
| Delete | Delete selected words |
|
||||
| Delete | Delete selected words or zones |
|
||||
| Ctrl+Z | Undo |
|
||||
| Ctrl+Shift+Z | Redo |
|
||||
| Ctrl+S | Save project |
|
||||
| Ctrl+E | Export |
|
||||
| Ctrl+F | Search transcript |
|
||||
| Ctrl+Scroll | Zoom waveform |
|
||||
| ? | 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
|
||||
|
||||
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.
|
||||
|
||||
@ -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
|
||||
- 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
|
||||
- Frontend: React + Vite + Tailwind CSS + shadcn/ui
|
||||
- Backend: Tauri 2.0 (Rust) for file I/O, invoking native binaries, and exposing commands to the UI
|
||||
- Transcription: Whisper.cpp (Rust bindings like `whisper-rs` / `whisper-cpp-sys`) — word-level timestamps
|
||||
- Audio/Video Processing: FFmpeg invoked from Rust (or `ffmpeg-next` Rust crate)
|
||||
- State: Zustand (in-frontend store)
|
||||
- Frontend: React 19 + Vite + TypeScript + Tailwind CSS + Zustand (with zundo undo/redo) + Virtuoso (virtualized transcript)
|
||||
- Backend: Tauri 2.0 (Rust) for file I/O, licensing, licensing crypto (Ed25519), model management, error logging
|
||||
- Transcription: Python faster-whisper with WhisperX for word-level alignment. Models downloaded on demand.
|
||||
- Audio/Video Processing: FFmpeg invoked from Rust via Python scripts (video_editor.py, audio_cleaner.py, caption_generator.py)
|
||||
- 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
|
||||
- Optional local tools: Ollama (optional local LLMs) for advanced on-device heuristics
|
||||
|
||||
## Developer Tools
|
||||
- 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)
|
||||
- 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)
|
||||
1. Drag-and-drop import (audio/video auto audio-extract)
|
||||
2. One-click local transcription (model selector: tiny/base → larger models)
|
||||
3. Scrollable, Google-Doc-style transcript editor
|
||||
## Implemented Features
|
||||
|
||||
- [x] 1. Media import via file dialog (audio/video auto audio-extract)
|
||||
- [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
|
||||
- Highlight + Delete → remove corresponding media segment (smart 150–250ms fades)
|
||||
4. One-click "Clean it" button
|
||||
- Remove fillers (configurable list)
|
||||
- Remove long pauses (>0.8s) by default
|
||||
5. One-click audio polish chain (FFmpeg): normalize, light compression, basic noise reduction
|
||||
6. Preview with synced playback, undo/redo, project save/load
|
||||
7. Export MP4/audio with optional SRT/VTT captions and burned-in captions
|
||||
- Select words → cut corresponding media segment (smart 150–250ms fades)
|
||||
- [x] 4. Smart Cleanup
|
||||
- Filler word removal (configurable list per-project)
|
||||
- Silence trimming
|
||||
- [x] 5. Audio Polish chain (FFmpeg): normalize, compression, noise reduction
|
||||
- [x] 6. Preview with synced playback, undo/redo (zundo), project save/load
|
||||
- [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)
|
||||
- Model-size chooser + progressive fallback (start fast, upgrade model later)
|
||||
- Local GPU/CPU detection & recommended model/settings UI
|
||||
- Per-project incremental transcription: re-run only edited segments
|
||||
- "Preview cleaning" dry-run that highlights candidate removals before applying
|
||||
- Export size/time estimator and suggested export presets
|
||||
- Custom filler lists per-project and import/export of filler lists
|
||||
- High-quality offline captions export (SRT + VTT + speaker labels)
|
||||
- Accessibility export presets (podcast vs YouTube presets)
|
||||
|
||||
- [ ] Local GPU/CPU detection & recommended model/settings UI
|
||||
- [ ] Per-project incremental transcription: re-run only edited segments
|
||||
- [ ] "Preview cleaning" dry-run that highlights candidate removals before applying
|
||||
- [ ] Export size/time estimator and suggested export presets
|
||||
- [ ] Accessibility export presets (podcast vs YouTube presets)
|
||||
- [ ] Bundled Qwen3 LLM for offline AI features
|
||||
|
||||
## Remove / Defer (Back Burner)
|
||||
These broaden scope or add legal/privacy surface — defer for now.
|
||||
|
||||
- Voice cloning / TTS: DEFER
|
||||
- Multi-track, full timeline NLE features: DEFER
|
||||
- Real-time collaboration / cloud sync: DEFER
|
||||
@ -52,18 +64,20 @@ These broaden scope or add legal/privacy surface — defer for now.
|
||||
|
||||
## Risks & Mitigations
|
||||
- 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.
|
||||
|
||||
## Prioritized Quick Wins
|
||||
1. Model chooser UI + auto-fallback settings
|
||||
1. Per-project incremental transcription
|
||||
2. "Preview cleaning" dry-run UI
|
||||
3. Per-project incremental transcription saving
|
||||
3. Export presets (podcast vs YouTube)
|
||||
|
||||
## Next Steps for Implementation
|
||||
- Add model chooser UI and capability detection early in the frontend iteration.
|
||||
- Implement Rust transcription command and a compact API for incremental transcription.
|
||||
- Implement FFmpeg polish templates and a minimal preview pipeline.
|
||||
- Bundle Qwen3 LLM for offline AI processing.
|
||||
- Implement incremental transcription to speed up re-editing workflows.
|
||||
- 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.
|
||||
|
||||
@ -9,6 +9,8 @@ from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import StreamingResponse
|
||||
from pathlib import Path
|
||||
|
||||
from routers import audio
|
||||
|
||||
app = FastAPI(title="TalkEdit Dev Backend", version="0.0.1")
|
||||
|
||||
app.add_middleware(
|
||||
@ -34,6 +36,8 @@ MIME_MAP = {
|
||||
}
|
||||
|
||||
|
||||
app.include_router(audio.router)
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
|
||||
223
backend/license_server.py
Normal file
223
backend/license_server.py
Normal 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()
|
||||
@ -8,9 +8,10 @@ from typing import List, Optional
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from services.video_editor import export_stream_copy, export_reencode, export_reencode_with_subs
|
||||
from services.video_editor import export_stream_copy, export_reencode, export_reencode_with_subs, mix_background_music, concat_clips
|
||||
from services.audio_cleaner import clean_audio
|
||||
from services.caption_generator import generate_srt, generate_ass, save_captions
|
||||
from services.background_removal import remove_background_on_export as remove_bg
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
@ -36,6 +37,22 @@ class ExportWordModel(BaseModel):
|
||||
confidence: float = 0.0
|
||||
|
||||
|
||||
class ZoomConfigModel(BaseModel):
|
||||
enabled: bool = False
|
||||
zoomFactor: float = 1.0
|
||||
panX: float = 0.0
|
||||
panY: float = 0.0
|
||||
|
||||
|
||||
class BackgroundMusicModel(BaseModel):
|
||||
path: str
|
||||
volumeDb: float = 0.0
|
||||
duckingEnabled: bool = False
|
||||
duckingDb: float = 6.0
|
||||
duckingAttackMs: float = 10.0
|
||||
duckingReleaseMs: float = 200.0
|
||||
|
||||
|
||||
class ExportRequest(BaseModel):
|
||||
input_path: str
|
||||
output_path: str
|
||||
@ -53,6 +70,12 @@ class ExportRequest(BaseModel):
|
||||
captions: str = "none"
|
||||
words: Optional[List[ExportWordModel]] = None
|
||||
deleted_indices: Optional[List[int]] = None
|
||||
zoom: Optional[ZoomConfigModel] = None
|
||||
additional_clips: Optional[List[str]] = None
|
||||
background_music: Optional[BackgroundMusicModel] = None
|
||||
remove_background: bool = False
|
||||
background_replacement: str = "blur"
|
||||
background_replacement_value: str = ""
|
||||
|
||||
|
||||
class TranscriptExportRequest(BaseModel):
|
||||
@ -130,6 +153,29 @@ async def export_video(req: ExportRequest):
|
||||
if not segments and not mute_segments:
|
||||
raise HTTPException(status_code=400, detail="No segments to export")
|
||||
|
||||
# Convert zoom config to dict
|
||||
zoom_dict = None
|
||||
if req.zoom and req.zoom.enabled:
|
||||
zoom_dict = {
|
||||
"enabled": True,
|
||||
"zoomFactor": req.zoom.zoomFactor,
|
||||
"panX": req.zoom.panX,
|
||||
"panY": req.zoom.panY,
|
||||
}
|
||||
|
||||
# Handle additional clips: pre-concat before main editing
|
||||
working_input = req.input_path
|
||||
has_additional = bool(req.additional_clips)
|
||||
if has_additional:
|
||||
try:
|
||||
concat_output = req.output_path + ".concat.mp4"
|
||||
concat_clips(req.input_path, req.additional_clips, concat_output)
|
||||
working_input = concat_output
|
||||
logger.info("Pre-concatenated %d additional clips into %s", len(req.additional_clips), concat_output)
|
||||
except Exception as e:
|
||||
logger.warning(f"Clip concatenation failed (non-fatal): {e}")
|
||||
# Fall back to main input only
|
||||
|
||||
mapped_gain_segments = _map_ranges_to_output_timeline(gain_segments or [], segments)
|
||||
|
||||
has_gain = abs(float(req.global_gain_db)) > 1e-6 or bool(gain_segments)
|
||||
@ -141,7 +187,7 @@ async def export_video(req: ExportRequest):
|
||||
detail="Speed zones currently cannot be combined with mute/gain filters in one export",
|
||||
)
|
||||
|
||||
use_stream_copy = req.mode == "fast" and len(segments) == 1 and not mute_segments and not has_gain and not has_speed
|
||||
use_stream_copy = req.mode == "fast" and len(segments) == 1 and not mute_segments and not has_gain and not has_speed and not zoom_dict and not has_additional
|
||||
needs_reencode_for_subs = req.captions == "burn-in"
|
||||
|
||||
# Burn-in captions or audio filters require re-encode
|
||||
@ -162,10 +208,10 @@ async def export_video(req: ExportRequest):
|
||||
|
||||
try:
|
||||
if use_stream_copy:
|
||||
output = export_stream_copy(req.input_path, req.output_path, segments)
|
||||
output = export_stream_copy(working_input, req.output_path, segments)
|
||||
elif ass_path:
|
||||
output = export_reencode_with_subs(
|
||||
req.input_path,
|
||||
working_input,
|
||||
req.output_path,
|
||||
segments,
|
||||
ass_path,
|
||||
@ -177,10 +223,11 @@ async def export_video(req: ExportRequest):
|
||||
global_gain_db=req.global_gain_db,
|
||||
normalize_loudness=req.normalize_loudness,
|
||||
normalize_target_lufs=req.normalize_target_lufs,
|
||||
zoom_config=zoom_dict,
|
||||
)
|
||||
else:
|
||||
output = export_reencode(
|
||||
req.input_path,
|
||||
working_input,
|
||||
req.output_path,
|
||||
segments,
|
||||
resolution=req.resolution,
|
||||
@ -191,6 +238,7 @@ async def export_video(req: ExportRequest):
|
||||
global_gain_db=req.global_gain_db,
|
||||
normalize_loudness=req.normalize_loudness,
|
||||
normalize_target_lufs=req.normalize_target_lufs,
|
||||
zoom_config=zoom_dict,
|
||||
)
|
||||
finally:
|
||||
if ass_path and os.path.exists(ass_path):
|
||||
@ -209,7 +257,6 @@ async def export_video(req: ExportRequest):
|
||||
os.replace(muxed_path, output)
|
||||
logger.info(f"Audio enhanced and muxed into {output}")
|
||||
|
||||
# Cleanup
|
||||
try:
|
||||
os.remove(cleaned_audio)
|
||||
os.rmdir(tmp_dir)
|
||||
@ -218,6 +265,35 @@ async def export_video(req: ExportRequest):
|
||||
except Exception as e:
|
||||
logger.warning(f"Audio enhancement failed (non-fatal): {e}")
|
||||
|
||||
# Background removal (post-process)
|
||||
if req.remove_background:
|
||||
try:
|
||||
bg_output = output + ".nobg.mp4"
|
||||
remove_bg(output, bg_output, req.background_replacement, req.background_replacement_value)
|
||||
os.replace(bg_output, output)
|
||||
logger.info("Background removed from %s", output)
|
||||
except Exception as e:
|
||||
logger.warning(f"Background removal failed (non-fatal): {e}")
|
||||
|
||||
# Background music mixing (post-process)
|
||||
if req.background_music:
|
||||
try:
|
||||
music_output = output + ".music.mp4"
|
||||
mix_background_music(
|
||||
output,
|
||||
req.background_music.path,
|
||||
music_output,
|
||||
volume_db=req.background_music.volumeDb,
|
||||
ducking_enabled=req.background_music.duckingEnabled,
|
||||
ducking_db=req.background_music.duckingDb,
|
||||
ducking_attack_ms=req.background_music.duckingAttackMs,
|
||||
ducking_release_ms=req.background_music.duckingReleaseMs,
|
||||
)
|
||||
os.replace(music_output, output)
|
||||
logger.info("Background music mixed into %s", output)
|
||||
except Exception as e:
|
||||
logger.warning(f"Background music mixing failed (non-fatal): {e}")
|
||||
|
||||
# Sidecar SRT: generate and save alongside video
|
||||
srt_path = None
|
||||
if req.captions == "sidecar" and words_dicts:
|
||||
@ -226,6 +302,13 @@ async def export_video(req: ExportRequest):
|
||||
save_captions(srt_content, srt_path)
|
||||
logger.info(f"Sidecar SRT saved to {srt_path}")
|
||||
|
||||
# Cleanup pre-concat temp file
|
||||
if has_additional and working_input != req.input_path and os.path.exists(working_input):
|
||||
try:
|
||||
os.remove(working_input)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
result = {"status": "ok", "output_path": output}
|
||||
if srt_path:
|
||||
result["srt_path"] = srt_path
|
||||
|
||||
54
backend/routers/local_llm.py
Normal file
54
backend/routers/local_llm.py
Normal 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))
|
||||
@ -1,18 +1,17 @@
|
||||
"""
|
||||
AI background removal (Phase 5 - future).
|
||||
Uses MediaPipe or Robust Video Matting for person segmentation.
|
||||
Export-only -- no real-time preview.
|
||||
AI background removal using MediaPipe for person segmentation.
|
||||
Applied during export as a post-processing step — no real-time preview.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import subprocess
|
||||
import tempfile
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Placeholder for Phase 5 implementation
|
||||
# Will use mediapipe or rvm for segmentation at export time
|
||||
|
||||
MEDIAPIPE_AVAILABLE = False
|
||||
RVM_AVAILABLE = False
|
||||
|
||||
try:
|
||||
import mediapipe as mp
|
||||
@ -20,14 +19,9 @@ try:
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
pass # rvm import would go here
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
def is_available() -> bool:
|
||||
return MEDIAPIPE_AVAILABLE or RVM_AVAILABLE
|
||||
return MEDIAPIPE_AVAILABLE
|
||||
|
||||
|
||||
def remove_background_on_export(
|
||||
@ -37,23 +31,202 @@ def remove_background_on_export(
|
||||
replacement_value: str = "",
|
||||
) -> str:
|
||||
"""
|
||||
Process video frame-by-frame to remove/replace background.
|
||||
Only runs during export (not real-time).
|
||||
Process video frame-by-frame using FFmpeg chromakey fallback,
|
||||
or MediaPipe-based segmentation if available.
|
||||
|
||||
Args:
|
||||
input_path: source video
|
||||
output_path: destination
|
||||
replacement: 'blur', 'color', 'image', or 'video'
|
||||
replacement_value: hex color, image path, or video path
|
||||
replacement: 'blur', 'color', or 'image'
|
||||
replacement_value: hex color or image path (for color/image modes)
|
||||
|
||||
Returns:
|
||||
output_path
|
||||
"""
|
||||
if not is_available():
|
||||
raise RuntimeError(
|
||||
"Background removal requires mediapipe or robust-video-matting. "
|
||||
"Install with: pip install mediapipe"
|
||||
)
|
||||
input_path = str(Path(input_path).resolve())
|
||||
output_path = str(Path(output_path).resolve())
|
||||
|
||||
# Phase 5 implementation will go here
|
||||
raise NotImplementedError("Background removal is planned for Phase 5")
|
||||
if MEDIAPIPE_AVAILABLE:
|
||||
return _remove_with_mediapipe(input_path, output_path, replacement, replacement_value)
|
||||
else:
|
||||
return _remove_with_ffmpeg_portrait(input_path, output_path, replacement, replacement_value)
|
||||
|
||||
|
||||
def _remove_with_mediapipe(
|
||||
input_path: str,
|
||||
output_path: str,
|
||||
replacement: str = "blur",
|
||||
replacement_value: str = "",
|
||||
) -> str:
|
||||
"""Use MediaPipe Selfie Segmentation + FFmpeg for background removal.
|
||||
|
||||
Extracts frames, applies segmentation, composites replacement background.
|
||||
"""
|
||||
try:
|
||||
import cv2
|
||||
import numpy as np
|
||||
import mediapipe as mp
|
||||
|
||||
mp_selfie_segmentation = mp.solutions.selfie_segmentation
|
||||
|
||||
# Determine background color/image
|
||||
if replacement == "color":
|
||||
color_hex = replacement_value or "#00FF00"
|
||||
color_hex = color_hex.lstrip("#")
|
||||
bg_color = tuple(int(color_hex[i:i+2], 16) for i in (0, 2, 4))
|
||||
bg_color = bg_color[::-1] # RGB -> BGR
|
||||
elif replacement == "image":
|
||||
bg_image = cv2.imread(replacement_value) if replacement_value else None
|
||||
if bg_image is None:
|
||||
bg_color = (0, 255, 0)
|
||||
bg_image = None
|
||||
else:
|
||||
# Blur background (default)
|
||||
bg_color = None
|
||||
|
||||
# Open video
|
||||
cap = cv2.VideoCapture(input_path)
|
||||
fps = cap.get(cv2.CAP_PROP_FPS)
|
||||
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
|
||||
# Temp directory for processed frames
|
||||
temp_dir = tempfile.mkdtemp(prefix="aive_bgrem_")
|
||||
frame_dir = os.path.join(temp_dir, "frames")
|
||||
os.makedirs(frame_dir, exist_ok=True)
|
||||
|
||||
with mp_selfie_segmentation.SelfieSegmentation(model_selection=0) as segmenter:
|
||||
frame_idx = 0
|
||||
while cap.isOpened():
|
||||
ret, frame = cap.read()
|
||||
if not ret:
|
||||
break
|
||||
|
||||
# Convert to RGB for MediaPipe
|
||||
rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
result = segmenter.process(rgb)
|
||||
mask = result.segmentation_mask
|
||||
|
||||
# Threshold the mask
|
||||
condition = mask > 0.5
|
||||
|
||||
if replacement == "blur":
|
||||
# Apply strong blur to background
|
||||
blurred = cv2.GaussianBlur(frame, (99, 99), 0)
|
||||
output_frame = np.where(condition[..., None], frame, blurred)
|
||||
elif replacement == "color":
|
||||
bg = np.full(frame.shape, bg_color, dtype=np.uint8)
|
||||
output_frame = np.where(condition[..., None], frame, bg)
|
||||
elif replacement == "image" and bg_image is not None:
|
||||
bg_resized = cv2.resize(bg_image, (width, height))
|
||||
output_frame = np.where(condition[..., None], frame, bg_resized)
|
||||
else:
|
||||
output_frame = frame
|
||||
|
||||
out_path = os.path.join(frame_dir, f"frame_{frame_idx:06d}.png")
|
||||
cv2.imwrite(out_path, output_frame)
|
||||
frame_idx += 1
|
||||
|
||||
if frame_idx % 100 == 0:
|
||||
logger.info("Background removal: %d/%d frames", frame_idx, total_frames)
|
||||
|
||||
cap.release()
|
||||
|
||||
# Encode frames back to video using FFmpeg
|
||||
import subprocess as _sp
|
||||
ffmpeg = "ffmpeg"
|
||||
cmd = [
|
||||
ffmpeg, "-y",
|
||||
"-framerate", str(fps),
|
||||
"-i", os.path.join(frame_dir, "frame_%06d.png"),
|
||||
"-i", input_path,
|
||||
"-map", "0:v:0",
|
||||
"-map", "1:a:0?",
|
||||
"-c:v", "libx264", "-preset", "medium", "-crf", "18",
|
||||
"-c:a", "aac", "-b:a", "192k",
|
||||
"-shortest",
|
||||
"-pix_fmt", "yuv420p",
|
||||
output_path,
|
||||
]
|
||||
result = _sp.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"FFmpeg frame encode failed: {result.stderr[-500:]}")
|
||||
|
||||
# Cleanup
|
||||
for f in os.listdir(frame_dir):
|
||||
try:
|
||||
os.remove(os.path.join(frame_dir, f))
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.rmdir(frame_dir)
|
||||
os.rmdir(temp_dir)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
logger.info("MediaPipe background removal completed -> %s", output_path)
|
||||
return output_path
|
||||
|
||||
except ImportError:
|
||||
logger.warning("mediapipe/cv2 not available, falling back to FFmpeg portrait mode")
|
||||
return _remove_with_ffmpeg_portrait(input_path, output_path, replacement, replacement_value)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"MediaPipe background removal failed: {e}")
|
||||
|
||||
|
||||
|
||||
def _remove_with_ffmpeg_portrait(
|
||||
input_path: str,
|
||||
output_path: str,
|
||||
replacement: str = "blur",
|
||||
replacement_value: str = "",
|
||||
) -> str:
|
||||
"""Fallback: basic FFmpeg-only background blur.
|
||||
|
||||
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"
|
||||
|
||||
if replacement == "blur":
|
||||
filter_complex = "gblur=sigma=30"
|
||||
elif replacement == "color":
|
||||
color = replacement_value or "00FF00"
|
||||
filter_complex = (
|
||||
f"split[fg][bg];"
|
||||
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:
|
||||
filter_complex = "null"
|
||||
|
||||
if filter_complex == "null":
|
||||
cmd = [ffmpeg, "-y", "-i", input_path, "-c", "copy", output_path]
|
||||
else:
|
||||
cmd = [
|
||||
ffmpeg, "-y",
|
||||
"-i", input_path,
|
||||
"-vf", filter_complex,
|
||||
"-c:v", "libx264", "-preset", "medium", "-crf", "18",
|
||||
"-c:a", "aac", "-b:a", "192k",
|
||||
"-movflags", "+faststart",
|
||||
output_path,
|
||||
]
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"FFmpeg background removal failed: {result.stderr[-500:]}")
|
||||
|
||||
logger.warning(
|
||||
"FFmpeg fallback background removal used (no MediaPipe). "
|
||||
"Install 'mediapipe' and 'opencv-python' for proper person segmentation."
|
||||
)
|
||||
return output_path
|
||||
|
||||
125
backend/services/local_llm.py
Normal file
125
backend/services/local_llm.py
Normal 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()
|
||||
@ -45,6 +45,24 @@ def _input_has_video_stream(ffmpeg_cmd: str, input_path: str) -> bool:
|
||||
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:
|
||||
return max(0.25, min(4.0, float(speed)))
|
||||
|
||||
@ -117,6 +135,159 @@ def _split_keep_segments_by_speed(
|
||||
return result
|
||||
|
||||
|
||||
def _build_zoom_filter(zoom_config: dict = None) -> str:
|
||||
"""Build FFmpeg video filter snippet for zoom/punch-in effect.
|
||||
|
||||
zoom_config: {enabled, zoomFactor, panX, panY}
|
||||
Returns empty string if disabled. Should be prepended to the video filter chain.
|
||||
"""
|
||||
if not zoom_config or not zoom_config.get("enabled"):
|
||||
return ""
|
||||
factor = float(zoom_config.get("zoomFactor", 1.0))
|
||||
if abs(factor - 1.0) < 0.01:
|
||||
return ""
|
||||
pan_x = float(zoom_config.get("panX", 0.0))
|
||||
pan_y = float(zoom_config.get("panY", 0.0))
|
||||
return f"crop=iw/{factor}:ih/{factor}:((iw-iw/{factor})/2)+({pan_x}*(iw-iw/{factor})/2):((ih-ih/{factor})/2)+({pan_y}*(ih-ih/{factor})/2),scale=iw:ih"
|
||||
|
||||
|
||||
def mix_background_music(
|
||||
video_path: str,
|
||||
music_path: str,
|
||||
output_path: str,
|
||||
volume_db: float = 0.0,
|
||||
ducking_enabled: bool = False,
|
||||
ducking_db: float = 6.0,
|
||||
ducking_attack_ms: float = 10.0,
|
||||
ducking_release_ms: float = 200.0,
|
||||
) -> str:
|
||||
"""Mix background music into a video with optional ducking.
|
||||
|
||||
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()
|
||||
escaped_music = music_path.replace("\\", "/").replace(":", "\\:")
|
||||
has_audio_result = _input_has_audio_stream(ffmpeg, video_path)
|
||||
|
||||
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 = (
|
||||
f"[0:a]asplit[main][sidechain];"
|
||||
f"{music_source};"
|
||||
f"[main][music]amix=inputs=2:duration=first:dropout_transition=2[mixed];"
|
||||
f"[mixed][sidechain]sidechaincompress="
|
||||
f"threshold=-30dB:ratio=20:attack={ducking_attack_ms / 1000}:"
|
||||
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:
|
||||
music_source = f"amovie='{escaped_music}',volume={volume_db}dB[music]"
|
||||
filter_complex = (
|
||||
f"{music_source};"
|
||||
f"[0:a][music]amix=inputs=2:duration=first:dropout_transition=2[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,
|
||||
]
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"Background music mix failed: {result.stderr[-500:]}")
|
||||
|
||||
return output_path
|
||||
|
||||
|
||||
def concat_clips(
|
||||
main_path: str,
|
||||
append_paths: list,
|
||||
output_path: str,
|
||||
) -> str:
|
||||
"""Concatenate multiple video clips using FFmpeg concat demuxer.
|
||||
|
||||
The main_path is kept as-is. append_paths are appended after it.
|
||||
"""
|
||||
if not append_paths:
|
||||
raise ValueError("No clips to concatenate")
|
||||
|
||||
ffmpeg = _find_ffmpeg()
|
||||
resolved_main = str(Path(main_path).resolve())
|
||||
|
||||
# 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_")
|
||||
try:
|
||||
concat_file = os.path.join(temp_dir, "concat.txt")
|
||||
with open(concat_file, "w") as f:
|
||||
for path in all_inputs:
|
||||
f.write(f"file '{path}'\n")
|
||||
|
||||
cmd = [
|
||||
ffmpeg, "-y",
|
||||
"-f", "concat",
|
||||
"-safe", "0",
|
||||
"-i", concat_file,
|
||||
"-c", "copy",
|
||||
"-movflags", "+faststart",
|
||||
final_output,
|
||||
]
|
||||
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"Clip concat failed: {result.stderr[-500:]}")
|
||||
|
||||
if needs_rename:
|
||||
os.replace(final_output, output_path)
|
||||
|
||||
return output_path
|
||||
finally:
|
||||
for f in os.listdir(temp_dir):
|
||||
try:
|
||||
os.remove(os.path.join(temp_dir, f))
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
os.rmdir(temp_dir)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _find_ffmpeg() -> str:
|
||||
"""Locate ffmpeg binary."""
|
||||
for cmd in ["ffmpeg", "ffmpeg.exe"]:
|
||||
@ -213,6 +384,29 @@ def export_stream_copy(
|
||||
pass
|
||||
|
||||
|
||||
def _apply_zoom_post(input_path: str, output_path: str, zoom_config: dict) -> str:
|
||||
"""Re-encode video applying zoom/punch-in crop+scale as a post-process step."""
|
||||
ffmpeg = _find_ffmpeg()
|
||||
zoom_filter = _build_zoom_filter(zoom_config)
|
||||
if not zoom_filter:
|
||||
return input_path
|
||||
|
||||
cmd = [
|
||||
ffmpeg, "-y",
|
||||
"-i", input_path,
|
||||
"-filter_complex", f"[0:v]{zoom_filter}[v]",
|
||||
"-map", "[v]",
|
||||
"-map", "0:a?",
|
||||
"-c:a", "copy",
|
||||
"-movflags", "+faststart",
|
||||
output_path,
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"Zoom post-process failed: {result.stderr[-500:]}")
|
||||
return output_path
|
||||
|
||||
|
||||
def export_reencode(
|
||||
input_path: str,
|
||||
output_path: str,
|
||||
@ -225,6 +419,7 @@ def export_reencode(
|
||||
global_gain_db: float = 0.0,
|
||||
normalize_loudness: bool = False,
|
||||
normalize_target_lufs: float = -14.0,
|
||||
zoom_config: dict = None,
|
||||
) -> str:
|
||||
"""
|
||||
Export video with full re-encode. Slower but supports resolution changes,
|
||||
@ -421,6 +616,13 @@ def export_reencode(
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"FFmpeg re-encode failed: {result.stderr[-500:]}")
|
||||
|
||||
# Apply zoom post-processing if configured
|
||||
if zoom_config and zoom_config.get("enabled") and has_video:
|
||||
zoomed_path = output_path + ".zoomed.mp4"
|
||||
_apply_zoom_post(output_path, zoomed_path, zoom_config)
|
||||
os.replace(zoomed_path, output_path)
|
||||
logger.info("Zoom/punch-in applied to %s (factor=%s)", output_path, zoom_config.get("zoomFactor", 1.0))
|
||||
|
||||
return output_path
|
||||
|
||||
|
||||
@ -437,6 +639,7 @@ def export_reencode_with_subs(
|
||||
global_gain_db: float = 0.0,
|
||||
normalize_loudness: bool = False,
|
||||
normalize_target_lufs: float = -14.0,
|
||||
zoom_config: dict = None,
|
||||
) -> str:
|
||||
"""
|
||||
Export video with re-encode and burn-in subtitles (ASS format).
|
||||
@ -578,6 +781,13 @@ def export_reencode_with_subs(
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"FFmpeg re-encode with subs failed: {result.stderr[-500:]}")
|
||||
|
||||
# Apply zoom post-processing if configured
|
||||
if zoom_config and zoom_config.get("enabled"):
|
||||
zoomed_path = output_path + ".zoomed.mp4"
|
||||
_apply_zoom_post(output_path, zoomed_path, zoom_config)
|
||||
os.replace(zoomed_path, output_path)
|
||||
logger.info("Zoom/punch-in applied to %s (factor=%s)", output_path, zoom_config.get("zoomFactor", 1.0))
|
||||
|
||||
return output_path
|
||||
|
||||
|
||||
|
||||
523
frontend/package-lock.json
generated
523
frontend/package-lock.json
generated
@ -30,6 +30,7 @@
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.5.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.7.0",
|
||||
@ -51,6 +52,57 @@
|
||||
"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": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
@ -333,6 +385,159 @@
|
||||
"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": {
|
||||
"version": "0.25.12",
|
||||
"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_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": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
@ -2365,6 +2588,16 @@
|
||||
"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": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||
@ -2601,6 +2834,20 @@
|
||||
"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": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||
@ -2621,6 +2868,20 @@
|
||||
"devOptional": true,
|
||||
"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": {
|
||||
"version": "4.4.3",
|
||||
"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": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||
@ -2667,6 +2935,19 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
|
||||
@ -3183,6 +3464,19 @@
|
||||
"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": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@ -3282,6 +3576,13 @@
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
@ -3319,6 +3620,57 @@
|
||||
"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": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||
@ -3462,6 +3814,13 @@
|
||||
"@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": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||
@ -3655,6 +4014,19 @@
|
||||
"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": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
@ -3997,6 +4369,16 @@
|
||||
"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": {
|
||||
"version": "1.22.11",
|
||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||
@ -4108,6 +4490,19 @@
|
||||
"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": {
|
||||
"version": "0.27.0",
|
||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||
@ -4240,6 +4635,13 @@
|
||||
"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": {
|
||||
"version": "3.4.19",
|
||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||
@ -4376,6 +4778,26 @@
|
||||
"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": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||
@ -4389,6 +4811,32 @@
|
||||
"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": {
|
||||
"version": "2.5.0",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "1.2.3",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
"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": {
|
||||
"version": "7.12.1",
|
||||
"resolved": "https://registry.npmjs.org/wavesurfer.js/-/wavesurfer.js-7.12.1.tgz",
|
||||
"integrity": "sha512-NswPjVHxk0Q1F/VMRemCPUzSojjuHHisQrBqQiRXg7MVbe3f5vQ6r0rTTXA/a/neC/4hnOEC4YpXca4LpH0SUg==",
|
||||
"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": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
@ -4766,6 +5272,23 @@
|
||||
"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": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
@ -7,8 +7,8 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run"
|
||||
"test": "vitest run",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
@ -33,6 +33,7 @@
|
||||
"eslint-plugin-react-hooks": "^7.0.1",
|
||||
"eslint-plugin-react-refresh": "^0.5.2",
|
||||
"globals": "^17.5.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"postcss": "^8.4.49",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.7.0",
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useEffect, useState, useMemo } from 'react';
|
||||
import { useEffect, useState, useMemo, useCallback, useRef } from 'react';
|
||||
import { useEditorStore } from './store/editorStore';
|
||||
import VideoPlayer from './components/VideoPlayer';
|
||||
import TranscriptEditor from './components/TranscriptEditor';
|
||||
@ -7,11 +7,16 @@ import AIPanel from './components/AIPanel';
|
||||
import ExportDialog from './components/ExportDialog';
|
||||
import SettingsPanel from './components/SettingsPanel';
|
||||
import DevPanel from './components/DevPanel';
|
||||
import MarkersPanel from './components/MarkersPanel';
|
||||
import SilenceTrimmerPanel from './components/SilenceTrimmerPanel';
|
||||
import ZoneEditor from './components/ZoneEditor';
|
||||
import BackgroundMusicPanel from './components/BackgroundMusicPanel';
|
||||
import AppendClipPanel from './components/AppendClipPanel';
|
||||
import LicenseDialog from './components/LicenseDialog';
|
||||
import HelpContent from './components/HelpContent';
|
||||
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
|
||||
import { useLicenseStore } from './store/licenseStore';
|
||||
import {
|
||||
Film,
|
||||
FolderOpen,
|
||||
Settings,
|
||||
Sparkles,
|
||||
@ -25,47 +30,102 @@ import {
|
||||
FilePlus2,
|
||||
RefreshCw,
|
||||
Grid3x3,
|
||||
MapPin,
|
||||
Music,
|
||||
ListVideo,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
HelpCircle,
|
||||
} from 'lucide-react';
|
||||
|
||||
const LAST_MEDIA_PATH_KEY = 'talkedit:lastMediaPath';
|
||||
|
||||
type Panel = 'ai' | 'settings' | 'export' | 'silence' | 'zones' | null;
|
||||
type Panel = 'ai' | 'settings' | 'export' | 'silence' | 'zones' | 'markers' | 'music' | 'append' | 'help' | null;
|
||||
|
||||
export default function App() {
|
||||
const {
|
||||
projectFilePath,
|
||||
videoPath,
|
||||
exportedAudioPath,
|
||||
words,
|
||||
segments,
|
||||
cutRanges,
|
||||
muteRanges,
|
||||
gainRanges,
|
||||
speedRanges,
|
||||
globalGainDb,
|
||||
silenceTrimGroups,
|
||||
transcriptionModel,
|
||||
language,
|
||||
isTranscribing,
|
||||
transcriptionStatus,
|
||||
markInTime,
|
||||
markOutTime,
|
||||
loadVideo,
|
||||
setProjectFilePath,
|
||||
setBackendUrl,
|
||||
clearMarkRange,
|
||||
setTranscription,
|
||||
setTranscriptionModel,
|
||||
setTranscribing,
|
||||
selectedWordIndices,
|
||||
addCutRange,
|
||||
addMuteRange,
|
||||
addGainRange,
|
||||
addSpeedRange,
|
||||
} = useEditorStore();
|
||||
const {
|
||||
projectFilePath,
|
||||
videoPath,
|
||||
exportedAudioPath,
|
||||
words,
|
||||
segments,
|
||||
cutRanges,
|
||||
muteRanges,
|
||||
gainRanges,
|
||||
speedRanges,
|
||||
globalGainDb,
|
||||
silenceTrimGroups,
|
||||
transcriptionModel,
|
||||
language,
|
||||
isTranscribing,
|
||||
transcriptionStatus,
|
||||
markInTime,
|
||||
markOutTime,
|
||||
loadVideo,
|
||||
setProjectFilePath,
|
||||
setBackendUrl,
|
||||
clearMarkRange,
|
||||
setTranscription,
|
||||
setTranscriptionModel,
|
||||
setTranscribing,
|
||||
selectedWordIndices,
|
||||
addCutRange,
|
||||
addMuteRange,
|
||||
addGainRange,
|
||||
addSpeedRange,
|
||||
backendUrl,
|
||||
} = useEditorStore();
|
||||
|
||||
const [activePanel, setActivePanel] = useState<Panel>(null);
|
||||
const [projectName, setProjectName] = useState<string | null>(null);
|
||||
const [splitRatio, setSplitRatio] = useState(() => {
|
||||
try { return Number(localStorage.getItem('talkedit:splitRatio')) || 0.5; } catch { return 0.5; }
|
||||
});
|
||||
const splitRef = useRef<HTMLDivElement>(null);
|
||||
const isDraggingSplit = useRef(false);
|
||||
|
||||
const startSplitDrag = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
isDraggingSplit.current = true;
|
||||
const container = splitRef.current?.parentElement;
|
||||
if (!container) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const onMove = (me: MouseEvent) => {
|
||||
if (!isDraggingSplit.current) return;
|
||||
const pct = (me.clientX - rect.left) / rect.width;
|
||||
const clamped = Math.max(0.15, Math.min(0.85, pct));
|
||||
setSplitRatio(clamped);
|
||||
localStorage.setItem('talkedit:splitRatio', String(clamped));
|
||||
};
|
||||
const onUp = () => { isDraggingSplit.current = false; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
}, []);
|
||||
|
||||
// Draggable right sidebar
|
||||
const [sidebarWidth, setSidebarWidth] = useState(() => {
|
||||
try { return Number(localStorage.getItem('talkedit:sidebarWidth')) || 320; } catch { return 320; }
|
||||
});
|
||||
const isDraggingSidebar = useRef(false);
|
||||
|
||||
const startSidebarDrag = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
isDraggingSidebar.current = true;
|
||||
const container = document.querySelector('.main-content') as HTMLElement;
|
||||
if (!container) return;
|
||||
const rect = container.getBoundingClientRect();
|
||||
const onMove = (me: MouseEvent) => {
|
||||
if (!isDraggingSidebar.current) return;
|
||||
const w = rect.right - me.clientX;
|
||||
const clamped = Math.max(180, Math.min(600, w));
|
||||
setSidebarWidth(clamped);
|
||||
localStorage.setItem('talkedit:sidebarWidth', String(clamped));
|
||||
};
|
||||
const onUp = () => { isDraggingSidebar.current = false; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); };
|
||||
window.addEventListener('mousemove', onMove);
|
||||
window.addEventListener('mouseup', onUp);
|
||||
}, []);
|
||||
|
||||
const [whisperModel, setWhisperModel] = useState('base');
|
||||
useEffect(() => { if (transcriptionModel) setWhisperModel(transcriptionModel); }, [transcriptionModel]);
|
||||
const [cutMode, setCutMode] = useState(false);
|
||||
@ -78,6 +138,14 @@ export default function App() {
|
||||
const [showUnsavedPrompt, setShowUnsavedPrompt] = useState(false);
|
||||
const [pendingProceedAction, setPendingProceedAction] = useState<(() => Promise<void>) | 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(() => {
|
||||
if (!videoPath) return null;
|
||||
@ -113,7 +181,10 @@ export default function App() {
|
||||
const hasUnsavedChanges = Boolean(projectSignature) && projectSignature !== lastSavedSignature;
|
||||
|
||||
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({
|
||||
videoPath: data.videoPath,
|
||||
exportedAudioPath: data.exportedAudioPath ?? null,
|
||||
@ -142,7 +213,23 @@ export default function App() {
|
||||
|
||||
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(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
@ -150,9 +237,9 @@ export default function App() {
|
||||
setMuteMode(false);
|
||||
setGainMode(false);
|
||||
setSpeedMode(false);
|
||||
setShowFileMenu(false);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, []);
|
||||
@ -177,6 +264,36 @@ export default function App() {
|
||||
sessionStorage.removeItem(LAST_MEDIA_PATH_KEY);
|
||||
}, [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 () => {
|
||||
await runGuarded(async () => {
|
||||
try {
|
||||
@ -233,7 +350,9 @@ export default function App() {
|
||||
setProjectFilePath(null);
|
||||
setProjectName(null);
|
||||
loadVideo(path);
|
||||
await transcribeVideo(path);
|
||||
if (canEdit) {
|
||||
await transcribeVideo(path);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
@ -331,6 +450,26 @@ export default function App() {
|
||||
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) => {
|
||||
setActivePanel((prev) => (prev === panel ? null : panel));
|
||||
};
|
||||
@ -439,213 +578,161 @@ export default function App() {
|
||||
}
|
||||
};
|
||||
|
||||
if (!videoPath) {
|
||||
return (
|
||||
<div className="h-screen flex flex-col items-center justify-center gap-8 bg-editor-bg px-6">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Film className="w-14 h-14 text-editor-accent opacity-80" />
|
||||
<h1 className="text-3xl font-semibold tracking-tight">TalkEdit</h1>
|
||||
<p className="text-editor-text-muted text-sm max-w-sm text-center">
|
||||
Offline AI-powered video editor.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Whisper model selector */}
|
||||
<div className="flex items-center gap-3">
|
||||
<label className="text-xs text-editor-text-muted whitespace-nowrap">Model:</label>
|
||||
<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 & 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>
|
||||
);
|
||||
}
|
||||
// Health check timer
|
||||
useEffect(() => {
|
||||
const checkHealth = async () => {
|
||||
try {
|
||||
const res = await fetch(`${backendUrl}/health`);
|
||||
setBackendDown(!res.ok);
|
||||
} catch {
|
||||
setBackendDown(true);
|
||||
}
|
||||
};
|
||||
checkHealth();
|
||||
const interval = setInterval(checkHealth, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [videoPath, backendUrl]);
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col bg-editor-bg overflow-hidden">
|
||||
{/* Top bar */}
|
||||
<header className="h-12 flex items-center px-4 border-b border-editor-border shrink-0">
|
||||
<div className="flex items-center gap-0.5">
|
||||
<ToolbarButton
|
||||
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">
|
||||
<div className="relative">
|
||||
<ToolbarButton
|
||||
icon={<SlidersHorizontal className="w-4 h-4" />}
|
||||
label="Gain Zone"
|
||||
onClick={handleGain}
|
||||
active={gainMode}
|
||||
/>
|
||||
<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"
|
||||
icon={<FolderOpen className="w-4 h-4" />}
|
||||
label="File"
|
||||
onClick={() => setShowFileMenu((p) => !p)}
|
||||
active={showFileMenu}
|
||||
/>
|
||||
{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 className="flex items-center gap-1">
|
||||
<ToolbarButton
|
||||
icon={<Gauge className="w-4 h-4" />}
|
||||
label="Speed Zone"
|
||||
onClick={handleSpeed}
|
||||
active={speedMode}
|
||||
icon={<Scissors className="w-4 h-4" />}
|
||||
label="Cut"
|
||||
onClick={handleCut}
|
||||
active={cutMode}
|
||||
disabled={!canEdit}
|
||||
title="Cut selected word range or mark in/out area — removes the segment from output"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={0.25}
|
||||
max={4}
|
||||
step={0.05}
|
||||
value={speedModeValue}
|
||||
onChange={(e) => setSpeedModeValue(Math.max(0.25, Math.min(4, Number(e.target.value) || 1)))}
|
||||
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"
|
||||
<ToolbarButton
|
||||
icon={<VolumeX className="w-4 h-4" />}
|
||||
label="Mute"
|
||||
onClick={handleMute}
|
||||
active={muteMode}
|
||||
disabled={!canEdit}
|
||||
title="Mute selected word range or mark in/out area — silences audio, keeps video"
|
||||
/>
|
||||
</div>
|
||||
<ToolbarButton
|
||||
icon={<Grid3x3 className="w-4 h-4" />}
|
||||
label="Zones"
|
||||
active={activePanel === 'zones'}
|
||||
onClick={() => togglePanel('zones')}
|
||||
disabled={!videoPath}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<span className="text-[10px] font-semibold">PA</span>}
|
||||
label="Pause Trim"
|
||||
active={activePanel === 'silence'}
|
||||
onClick={() => togglePanel('silence')}
|
||||
disabled={!videoPath}
|
||||
/>
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-editor-surface border border-editor-border">
|
||||
<select
|
||||
value={whisperModel}
|
||||
onChange={(e) => setWhisperModel(e.target.value)}
|
||||
className="bg-editor-surface text-xs text-editor-text focus:outline-none [color-scheme:dark]"
|
||||
title="Transcription model"
|
||||
>
|
||||
<optgroup label="Multilingual">
|
||||
<option value="tiny">tiny</option>
|
||||
<option value="base">base</option>
|
||||
<option value="small">small</option>
|
||||
<option value="medium">medium</option>
|
||||
<option value="large-v2">large-v2</option>
|
||||
<option value="large-v3">large-v3</option>
|
||||
<option value="large-v3-turbo">large-v3-turbo</option>
|
||||
<option value="distil-large-v3">distil-large-v3</option>
|
||||
</optgroup>
|
||||
<optgroup label="English">
|
||||
<option value="tiny.en">tiny.en</option>
|
||||
<option value="base.en">base.en</option>
|
||||
<option value="small.en">small.en</option>
|
||||
<option value="medium.en">medium.en</option>
|
||||
<option value="distil-small.en">distil-small.en</option>
|
||||
<option value="distil-medium.en">distil-medium.en</option>
|
||||
</optgroup>
|
||||
</select>
|
||||
<button
|
||||
onClick={handleReprocessProject}
|
||||
disabled={isTranscribing || !videoPath}
|
||||
title="Reprocess transcript with selected model"
|
||||
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"
|
||||
>
|
||||
<RefreshCw className={`w-3 h-3 ${isTranscribing ? 'animate-spin' : ''}`} />
|
||||
Reprocess
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<ToolbarButton
|
||||
icon={<SlidersHorizontal className="w-4 h-4" />}
|
||||
label="Sound Gain"
|
||||
onClick={handleGain}
|
||||
active={gainMode}
|
||||
disabled={!canEdit}
|
||||
title="Add gain zone from selection or mark in/out — adjust volume up or down"
|
||||
/>
|
||||
<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="Volume adjustment in decibels for new gain zones — positive boosts, negative reduces"
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<ToolbarButton
|
||||
icon={<Gauge className="w-4 h-4" />}
|
||||
label="Speed Adjust"
|
||||
onClick={handleSpeed}
|
||||
active={speedMode}
|
||||
disabled={!canEdit}
|
||||
title="Add speed zone from selection or mark in/out — change playback speed"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={0.25}
|
||||
max={4}
|
||||
step={0.05}
|
||||
value={speedModeValue}
|
||||
onChange={(e) => setSpeedModeValue(Math.max(0.25, Math.min(4, Number(e.target.value) || 1)))}
|
||||
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 speed multiplier for new speed zones — 1x is normal, 2x is double speed"
|
||||
disabled={!canEdit}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-px h-5 bg-editor-border mx-1" />
|
||||
<ToolbarButton
|
||||
icon={<Grid3x3 className="w-4 h-4" />}
|
||||
label="Edit Zones"
|
||||
active={activePanel === 'zones'}
|
||||
onClick={() => togglePanel('zones')}
|
||||
disabled={!videoPath || !canEdit}
|
||||
title="Open zone editor panel — view and manage all cut, mute, gain, and speed zones"
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<span className="text-[10px] font-semibold">PA</span>}
|
||||
label="Trim Silence"
|
||||
active={activePanel === 'silence'}
|
||||
onClick={() => togglePanel('silence')}
|
||||
disabled={!videoPath || !canEdit}
|
||||
title="Detect and remove silent pauses — batch-removes silence above a configurable threshold"
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<MapPin className="w-4 h-4" />}
|
||||
label="Chapter Marks"
|
||||
active={activePanel === 'markers'}
|
||||
onClick={() => togglePanel('markers')}
|
||||
disabled={!videoPath || !canEdit}
|
||||
title="Add and manage timeline markers — chapter points, key moments, YouTube timestamps"
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<Music className="w-4 h-4" />}
|
||||
label="Bkg. Music"
|
||||
active={activePanel === 'music'}
|
||||
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
|
||||
icon={<Sparkles className="w-4 h-4" />}
|
||||
label="AI"
|
||||
label="AI Tools"
|
||||
active={activePanel === 'ai'}
|
||||
onClick={() => togglePanel('ai')}
|
||||
disabled={words.length === 0}
|
||||
disabled={words.length === 0 || !canEdit}
|
||||
title="AI filler detection, clip suggestions, and transcript analysis"
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<Download className="w-4 h-4" />}
|
||||
label="Export"
|
||||
active={activePanel === 'export'}
|
||||
onClick={() => togglePanel('export')}
|
||||
disabled={words.length === 0}
|
||||
disabled={!videoPath}
|
||||
/>
|
||||
<ToolbarButton
|
||||
icon={<Settings className="w-4 h-4" />}
|
||||
@ -653,21 +740,72 @@ export default function App() {
|
||||
active={activePanel === '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>
|
||||
</header>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<div className="main-content flex-1 flex overflow-hidden">
|
||||
{/* Left: video + transcript */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<div className="flex-1 flex min-h-0">
|
||||
<div ref={splitRef} className="flex-1 flex min-h-0" style={{ position: 'relative' }}>
|
||||
{/* Video player */}
|
||||
<div className="w-1/2 p-3 flex items-center justify-center bg-black/20">
|
||||
<VideoPlayer />
|
||||
<div className="p-3 flex items-center justify-center bg-black/20 overflow-hidden" style={{ width: `${splitRatio * 100}%`, minWidth: 0 }}>
|
||||
{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>
|
||||
|
||||
{/* Draggable divider */}
|
||||
<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"
|
||||
style={{ cursor: isDraggingSplit.current ? 'col-resize' : 'col-resize' }}
|
||||
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 */}
|
||||
<div className="w-1/2 border-l border-editor-border flex flex-col min-h-0">
|
||||
<div className="border-l border-editor-border flex flex-col min-h-0" style={{ width: `${(1 - splitRatio) * 100}%`, minWidth: 0 }}>
|
||||
{videoPath && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-editor-border shrink-0 bg-editor-surface/50">
|
||||
{projectName && (
|
||||
@ -702,6 +840,9 @@ export default function App() {
|
||||
<div className="text-center space-y-1">
|
||||
<p className="text-sm font-medium text-editor-text">Processing audio</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>
|
||||
) : words.length > 0 ? (
|
||||
@ -736,19 +877,40 @@ export default function App() {
|
||||
|
||||
{/* Right panel (AI / Export / Settings) */}
|
||||
{activePanel && (
|
||||
<div className="w-80 border-l border-editor-border overflow-y-auto shrink-0">
|
||||
<div className="flex shrink-0">
|
||||
{/* Draggable sidebar divider */}
|
||||
<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"
|
||||
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 }}>
|
||||
{activePanel === 'zones' && (
|
||||
<ZoneEditor />
|
||||
)}
|
||||
{activePanel === 'silence' && <SilenceTrimmerPanel />}
|
||||
{activePanel === 'ai' && <AIPanel />}
|
||||
{activePanel === 'markers' && <MarkersPanel />}
|
||||
{activePanel === 'music' && <BackgroundMusicPanel />}
|
||||
{activePanel === 'append' && <AppendClipPanel />}
|
||||
{activePanel === 'ai' && <AIPanel onReprocess={handleReprocessProject} whisperModel={whisperModel} setWhisperModel={setWhisperModel} />}
|
||||
{activePanel === 'export' && <ExportDialog />}
|
||||
{activePanel === 'settings' && <SettingsPanel />}
|
||||
{activePanel === 'help' && <HelpContent />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{import.meta.env.DEV && <DevPanel />}
|
||||
|
||||
<LicenseDialog />
|
||||
|
||||
{showReprocessConfirm && (
|
||||
<div
|
||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 px-4"
|
||||
@ -816,6 +978,44 @@ export default function App() {
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@ -826,26 +1026,60 @@ function ToolbarButton({
|
||||
active,
|
||||
onClick,
|
||||
disabled,
|
||||
title,
|
||||
}: {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
active?: boolean;
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
title={label}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
||||
active
|
||||
? 'bg-editor-accent text-white'
|
||||
: 'text-editor-text-muted hover:text-editor-text hover:bg-editor-surface'
|
||||
} ${disabled ? 'opacity-40 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</button>
|
||||
<span title={title || label}>
|
||||
<button
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
||||
active
|
||||
? 'bg-editor-accent text-white'
|
||||
: 'text-editor-text-muted hover:text-editor-text hover:bg-editor-surface'
|
||||
} ${disabled ? 'opacity-40 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
{icon}
|
||||
{label}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,11 +1,20 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { useEditorStore } from '../store/editorStore';
|
||||
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';
|
||||
|
||||
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 canUseAI = useLicenseStore((s) => s.canUseAI);
|
||||
const setShowLicenseDialog = useLicenseStore((s) => s.setShowDialog);
|
||||
const {
|
||||
defaultProvider,
|
||||
providers,
|
||||
@ -20,10 +29,12 @@ export default function AIPanel() {
|
||||
setProcessing,
|
||||
} = 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 () => {
|
||||
if (words.length === 0) return;
|
||||
setError(null);
|
||||
setProcessing(true, 'Detecting filler words...');
|
||||
try {
|
||||
const config = providers[defaultProvider];
|
||||
@ -41,11 +52,15 @@ export default function AIPanel() {
|
||||
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();
|
||||
setFillerResult(data);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError(err instanceof Error ? err.message : 'Filler detection failed');
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
@ -53,6 +68,7 @@ export default function AIPanel() {
|
||||
|
||||
const createClips = useCallback(async () => {
|
||||
if (words.length === 0) return;
|
||||
setError(null);
|
||||
setProcessing(true, 'Finding best clip segments...');
|
||||
try {
|
||||
const config = providers[defaultProvider];
|
||||
@ -75,11 +91,15 @@ export default function AIPanel() {
|
||||
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();
|
||||
setClipSuggestions(data.clips || []);
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
setError(err instanceof Error ? err.message : 'Clip creation failed');
|
||||
} finally {
|
||||
setProcessing(false);
|
||||
}
|
||||
@ -150,156 +170,275 @@ export default function AIPanel() {
|
||||
onClick={() => setActiveTab('filler')}
|
||||
icon={<Scissors className="w-3.5 h-3.5" />}
|
||||
label="Filler Words"
|
||||
title="Detect and remove filler words from transcript"
|
||||
/>
|
||||
<TabButton
|
||||
active={activeTab === 'clips'}
|
||||
onClick={() => setActiveTab('clips')}
|
||||
icon={<Film className="w-3.5 h-3.5" />}
|
||||
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 className="flex-1 overflow-y-auto p-4">
|
||||
{activeTab === 'filler' && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs text-editor-text-muted">
|
||||
Use AI to detect and remove filler words like "um", "uh", "like", "you know" from
|
||||
your transcript.
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] text-editor-text-muted font-medium">
|
||||
Custom filler words (comma-separated)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={customFillerWords}
|
||||
onChange={(e) => setCustomFillerWords(e.target.value)}
|
||||
placeholder="e.g. okay, alright, anyway"
|
||||
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
|
||||
onClick={detectFillers}
|
||||
disabled={isProcessing || words.length === 0}
|
||||
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"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{processingMessage}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Sparkles className="w-4 h-4" />
|
||||
Detect Filler Words
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
{!canUseAI ? (
|
||||
<div className="text-center py-8 px-4">
|
||||
<Lock className="w-8 h-8 text-editor-text-muted mx-auto mb-3" />
|
||||
<p className="text-sm font-medium mb-1">AI editing requires Business</p>
|
||||
<p className="text-xs text-editor-text-muted mb-4">
|
||||
Upgrade to Business to unlock filler word removal, clip suggestions, and more.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowLicenseDialog(true)}
|
||||
className="px-4 py-2 bg-editor-accent hover:bg-editor-accent-hover text-white rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
Upgrade Now
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs text-editor-text-muted">
|
||||
Use AI to detect and remove filler words like "um", "uh", "like", "you know" from
|
||||
your transcript.
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-[11px] text-editor-text-muted font-medium">
|
||||
Custom filler words (comma-separated)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={customFillerWords}
|
||||
onChange={(e) => setCustomFillerWords(e.target.value)}
|
||||
placeholder="e.g. okay, alright, anyway"
|
||||
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
|
||||
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 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium">
|
||||
Found {fillerResult.fillerWords.length} filler words
|
||||
</span>
|
||||
<div className="flex gap-1">
|
||||
{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={applyFillerDeletions}
|
||||
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"
|
||||
onClick={detectFillers}
|
||||
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
|
||||
</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
|
||||
<RotateCcw className="w-3 h-3" /> Retry
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1 max-h-64 overflow-y-auto">
|
||||
{fillerResult.fillerWords.map((fw) => (
|
||||
<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>
|
||||
)}
|
||||
{fillerResult && fillerResult.fillerWords.length > 0 && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium">
|
||||
Found {fillerResult.fillerWords.length} filler words
|
||||
</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>
|
||||
)}
|
||||
<div className="space-y-1 max-h-64 overflow-y-auto">
|
||||
{fillerResult.fillerWords.map((fw) => (
|
||||
<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 && (
|
||||
<p className="text-xs text-editor-success">No filler words detected.</p>
|
||||
{fillerResult && fillerResult.fillerWords.length === 0 && (
|
||||
<p className="text-xs text-editor-success">No filler words detected.</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'clips' && (
|
||||
<div className="space-y-4">
|
||||
<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}
|
||||
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"
|
||||
>
|
||||
{isProcessing ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
{processingMessage}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
))}
|
||||
{!canUseAI ? (
|
||||
<div className="text-center py-8 px-4">
|
||||
<Lock className="w-8 h-8 text-editor-text-muted mx-auto mb-3" />
|
||||
<p className="text-sm font-medium mb-1">AI clip suggestions require Business</p>
|
||||
<p className="text-xs text-editor-text-muted mb-4">
|
||||
Upgrade to Business to find the best segments for social media clips.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowLicenseDialog(true)}
|
||||
className="px-4 py-2 bg-editor-accent hover:bg-editor-accent-hover text-white rounded-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
Upgrade Now
|
||||
</button>
|
||||
</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>
|
||||
)}
|
||||
|
||||
{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 & 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>
|
||||
);
|
||||
@ -310,15 +449,18 @@ function TabButton({
|
||||
onClick,
|
||||
icon,
|
||||
label,
|
||||
title,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
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 ${
|
||||
active
|
||||
? 'border-editor-accent text-editor-accent'
|
||||
|
||||
84
frontend/src/components/AppendClipPanel.tsx
Normal file
84
frontend/src/components/AppendClipPanel.tsx
Normal file
@ -0,0 +1,84 @@
|
||||
import { useEditorStore } from '../store/editorStore';
|
||||
import { Video, Plus, Trash2, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
|
||||
export default function AppendClipPanel() {
|
||||
const { additionalClips, addAdditionalClip, removeAdditionalClip, reorderAdditionalClip, videoPath } = useEditorStore();
|
||||
|
||||
const handleAddClip = async () => {
|
||||
const path = await window.electronAPI?.openFile({
|
||||
filters: [
|
||||
{ name: 'Video Files', extensions: ['mp4', 'mkv', 'mov', 'avi', 'webm'] },
|
||||
{ name: 'All Files', extensions: ['*'] },
|
||||
],
|
||||
});
|
||||
if (path) {
|
||||
addAdditionalClip(path);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-3">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-1.5">
|
||||
<Video className="w-4 h-4" />
|
||||
Append Clips
|
||||
</h3>
|
||||
<p className="text-[10px] text-editor-text-muted leading-relaxed">
|
||||
Load additional video clips to append after the main video. Clips are concatenated in order during export.
|
||||
</p>
|
||||
|
||||
{additionalClips.length === 0 ? (
|
||||
<div className="text-[11px] text-editor-text-muted text-center py-3">
|
||||
No additional clips loaded
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1 max-h-60 overflow-y-auto">
|
||||
{additionalClips.map((clip, idx) => (
|
||||
<div
|
||||
key={clip.id}
|
||||
className="flex items-center gap-2 p-2 rounded bg-editor-surface border border-editor-border text-xs"
|
||||
>
|
||||
<Video className="w-3 h-3 text-editor-accent shrink-0" />
|
||||
<span className="flex-1 truncate text-editor-text">{clip.label}</span>
|
||||
<span className="text-[10px] text-editor-text-muted shrink-0">#{idx + 1}</span>
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<button
|
||||
onClick={() => reorderAdditionalClip(clip.id, -1)}
|
||||
disabled={idx === 0}
|
||||
className="p-0.5 rounded hover:bg-editor-bg disabled:opacity-30 text-editor-text-muted hover:text-editor-text"
|
||||
title="Move up"
|
||||
>
|
||||
<ChevronUp className="w-3 h-3" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => reorderAdditionalClip(clip.id, 1)}
|
||||
disabled={idx === additionalClips.length - 1}
|
||||
className="p-0.5 rounded hover:bg-editor-bg disabled:opacity-30 text-editor-text-muted hover:text-editor-text"
|
||||
title="Move down"
|
||||
>
|
||||
<ChevronDown className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => removeAdditionalClip(clip.id)}
|
||||
className="p-0.5 rounded hover:bg-red-500/20 text-red-400"
|
||||
title="Remove clip"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleAddClip}
|
||||
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"
|
||||
title="Select a video or audio file to append during export"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add Clip
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
150
frontend/src/components/BackgroundMusicPanel.tsx
Normal file
150
frontend/src/components/BackgroundMusicPanel.tsx
Normal file
@ -0,0 +1,150 @@
|
||||
import { useEditorStore } from '../store/editorStore';
|
||||
import { Music, Trash2, Volume2, Disc3 } from 'lucide-react';
|
||||
|
||||
export default function BackgroundMusicPanel() {
|
||||
const { backgroundMusic, setBackgroundMusic, updateBackgroundMusic } = useEditorStore();
|
||||
|
||||
const handleLoadMusic = async () => {
|
||||
const path = await window.electronAPI?.openFile({
|
||||
filters: [
|
||||
{ name: 'Audio Files', extensions: ['mp3', 'wav', 'm4a', 'flac', 'ogg', 'aac', 'wma'] },
|
||||
{ name: 'All Files', extensions: ['*'] },
|
||||
],
|
||||
});
|
||||
if (path) {
|
||||
setBackgroundMusic({
|
||||
path,
|
||||
volumeDb: -10,
|
||||
duckingEnabled: true,
|
||||
duckingDb: 6,
|
||||
duckingAttackMs: 10,
|
||||
duckingReleaseMs: 200,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveMusic = () => {
|
||||
setBackgroundMusic(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-1.5">
|
||||
<Music className="w-4 h-4" />
|
||||
Background Music
|
||||
</h3>
|
||||
|
||||
{!backgroundMusic ? (
|
||||
<button
|
||||
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"
|
||||
title="Select an audio file to use as background music"
|
||||
>
|
||||
<Disc3 className="w-4 h-4" />
|
||||
Load Music File
|
||||
</button>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 p-2 rounded bg-editor-surface border border-editor-border">
|
||||
<Music className="w-4 h-4 text-editor-accent shrink-0" />
|
||||
<span className="flex-1 text-xs truncate">
|
||||
{backgroundMusic.path.split(/[/\\]/).pop()}
|
||||
</span>
|
||||
<button
|
||||
onClick={handleRemoveMusic}
|
||||
className="p-1 rounded hover:bg-red-500/20 text-red-400 transition-colors"
|
||||
title="Remove music"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Volume2 className="w-3 h-3 text-editor-text-muted shrink-0" />
|
||||
<span className="text-[10px] text-editor-text-muted w-16">Volume:</span>
|
||||
<input
|
||||
type="range"
|
||||
min={-30}
|
||||
max={12}
|
||||
step={1}
|
||||
value={backgroundMusic.volumeDb}
|
||||
onChange={(e) => updateBackgroundMusic({ volumeDb: Number(e.target.value) })}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={backgroundMusic.duckingEnabled}
|
||||
onChange={(e) => updateBackgroundMusic({ duckingEnabled: e.target.checked })}
|
||||
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>
|
||||
<span className="text-xs font-medium">Auto-ducking</span>
|
||||
<p className="text-[10px] text-editor-text-muted">
|
||||
Lower music volume when speech is detected
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{backgroundMusic.duckingEnabled && (
|
||||
<div className="pl-6 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-editor-text-muted w-20">Duck amount:</span>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={20}
|
||||
step={1}
|
||||
value={backgroundMusic.duckingDb}
|
||||
onChange={(e) => updateBackgroundMusic({ duckingDb: Number(e.target.value) })}
|
||||
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>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-editor-text-muted w-20">Attack:</span>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={100}
|
||||
step={1}
|
||||
value={backgroundMusic.duckingAttackMs}
|
||||
onChange={(e) => updateBackgroundMusic({ duckingAttackMs: Number(e.target.value) })}
|
||||
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>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-editor-text-muted w-20">Release:</span>
|
||||
<input
|
||||
type="range"
|
||||
min={10}
|
||||
max={1000}
|
||||
step={10}
|
||||
value={backgroundMusic.duckingReleaseMs}
|
||||
onChange={(e) => updateBackgroundMusic({ duckingReleaseMs: Number(e.target.value) })}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-[10px] text-editor-text-muted leading-relaxed">
|
||||
The music will be mixed during export. Enable auto-ducking to lower music volume whenever speech is active.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,12 +1,13 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
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() {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [pathInput, setPathInput] = useState('');
|
||||
const [testResult, setTestResult] = useState<string | null>(null);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||
|
||||
const { backendUrl, videoPath, loadVideo } = useEditorStore();
|
||||
|
||||
@ -121,6 +122,37 @@ export default function DevPanel() {
|
||||
{testResult}
|
||||
</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>
|
||||
|
||||
90
frontend/src/components/ErrorBoundary.tsx
Normal file
90
frontend/src/components/ErrorBoundary.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -1,14 +1,28 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useEditorStore } from '../store/editorStore';
|
||||
import { Download, Loader2, Zap, Cog, Info, Volume2, FileText } from 'lucide-react';
|
||||
import { Download, Loader2, Zap, Cog, Info, Volume2, FileText, ZoomIn, Video, Music } from 'lucide-react';
|
||||
import type { ExportOptions } from '../types/project';
|
||||
import { assert } from '../lib/assert';
|
||||
|
||||
export default function ExportDialog() {
|
||||
const { videoPath, words, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, isExporting, exportProgress, backendUrl, setExporting, getKeepSegments } =
|
||||
const { videoPath, words, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, isExporting, exportProgress, backendUrl, setExporting, getKeepSegments, additionalClips, backgroundMusic } =
|
||||
useEditorStore();
|
||||
|
||||
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
|
||||
const audioExtensions = new Set(['.wav', '.mp3', '.flac', '.m4a', '.ogg', '.aac', '.wma']);
|
||||
const inputExt = videoPath ? '.' + videoPath.split('.').pop()?.toLowerCase() : '';
|
||||
@ -22,6 +36,10 @@ export default function ExportDialog() {
|
||||
captions: 'none',
|
||||
normalizeAudio: false,
|
||||
normalizeTarget: -14,
|
||||
zoom: { enabled: false, zoomFactor: 1.25, panX: 0, panY: 0 },
|
||||
removeBackground: false,
|
||||
backgroundReplacement: 'blur',
|
||||
backgroundReplacementValue: '',
|
||||
});
|
||||
const [exportError, setExportError] = useState<string | null>(null);
|
||||
const [transcriptFormat, setTranscriptFormat] = useState<'txt' | 'srt'>('txt');
|
||||
@ -42,14 +60,7 @@ export default function ExportDialog() {
|
||||
setIsTranscribingTranscript(true);
|
||||
try {
|
||||
// Compute deleted word set
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
const deletedSet = getDeletedSet();
|
||||
|
||||
// Generate content entirely on the frontend — no backend needed
|
||||
let content: string;
|
||||
@ -99,7 +110,7 @@ export default function ExportDialog() {
|
||||
} finally {
|
||||
setIsTranscribingTranscript(false);
|
||||
}
|
||||
}, [videoPath, words, cutRanges, transcriptFormat]);
|
||||
}, [videoPath, words, getDeletedSet, transcriptFormat]);
|
||||
|
||||
const HANDLE_EXPORT_filters = useCallback(() => {
|
||||
const ext = options.format;
|
||||
@ -126,14 +137,8 @@ export default function ExportDialog() {
|
||||
setExportError(null);
|
||||
try {
|
||||
const keepSegments = getKeepSegments();
|
||||
|
||||
const deletedSet = new Set<number>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
assert(words.length > 0, 'handleExport: words is empty before building keep segments');
|
||||
const deletedSet = getDeletedSet();
|
||||
|
||||
// Map frontend camelCase gain/speed fields to backend snake_case
|
||||
const backendGainRanges = gainRanges.map((r) => ({
|
||||
@ -147,27 +152,51 @@ export default function ExportDialog() {
|
||||
speed: r.speed,
|
||||
}));
|
||||
|
||||
const body: Record<string, any> = {
|
||||
input_path: videoPath,
|
||||
output_path: outputPath,
|
||||
keep_segments: keepSegments,
|
||||
mute_ranges: muteRanges.length > 0 ? muteRanges.map((r) => ({ start: r.start, end: r.end })) : undefined,
|
||||
gain_ranges: backendGainRanges.length > 0 ? backendGainRanges : undefined,
|
||||
speed_ranges: backendSpeedRanges.length > 0 ? backendSpeedRanges : undefined,
|
||||
global_gain_db: globalGainDb,
|
||||
words: options.captions !== 'none' ? words : undefined,
|
||||
deleted_indices: options.captions !== 'none' ? [...deletedSet] : undefined,
|
||||
mode: options.mode,
|
||||
resolution: options.resolution,
|
||||
format: options.format,
|
||||
enhanceAudio: options.enhanceAudio,
|
||||
normalize_loudness: options.normalizeAudio,
|
||||
normalize_target_lufs: options.normalizeTarget,
|
||||
captions: options.captions,
|
||||
};
|
||||
|
||||
// Zoom
|
||||
if (options.zoom?.enabled) {
|
||||
body.zoom = options.zoom;
|
||||
}
|
||||
|
||||
// Additional clips
|
||||
if (additionalClips.length > 0) {
|
||||
body.additional_clips = additionalClips.map((c) => c.path);
|
||||
}
|
||||
|
||||
// Background music
|
||||
if (backgroundMusic) {
|
||||
body.background_music = backgroundMusic;
|
||||
}
|
||||
|
||||
// Background removal
|
||||
if (options.removeBackground) {
|
||||
body.remove_background = true;
|
||||
body.background_replacement = options.backgroundReplacement || 'blur';
|
||||
body.background_replacement_value = options.backgroundReplacementValue || '';
|
||||
}
|
||||
|
||||
const res = await fetch(`${backendUrl}/export`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
input_path: videoPath,
|
||||
output_path: outputPath,
|
||||
keep_segments: keepSegments,
|
||||
mute_ranges: muteRanges.length > 0 ? muteRanges.map((r) => ({ start: r.start, end: r.end })) : undefined,
|
||||
gain_ranges: backendGainRanges.length > 0 ? backendGainRanges : undefined,
|
||||
speed_ranges: backendSpeedRanges.length > 0 ? backendSpeedRanges : undefined,
|
||||
global_gain_db: globalGainDb,
|
||||
words: options.captions !== 'none' ? words : undefined,
|
||||
deleted_indices: options.captions !== 'none' ? [...deletedSet] : undefined,
|
||||
mode: options.mode,
|
||||
resolution: options.resolution,
|
||||
format: options.format,
|
||||
enhanceAudio: options.enhanceAudio,
|
||||
normalize_loudness: options.normalizeAudio,
|
||||
normalize_target_lufs: options.normalizeTarget,
|
||||
captions: options.captions,
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
let detail = res.statusText;
|
||||
@ -185,7 +214,7 @@ export default function ExportDialog() {
|
||||
setExportError(err instanceof Error ? err.message : 'Export failed');
|
||||
setExporting(false);
|
||||
}
|
||||
}, [videoPath, options, backendUrl, setExporting, getKeepSegments, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, words, HANDLE_EXPORT_filters]);
|
||||
}, [videoPath, options, backendUrl, setExporting, getKeepSegments, getDeletedSet, muteRanges, gainRanges, speedRanges, globalGainDb, words, HANDLE_EXPORT_filters, additionalClips, backgroundMusic]);
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-5">
|
||||
@ -201,6 +230,7 @@ export default function ExportDialog() {
|
||||
icon={<Zap className="w-4 h-4" />}
|
||||
title="Fast"
|
||||
desc="Stream copy, seconds"
|
||||
tooltip="Stream copy — fast, no quality loss, but does not apply cuts or effects"
|
||||
/>
|
||||
<ModeCard
|
||||
active={options.mode === 'reencode'}
|
||||
@ -208,6 +238,7 @@ export default function ExportDialog() {
|
||||
icon={<Cog className="w-4 h-4" />}
|
||||
title="Re-encode"
|
||||
desc="Custom quality, slower"
|
||||
tooltip="Full re-encode — applies cuts, gain, speed, zoom, captions, and effects"
|
||||
/>
|
||||
</div>
|
||||
</fieldset>
|
||||
@ -223,6 +254,7 @@ export default function ExportDialog() {
|
||||
{ value: '1080p', label: '1080p (Full HD)' },
|
||||
{ value: '4k', label: '4K (Ultra HD)' },
|
||||
]}
|
||||
title="Output video resolution — higher resolution = larger file"
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -237,8 +269,147 @@ export default function ExportDialog() {
|
||||
{ value: 'webm', label: 'WebM (VP9)' },
|
||||
...(isAudioOnly ? [{ value: 'wav' as const, label: 'WAV (Uncompressed)' }] : []),
|
||||
]}
|
||||
title="Output container format — MP4 is most compatible"
|
||||
/>
|
||||
|
||||
{/* Video zoom / punch-in */}
|
||||
<div className="space-y-2 pt-1 border-t border-editor-border">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={options.zoom?.enabled || false}
|
||||
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"
|
||||
title="Crop and reposition the video frame — useful for removing black bars or reframing"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-xs font-medium flex items-center gap-1">
|
||||
<ZoomIn className="w-3 h-3" />
|
||||
Video zoom / punch-in
|
||||
</span>
|
||||
<p className="text-[10px] text-editor-text-muted">
|
||||
Crop and zoom into the center of the video. Requires re-encode.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
{options.zoom?.enabled && (
|
||||
<div className="pl-6 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-editor-text-muted w-16">Zoom:</span>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.05}
|
||||
value={options.zoom?.zoomFactor || 1}
|
||||
onChange={(e) => setOptions((o) => ({ ...o, zoom: { ...o.zoom!, zoomFactor: Number(e.target.value) } }))}
|
||||
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>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-editor-text-muted w-16">Pan X:</span>
|
||||
<input
|
||||
type="range"
|
||||
min={-1}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={options.zoom?.panX || 0}
|
||||
onChange={(e) => setOptions((o) => ({ ...o, zoom: { ...o.zoom!, panX: Number(e.target.value) } }))}
|
||||
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>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[10px] text-editor-text-muted w-16">Pan Y:</span>
|
||||
<input
|
||||
type="range"
|
||||
min={-1}
|
||||
max={1}
|
||||
step={0.05}
|
||||
value={options.zoom?.panY || 0}
|
||||
onChange={(e) => setOptions((o) => ({ ...o, zoom: { ...o.zoom!, panY: Number(e.target.value) } }))}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Background removal */}
|
||||
{!isAudioOnly && (
|
||||
<div className="space-y-2 pt-1 border-t border-editor-border">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={options.removeBackground || false}
|
||||
onChange={(e) => setOptions((o) => ({ ...o, removeBackground: e.target.checked }))}
|
||||
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>
|
||||
<span className="text-xs font-medium flex items-center gap-1">
|
||||
<Video className="w-3 h-3" />
|
||||
Remove background
|
||||
</span>
|
||||
<p className="text-[10px] text-editor-text-muted">
|
||||
Replace or blur the background. Uses MediaPipe if available.
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
{options.removeBackground && (
|
||||
<div className="pl-6 space-y-2">
|
||||
<SelectField
|
||||
label="Background replacement"
|
||||
value={options.backgroundReplacement || 'blur'}
|
||||
onChange={(v) => setOptions((o) => ({ ...o, backgroundReplacement: v as 'blur' | 'color' | 'image' }))}
|
||||
options={[
|
||||
{ value: 'blur', label: 'Blur background' },
|
||||
{ value: 'color', label: 'Solid color' },
|
||||
{ value: 'image', label: 'Custom image' },
|
||||
]}
|
||||
/>
|
||||
{options.backgroundReplacement === 'color' && (
|
||||
<input
|
||||
type="text"
|
||||
value={options.backgroundReplacementValue || '#00FF00'}
|
||||
onChange={(e) => setOptions((o) => ({ ...o, backgroundReplacementValue: e.target.value }))}
|
||||
placeholder="#00FF00"
|
||||
className="w-full 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]"
|
||||
/>
|
||||
)}
|
||||
{options.backgroundReplacement === 'image' && (
|
||||
<p className="text-[10px] text-editor-text-muted">Place a background image file path above.</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Background music track info */}
|
||||
{backgroundMusic && (
|
||||
<div className="pt-1 border-t border-editor-border">
|
||||
<div className="flex items-center gap-1.5 text-xs text-editor-accent">
|
||||
<Music className="w-3 h-3" />
|
||||
Background music: {backgroundMusic.path.split(/[/\\]/).pop()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Append clips info */}
|
||||
{additionalClips.length > 0 && (
|
||||
<div className="pt-1 border-t border-editor-border">
|
||||
<div className="flex items-center gap-1.5 text-xs text-editor-accent">
|
||||
<Video className="w-3 h-3" />
|
||||
{additionalClips.length} additional clip{additionalClips.length > 1 ? 's' : ''} appended
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Audio normalization — integrated into export */}
|
||||
<div className="space-y-2 pt-1 border-t border-editor-border">
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
@ -247,6 +418,7 @@ export default function ExportDialog() {
|
||||
checked={options.normalizeAudio}
|
||||
onChange={(e) => setOptions((o) => ({ ...o, normalizeAudio: e.target.checked }))}
|
||||
className="w-4 h-4 rounded bg-editor-surface border-editor-border accent-editor-accent"
|
||||
title="Normalize audio to a consistent loudness target"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-xs font-medium">Normalize loudness</span>
|
||||
@ -262,6 +434,7 @@ export default function ExportDialog() {
|
||||
value={options.normalizeTarget}
|
||||
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]"
|
||||
title="Loudness target — YouTube (-14), Spotify (-16), Broadcast (-23)"
|
||||
>
|
||||
<option value={-14}>YouTube (-14 LUFS)</option>
|
||||
<option value={-16}>Spotify (-16 LUFS)</option>
|
||||
@ -280,6 +453,7 @@ export default function ExportDialog() {
|
||||
checked={options.enhanceAudio}
|
||||
onChange={(e) => setOptions((o) => ({ ...o, enhanceAudio: e.target.checked }))}
|
||||
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>
|
||||
</label>
|
||||
@ -294,6 +468,7 @@ export default function ExportDialog() {
|
||||
{ value: 'burn-in', label: 'Burn-in (permanent)' },
|
||||
{ value: 'sidecar', label: 'Sidecar SRT file' },
|
||||
]}
|
||||
title="Burn captions into video, export as separate SRT/VTT file, or none"
|
||||
/>
|
||||
|
||||
{/* Transcript-only export */}
|
||||
@ -318,6 +493,7 @@ export default function ExportDialog() {
|
||||
onClick={handleTranscriptExport}
|
||||
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"
|
||||
title="Export just the transcript text or subtitles without the video"
|
||||
>
|
||||
{isTranscribingTranscript ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
@ -333,21 +509,31 @@ export default function ExportDialog() {
|
||||
<button
|
||||
onClick={handleExport}
|
||||
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 ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 animate-spin" />
|
||||
Exporting... {Math.round(exportProgress)}%
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Download className="w-4 h-4" />
|
||||
Export Video
|
||||
</>
|
||||
)}
|
||||
<Download className="w-4 h-4" />
|
||||
Export Video
|
||||
</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 && (
|
||||
<div className="rounded border border-red-500/40 bg-red-500/10 px-3 py-2 text-xs text-red-300">
|
||||
{exportError}
|
||||
@ -378,16 +564,19 @@ function ModeCard({
|
||||
icon,
|
||||
title,
|
||||
desc,
|
||||
tooltip,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
icon: React.ReactNode;
|
||||
title: string;
|
||||
desc: string;
|
||||
tooltip?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={tooltip}
|
||||
className={`flex flex-col items-center gap-1 p-3 rounded-lg border-2 transition-colors ${
|
||||
active
|
||||
? 'border-editor-accent bg-editor-accent/10'
|
||||
@ -406,16 +595,19 @@ function SelectField({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
title,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: Array<{ value: string; label: string }>;
|
||||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<label className="text-xs text-editor-text-muted font-medium">{label}</label>
|
||||
<select
|
||||
title={title}
|
||||
value={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]"
|
||||
|
||||
156
frontend/src/components/HelpContent.tsx
Normal file
156
frontend/src/components/HelpContent.tsx
Normal 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 & 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 > 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>
|
||||
);
|
||||
}
|
||||
296
frontend/src/components/LicenseDialog.tsx
Normal file
296
frontend/src/components/LicenseDialog.tsx
Normal 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.
|
||||
<button onClick={onActivate} className="underline font-medium hover:text-red-200">
|
||||
Activate license
|
||||
</button>
|
||||
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>
|
||||
);
|
||||
}
|
||||
171
frontend/src/components/MarkersPanel.tsx
Normal file
171
frontend/src/components/MarkersPanel.tsx
Normal file
@ -0,0 +1,171 @@
|
||||
import { useState } from 'react';
|
||||
import { useEditorStore } from '../store/editorStore';
|
||||
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'];
|
||||
|
||||
export default function MarkersPanel() {
|
||||
const { timelineMarkers, addTimelineMarker, updateTimelineMarker, removeTimelineMarker, getChapters } =
|
||||
useEditorStore();
|
||||
const currentTime = useEditorStore((s) => s.currentTime);
|
||||
const [editingId, setEditingId] = useState<string | null>(null);
|
||||
const [editLabel, setEditLabel] = useState('');
|
||||
const [newLabel, setNewLabel] = useState('');
|
||||
const [newColor, setNewColor] = useState(COLORS[0]);
|
||||
const [showChapters, setShowChapters] = useState(false);
|
||||
|
||||
const chapters = getChapters();
|
||||
|
||||
const addAtCurrentTime = () => {
|
||||
addTimelineMarker(currentTime, newLabel || undefined, newColor);
|
||||
setNewLabel('');
|
||||
};
|
||||
|
||||
const startEdit = (id: string, label: string) => {
|
||||
setEditingId(id);
|
||||
setEditLabel(label);
|
||||
};
|
||||
|
||||
const commitEdit = (id: string) => {
|
||||
if (editLabel.trim()) {
|
||||
updateTimelineMarker(id, { label: editLabel.trim() });
|
||||
}
|
||||
setEditingId(null);
|
||||
};
|
||||
|
||||
const exportChapters = () => {
|
||||
const lines = chapters.map((ch) => {
|
||||
const h = Math.floor(ch.startTime / 3600);
|
||||
const m = Math.floor((ch.startTime % 3600) / 60);
|
||||
const s = Math.floor(ch.startTime % 60);
|
||||
const timeStr = `${h > 0 ? `${h}:` : ''}${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`;
|
||||
return `${timeStr} ${ch.label}`;
|
||||
});
|
||||
const text = lines.join('\n');
|
||||
navigator.clipboard.writeText(text).catch(() => {});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-1.5">
|
||||
<MapPin className="w-4 h-4" />
|
||||
Timeline Markers
|
||||
</h3>
|
||||
<p className="text-xs text-editor-text-muted">
|
||||
Drop markers at key points. Markers become YouTube-compatible chapters.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Add marker at current time */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
value={newLabel}
|
||||
onChange={(e) => setNewLabel(e.target.value)}
|
||||
placeholder={`${currentTime.toFixed(2)}s`}
|
||||
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"
|
||||
/>
|
||||
<div className="flex gap-0.5">
|
||||
{COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setNewColor(c)}
|
||||
className={`w-4 h-4 rounded-full border ${newColor === c ? 'border-white ring-1 ring-white' : 'border-transparent'}`}
|
||||
style={{ backgroundColor: c }}
|
||||
title={COLOR_NAMES[c]}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
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"
|
||||
title="Add a marker at the current playhead position"
|
||||
>
|
||||
<MapPin className="w-3 h-3" />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Marker list */}
|
||||
{timelineMarkers.length > 0 ? (
|
||||
<div className="space-y-1 max-h-60 overflow-y-auto">
|
||||
{timelineMarkers.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded bg-editor-surface border border-editor-border text-xs"
|
||||
>
|
||||
<div className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: m.color }} />
|
||||
<span className="text-[10px] text-editor-text-muted w-14 shrink-0">{m.time.toFixed(2)}s</span>
|
||||
{editingId === m.id ? (
|
||||
<>
|
||||
<input
|
||||
value={editLabel}
|
||||
onChange={(e) => setEditLabel(e.target.value)}
|
||||
autoFocus
|
||||
className="flex-1 px-1.5 py-0.5 text-xs bg-editor-bg border border-editor-border rounded focus:outline-none focus:border-editor-accent"
|
||||
/>
|
||||
<button onClick={() => commitEdit(m.id)} className="p-0.5 text-editor-success"><Check className="w-3 h-3" /></button>
|
||||
<button onClick={() => setEditingId(null)} className="p-0.5 text-editor-text-muted"><X className="w-3 h-3" /></button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex-1 truncate">{m.label}</span>
|
||||
<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={() => { 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 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.length > 0 && (
|
||||
<div className="space-y-2 pt-1 border-t border-editor-border">
|
||||
<button
|
||||
onClick={() => setShowChapters(!showChapters)}
|
||||
className="flex items-center gap-1 text-xs font-medium text-editor-text-muted hover:text-editor-text"
|
||||
>
|
||||
{showChapters ? '▼' : '▶'} Chapters ({chapters.length})
|
||||
</button>
|
||||
{showChapters && (
|
||||
<div className="space-y-1">
|
||||
{chapters.map((ch) => (
|
||||
<div key={ch.markerId} className="flex items-center gap-2 text-[10px] text-editor-text-muted">
|
||||
<span className="font-mono">{ch.label}</span>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={exportChapters}
|
||||
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 as YouTube timestamps
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -1,8 +1,9 @@
|
||||
import { useAIStore } from '../store/aiStore';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type { AIProvider } from '../types/project';
|
||||
import type { AIProvider, KeyBinding, HotkeyPreset } from '../types/project';
|
||||
import { useEditorStore } from '../store/editorStore';
|
||||
import { Bot, Cloud, Brain, RefreshCw } 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';
|
||||
|
||||
export default function SettingsPanel() {
|
||||
const { providers, defaultProvider, setProviderConfig, setDefaultProvider } = useAIStore();
|
||||
@ -19,11 +20,96 @@ export default function SettingsPanel() {
|
||||
window.localStorage.setItem(CONFIDENCE_THRESHOLD_KEY, String(clamped));
|
||||
}
|
||||
};
|
||||
const [ollamaModels, setOllamaModels] = useState<string[]>([]);
|
||||
// Keyboard shortcuts state
|
||||
const [bindings, setBindings] = useState<KeyBinding[]>(() => {
|
||||
try { return loadBindings(); } catch { return DEFAULT_PRESETS['standard']; }
|
||||
});
|
||||
const [editingKey, setEditingKey] = useState<string | null>(null);
|
||||
const [editKeyValue, setEditKeyValue] = useState('');
|
||||
const conflicts = detectKeyConflicts(bindings);
|
||||
|
||||
const persistBindings = (newB: KeyBinding[]) => {
|
||||
saveBindings(newB);
|
||||
setBindings(newB);
|
||||
};
|
||||
|
||||
const applyPresetAction = (preset: HotkeyPreset) => {
|
||||
persistBindings(applyKeyPreset(preset));
|
||||
};
|
||||
|
||||
const startKeyEdit = (idx: number) => {
|
||||
setEditingKey(bindings[idx].id);
|
||||
setEditKeyValue(bindings[idx].keys);
|
||||
};
|
||||
|
||||
const handleKeyCapture = (e: React.KeyboardEvent, idx: number) => {
|
||||
e.preventDefault();
|
||||
const parts: string[] = [];
|
||||
if (e.ctrlKey || e.metaKey) parts.push('Ctrl');
|
||||
if (e.shiftKey) parts.push('Shift');
|
||||
if (e.altKey) parts.push('Alt');
|
||||
const key = e.key === ' ' ? 'Space' : e.key.length === 1 ? e.key.toUpperCase() : e.key;
|
||||
if (!['Control', 'Shift', 'Alt', 'Meta'].includes(key)) parts.push(key);
|
||||
if (parts.length === 0) return;
|
||||
const combo = parts.join('+');
|
||||
const newBindings = bindings.map((b, i) => (i === idx ? { ...b, keys: combo } : b));
|
||||
setEditKeyValue(combo);
|
||||
setEditingKey(null);
|
||||
persistBindings(newBindings);
|
||||
};
|
||||
|
||||
const handleReset = (idx: number) => {
|
||||
const standard = DEFAULT_PRESETS['standard'];
|
||||
const existing = standard.find((b: KeyBinding) => b.id === bindings[idx].id);
|
||||
if (!existing) return;
|
||||
persistBindings(bindings.map((b, i) => (i === idx ? { ...existing } : b)));
|
||||
};
|
||||
|
||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||
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 () => {
|
||||
setLoadingModels(true);
|
||||
setLoadingOllamaModels(true);
|
||||
try {
|
||||
const res = await fetch(`${backendUrl}/ai/ollama-models`);
|
||||
if (res.ok) {
|
||||
@ -33,7 +119,7 @@ export default function SettingsPanel() {
|
||||
} catch {
|
||||
setOllamaModels([]);
|
||||
} finally {
|
||||
setLoadingModels(false);
|
||||
setLoadingOllamaModels(false);
|
||||
}
|
||||
}, [backendUrl]);
|
||||
|
||||
@ -63,6 +149,7 @@ export default function SettingsPanel() {
|
||||
value={zonePreviewPaddingSeconds}
|
||||
onChange={(e) => setZonePreviewPaddingSeconds(Number(e.target.value) || 0)}
|
||||
className="flex-1 h-1.5"
|
||||
title="Extra time in seconds to show before and after each zone during preview"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
@ -72,6 +159,7 @@ export default function SettingsPanel() {
|
||||
value={zonePreviewPaddingSeconds}
|
||||
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"
|
||||
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>
|
||||
</div>
|
||||
@ -94,6 +182,7 @@ export default function SettingsPanel() {
|
||||
value={confidenceThreshold}
|
||||
onChange={(e) => setConfidenceThreshold(Number(e.target.value))}
|
||||
className="flex-1 h-1.5"
|
||||
title="Words below this confidence get an orange underline — lower values show fewer warnings"
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
@ -103,6 +192,7 @@ export default function SettingsPanel() {
|
||||
value={confidenceThreshold}
|
||||
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"
|
||||
title="Words below this confidence get an orange underline — lower values show fewer warnings"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between text-[10px]">
|
||||
@ -112,6 +202,64 @@ export default function SettingsPanel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Keyboard shortcuts */}
|
||||
<div className="space-y-2 pt-1 border-t border-editor-border">
|
||||
<h4 className="text-xs font-semibold flex items-center gap-1.5">
|
||||
<Keyboard className="w-3.5 h-3.5" />
|
||||
Keyboard Shortcuts
|
||||
</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
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"
|
||||
title="Reset all shortcuts to the Standard preset"
|
||||
>
|
||||
Standard Preset
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
title="Reset all shortcuts to the Left-Hand preset"
|
||||
>
|
||||
Left-Hand Preset
|
||||
</button>
|
||||
</div>
|
||||
{conflicts.length > 0 && (
|
||||
<div className="px-2 py-1 rounded border border-red-500/40 bg-red-500/10 text-[10px] text-red-300">
|
||||
⚠️ {conflicts.join('; ')}
|
||||
</div>
|
||||
)}
|
||||
<div className="max-h-52 overflow-y-auto space-y-1 pr-1">
|
||||
{bindings.map((b, i) => (
|
||||
<div key={b.id} className="flex items-center gap-2 text-[11px]">
|
||||
<span className="flex-1 truncate text-editor-text-muted">{b.label}</span>
|
||||
<input
|
||||
value={editingKey === b.id ? editKeyValue : b.keys}
|
||||
onFocus={() => startKeyEdit(i)}
|
||||
onChange={(e) => {
|
||||
setEditingKey(b.id);
|
||||
setEditKeyValue(e.target.value);
|
||||
}}
|
||||
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"
|
||||
placeholder="Type shortcut"
|
||||
title="Click then press the desired key combination"
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleReset(i)}
|
||||
className="text-[10px] text-editor-text-muted hover:text-editor-text px-1"
|
||||
title="Reset this shortcut to default"
|
||||
>
|
||||
↺
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-[10px] text-editor-text-muted">
|
||||
Press <kbd>?</kbd> anytime to view shortcuts. Changes apply immediately.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Default provider selector */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs text-editor-text-muted font-medium">Default AI Provider</label>
|
||||
@ -120,6 +268,11 @@ export default function SettingsPanel() {
|
||||
<button
|
||||
key={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] ${
|
||||
defaultProvider === p
|
||||
? 'border-editor-accent bg-editor-accent/10 text-editor-accent'
|
||||
@ -133,6 +286,50 @@ export default function SettingsPanel() {
|
||||
</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)} · {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>
|
||||
|
||||
{/* Ollama settings */}
|
||||
@ -142,16 +339,18 @@ export default function SettingsPanel() {
|
||||
value={providers.ollama.baseUrl || ''}
|
||||
onChange={(v) => setProviderConfig('ollama', { baseUrl: v })}
|
||||
placeholder="http://localhost:11434"
|
||||
title="URL of your Ollama instance — http://localhost:11434 by default"
|
||||
/>
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-xs text-editor-text-muted">Model</label>
|
||||
<button
|
||||
onClick={fetchOllamaModels}
|
||||
disabled={loadingModels}
|
||||
disabled={loadingOllamaModels}
|
||||
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
|
||||
</button>
|
||||
</div>
|
||||
@ -160,6 +359,7 @@ export default function SettingsPanel() {
|
||||
value={providers.ollama.model}
|
||||
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"
|
||||
title="Which Ollama model to use for AI features"
|
||||
>
|
||||
{ollamaModels.map((m) => (
|
||||
<option key={m} value={m}>{m}</option>
|
||||
@ -171,6 +371,7 @@ export default function SettingsPanel() {
|
||||
value={providers.ollama.model}
|
||||
onChange={(v) => setProviderConfig('ollama', { model: v })}
|
||||
placeholder="llama3"
|
||||
title="Which Ollama model to use for AI features"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
@ -184,12 +385,14 @@ export default function SettingsPanel() {
|
||||
onChange={(v) => setProviderConfig('openai', { apiKey: v })}
|
||||
placeholder="sk-..."
|
||||
type="password"
|
||||
title="Your OpenAI API key — stored encrypted on your machine"
|
||||
/>
|
||||
<InputField
|
||||
label="Model"
|
||||
value={providers.openai.model}
|
||||
onChange={(v) => setProviderConfig('openai', { model: v })}
|
||||
placeholder="gpt-4o"
|
||||
title="OpenAI model to use (e.g. gpt-4o, gpt-4o-mini)"
|
||||
/>
|
||||
</ProviderSection>
|
||||
|
||||
@ -201,12 +404,14 @@ export default function SettingsPanel() {
|
||||
onChange={(v) => setProviderConfig('claude', { apiKey: v })}
|
||||
placeholder="sk-ant-..."
|
||||
type="password"
|
||||
title="Your Anthropic Claude API key — stored encrypted on your machine"
|
||||
/>
|
||||
<InputField
|
||||
label="Model"
|
||||
value={providers.claude.model}
|
||||
onChange={(v) => setProviderConfig('claude', { model: v })}
|
||||
placeholder="claude-sonnet-4-20250514"
|
||||
title="Claude model to use (e.g. claude-sonnet-4-20250514)"
|
||||
/>
|
||||
</ProviderSection>
|
||||
</div>
|
||||
@ -239,12 +444,14 @@ function InputField({
|
||||
onChange,
|
||||
placeholder,
|
||||
type = 'text',
|
||||
title,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
placeholder: string;
|
||||
type?: string;
|
||||
title?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
@ -254,6 +461,7 @@ function InputField({
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -134,6 +134,7 @@ export default function SilenceTrimmerPanel() {
|
||||
value={minSilenceMs}
|
||||
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"
|
||||
title="Minimum duration of silence to detect in milliseconds"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -149,6 +150,7 @@ export default function SilenceTrimmerPanel() {
|
||||
value={silenceDb}
|
||||
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"
|
||||
title="Volume threshold in dB — lower values detect quieter sounds as silence"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -165,6 +167,7 @@ export default function SilenceTrimmerPanel() {
|
||||
value={preBufferMs}
|
||||
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"
|
||||
title="Extra time to add before each detected silence"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
@ -179,6 +182,7 @@ export default function SilenceTrimmerPanel() {
|
||||
value={postBufferMs}
|
||||
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"
|
||||
title="Extra time to add after each detected silence"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -186,7 +190,8 @@ export default function SilenceTrimmerPanel() {
|
||||
<button
|
||||
onClick={detectSilence}
|
||||
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 ? (
|
||||
<>
|
||||
@ -214,6 +219,7 @@ export default function SilenceTrimmerPanel() {
|
||||
<button
|
||||
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"
|
||||
title="Re-apply this silence trim group with current settings"
|
||||
>
|
||||
<RotateCcw className="w-3 h-3" />
|
||||
Reapply Group
|
||||
@ -222,6 +228,7 @@ export default function SilenceTrimmerPanel() {
|
||||
<button
|
||||
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"
|
||||
title="Create a new silence trim group from detected pauses"
|
||||
>
|
||||
<Scissors className="w-3 h-3" />
|
||||
Apply As New Group
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import { useCallback, useRef, useEffect, useMemo, useState } from 'react';
|
||||
import { useEditorStore } from '../store/editorStore';
|
||||
import { useLicenseStore } from '../store/licenseStore';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
import { Scissors, VolumeX, SlidersHorizontal, Gauge, RotateCcw, Search, ChevronUp, ChevronDown, X, RefreshCw } from 'lucide-react';
|
||||
import { assert } from '../lib/assert';
|
||||
|
||||
interface TranscriptEditorProps {
|
||||
cutMode: boolean;
|
||||
@ -42,6 +44,7 @@ export default function TranscriptEditor({
|
||||
const addGainRange = useEditorStore((s) => s.addGainRange);
|
||||
const addSpeedRange = useEditorStore((s) => s.addSpeedRange);
|
||||
const getWordAtTime = useEditorStore((s) => s.getWordAtTime);
|
||||
const canEdit = useLicenseStore((s) => s.canEdit);
|
||||
|
||||
const selectionStart = useRef<number | null>(null);
|
||||
const wasDragging = useRef(false);
|
||||
@ -204,9 +207,11 @@ export default function TranscriptEditor({
|
||||
|
||||
const handleMouseUp = useCallback(() => {
|
||||
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 endWord = words[zoneDragRange.end];
|
||||
if (startWord && endWord) {
|
||||
if (startWord && endWord && canEdit) {
|
||||
if (cutMode) addCutRange(startWord.start, endWord.end);
|
||||
if (muteMode) addMuteRange(startWord.start, endWord.end);
|
||||
if (gainMode) addGainRange(startWord.start, endWord.end, gainModeDb);
|
||||
@ -216,7 +221,7 @@ export default function TranscriptEditor({
|
||||
zoneDragStart.current = null;
|
||||
setZoneDragRange(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(
|
||||
(e: React.MouseEvent) => {
|
||||
@ -267,6 +272,7 @@ export default function TranscriptEditor({
|
||||
|
||||
// Snapshot indices and word timings before the async gap
|
||||
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 endWord = words[sorted[sorted.length - 1]];
|
||||
if (!startWord || !endWord) {
|
||||
@ -303,8 +309,9 @@ export default function TranscriptEditor({
|
||||
|
||||
const handleWordDoubleClick = useCallback((index: number) => {
|
||||
if (cutMode || muteMode || gainMode || speedMode) return;
|
||||
if (!canEdit) return;
|
||||
startEditing(index);
|
||||
}, [cutMode, muteMode, gainMode, speedMode, startEditing]);
|
||||
}, [cutMode, muteMode, gainMode, speedMode, startEditing, canEdit]);
|
||||
|
||||
// Focus edit input when it appears
|
||||
useEffect(() => {
|
||||
@ -333,6 +340,8 @@ export default function TranscriptEditor({
|
||||
const cutSelectedWords = useCallback(() => {
|
||||
if (selectedWordIndices.length === 0) return;
|
||||
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 endTime = words[sorted[sorted.length - 1]].end;
|
||||
addCutRange(startTime, endTime);
|
||||
@ -454,8 +463,8 @@ export default function TranscriptEditor({
|
||||
${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 && 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' : ''}
|
||||
${isActiveSearchMatch && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-accent/35 ring-1 ring-editor-accent text-white' : ''}
|
||||
${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-2 ring-editor-accent text-white font-medium' : ''}
|
||||
${isSelected && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-word-selected text-white' : ''}
|
||||
${isActive && !isSelected && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-accent/20 text-editor-accent' : ''}
|
||||
${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">
|
||||
<button
|
||||
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" />
|
||||
Cut
|
||||
</button>
|
||||
<button
|
||||
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" />
|
||||
Mute
|
||||
</button>
|
||||
<button
|
||||
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" />
|
||||
Gain ({gainModeDb > 0 ? '+' : ''}{gainModeDb.toFixed(1)} dB)
|
||||
</button>
|
||||
<button
|
||||
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" />
|
||||
Speed {speedModeValue.toFixed(2)}x
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
title="Re-run Whisper transcription on this segment"
|
||||
>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -1,6 +1,9 @@
|
||||
import { useRef, useEffect, useCallback, useState, useMemo } from 'react';
|
||||
import { useEditorStore } from '../store/editorStore';
|
||||
import { useLicenseStore } from '../store/licenseStore';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
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 COLLAPSED_CUT_DISPLAY_SECONDS = 0.08;
|
||||
@ -234,18 +237,24 @@ export default function WaveformTimeline({
|
||||
const headCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [audioError, setAudioError] = useState<string | null>(null);
|
||||
const [waveformReady, setWaveformReady] = useState(false);
|
||||
|
||||
const videoUrl = useEditorStore((s) => s.videoUrl);
|
||||
const videoPath = useEditorStore((s) => s.videoPath);
|
||||
const backendUrl = useEditorStore((s) => s.backendUrl);
|
||||
const duration = useEditorStore((s) => s.duration);
|
||||
const setCurrentTime = useEditorStore((s) => s.setCurrentTime);
|
||||
const cutRanges = useEditorStore((s) => s.cutRanges);
|
||||
const muteRanges = useEditorStore((s) => s.muteRanges);
|
||||
const gainRanges = useEditorStore((s) => s.gainRanges);
|
||||
const speedRanges = useEditorStore((s) => s.speedRanges);
|
||||
const timelineMarkers = useEditorStore((s) => s.timelineMarkers);
|
||||
const markInTime = useEditorStore((s) => s.markInTime);
|
||||
const markOutTime = useEditorStore((s) => s.markOutTime);
|
||||
const setCurrentTime = useEditorStore((s) => s.setCurrentTime);
|
||||
const [showThumbnails, setShowThumbnails] = useState(() => typeof window !== 'undefined' && localStorage.getItem('talkedit:showThumbnails') === 'true');
|
||||
const [thumbnailFrames, setThumbnailFrames] = useState<Map<number, string>>(new Map());
|
||||
void setShowThumbnails;
|
||||
const thumbnailContainerRef = useRef<HTMLDivElement>(null);
|
||||
const addCutRange = useEditorStore((s) => s.addCutRange);
|
||||
const addMuteRange = useEditorStore((s) => s.addMuteRange);
|
||||
const addGainRange = useEditorStore((s) => s.addGainRange);
|
||||
@ -258,6 +267,7 @@ export default function WaveformTimeline({
|
||||
const removeMuteRange = useEditorStore((s) => s.removeMuteRange);
|
||||
const removeGainRange = useEditorStore((s) => s.removeGainRange);
|
||||
const removeSpeedRange = useEditorStore((s) => s.removeSpeedRange);
|
||||
const canEdit = useLicenseStore((s) => s.canEdit);
|
||||
|
||||
const waveformDataRef = useRef<WaveformData | null>(null);
|
||||
const zoomRef = useRef(1); // 1 = show all, >1 = zoomed in
|
||||
@ -281,10 +291,9 @@ export default function WaveformTimeline({
|
||||
const [showAdjustedTimeline, setShowAdjustedTimeline] = useState(false);
|
||||
|
||||
const sourceDuration = duration || waveformDataRef.current?.duration || 0;
|
||||
const timelineCutRanges = showAdjustedTimeline ? cutRanges : [];
|
||||
const { segments: timelineSegments, displayDuration } = useMemo(
|
||||
() => buildTimelineSegments(sourceDuration, timelineCutRanges),
|
||||
[sourceDuration, timelineCutRanges],
|
||||
() => buildTimelineSegments(sourceDuration, showAdjustedTimeline ? cutRanges : []),
|
||||
[sourceDuration, cutRanges, showAdjustedTimeline],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@ -341,6 +350,7 @@ export default function WaveformTimeline({
|
||||
if (cancelled) return;
|
||||
waveformDataRef.current = waveformData;
|
||||
drawStaticWaveformRef.current();
|
||||
setWaveformReady(true);
|
||||
} catch (err) {
|
||||
if (cancelled || (err instanceof DOMException && err.name === 'AbortError')) {
|
||||
console.log('[WaveformTimeline] req=', requestId, 'aborted/cancelled');
|
||||
@ -466,10 +476,10 @@ export default function WaveformTimeline({
|
||||
// Draw resize handles
|
||||
ctx.fillStyle = '#ef4444';
|
||||
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.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();
|
||||
}
|
||||
}
|
||||
@ -491,10 +501,10 @@ export default function WaveformTimeline({
|
||||
// Draw resize handles
|
||||
ctx.fillStyle = '#3b82f6';
|
||||
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.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();
|
||||
}
|
||||
}
|
||||
@ -515,10 +525,10 @@ export default function WaveformTimeline({
|
||||
|
||||
ctx.fillStyle = '#f59e0b';
|
||||
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.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();
|
||||
}
|
||||
}
|
||||
@ -539,10 +549,10 @@ export default function WaveformTimeline({
|
||||
|
||||
ctx.fillStyle = '#10b981';
|
||||
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.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();
|
||||
}
|
||||
|
||||
@ -606,6 +616,46 @@ export default function WaveformTimeline({
|
||||
if (markInTime !== null) drawMarkLine(markInTime, 'I');
|
||||
if (markOutTime !== null) drawMarkLine(markOutTime, 'O');
|
||||
|
||||
// Draw timeline markers (numbered circles)
|
||||
const sortedMarkers = [...timelineMarkers].sort((a, b) => a.time - b.time);
|
||||
for (let mi = 0; mi < sortedMarkers.length; mi++) {
|
||||
const marker = sortedMarkers[mi];
|
||||
const number = mi + 1;
|
||||
const x = (sourceToDisplayTime(marker.time, timelineSegments, dur) - scroll) * pxPerSec;
|
||||
if (x < -8 || x > width + 8) continue;
|
||||
const radius = 7;
|
||||
const cy = waveTop - radius - 2;
|
||||
// Draw filled circle
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, cy, radius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = marker.color;
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#0f1117';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.stroke();
|
||||
// Draw number
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 9px sans-serif';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(String(number), x, cy);
|
||||
ctx.textAlign = 'start';
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
// Draw a thin vertical line below the circle
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, cy + radius);
|
||||
ctx.lineTo(x, waveTop + waveH);
|
||||
ctx.strokeStyle = marker.color;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
// Label to the right
|
||||
ctx.fillStyle = marker.color;
|
||||
ctx.font = '9px sans-serif';
|
||||
ctx.textBaseline = 'bottom';
|
||||
ctx.fillText(marker.label, Math.min(width - 50, Math.max(2, x + 5)), waveTop - 2);
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
}
|
||||
|
||||
const mid = waveTop + waveH / 2;
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = '#4a4d5e';
|
||||
@ -641,7 +691,6 @@ export default function WaveformTimeline({
|
||||
gainMode,
|
||||
speedMode,
|
||||
selectedZone,
|
||||
showAdjustedTimeline,
|
||||
markInTime,
|
||||
markOutTime,
|
||||
displayDuration,
|
||||
@ -650,6 +699,7 @@ export default function WaveformTimeline({
|
||||
showGainZones,
|
||||
showSpeedZones,
|
||||
timelineSegments,
|
||||
timelineMarkers,
|
||||
]);
|
||||
|
||||
// Keep the ref in sync with the latest drawStaticWaveform closure
|
||||
@ -774,6 +824,7 @@ export default function WaveformTimeline({
|
||||
}, [displayDuration, setCurrentTime, timelineSegments]);
|
||||
|
||||
const clientXToTime = useCallback((clientX: number): number => {
|
||||
assert(headCanvasRef.current !== null, 'clientXToTime: headCanvasRef.current is null');
|
||||
const canvas = headCanvasRef.current;
|
||||
const dur = waveformDataRef.current?.duration;
|
||||
if (!canvas || !dur) return 0;
|
||||
@ -802,7 +853,7 @@ export default function WaveformTimeline({
|
||||
// Check if click is in waveform area
|
||||
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
|
||||
for (const range of showCutZones ? cutRanges : []) {
|
||||
@ -980,7 +1031,7 @@ export default function WaveformTimeline({
|
||||
|
||||
// Check if clicking on a zone
|
||||
const zoneHit = getZoneAtPosition(e.clientX, e.clientY);
|
||||
if (zoneHit) {
|
||||
if (zoneHit && canEdit) {
|
||||
if (zoneHit.edge === 'move') {
|
||||
setSelectedZone({ type: zoneHit.type, id: zoneHit.id });
|
||||
} else {
|
||||
@ -1053,7 +1104,7 @@ export default function WaveformTimeline({
|
||||
// Clear selection if clicking elsewhere
|
||||
setSelectedZone(null);
|
||||
|
||||
if (cutMode || muteMode || gainMode || speedMode) {
|
||||
if (canEdit && (cutMode || muteMode || gainMode || speedMode)) {
|
||||
// Range selection mode
|
||||
const startTime = clientXToTime(e.clientX);
|
||||
selectionStartRef.current = startTime;
|
||||
@ -1075,6 +1126,8 @@ export default function WaveformTimeline({
|
||||
setIsDragging(false);
|
||||
|
||||
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 end = Math.max(selectionStartRef.current, selectionEndRef.current);
|
||||
const minDuration = 0.01;
|
||||
@ -1136,11 +1189,12 @@ export default function WaveformTimeline({
|
||||
if (e.key === 'Escape') {
|
||||
setSelectedZone(null);
|
||||
editingZoneRef.current = null;
|
||||
} else if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
} else if (canEdit && (e.key === 'Delete' || e.key === 'Backspace')) {
|
||||
if (selectedZone) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
if (!window.confirm("Delete this zone?")) return;
|
||||
if (selectedZone.type === 'cut') {
|
||||
removeCutRange(selectedZone.id);
|
||||
} else if (selectedZone.type === 'mute') {
|
||||
@ -1159,7 +1213,7 @@ export default function WaveformTimeline({
|
||||
// Capture phase ensures zone delete runs before app-level bubble shortcuts.
|
||||
window.addEventListener('keydown', handleKeyDown, { capture: true });
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true });
|
||||
}, [selectedZone, removeCutRange, removeMuteRange, removeGainRange, removeSpeedRange]);
|
||||
}, [selectedZone, removeCutRange, removeMuteRange, removeGainRange, removeSpeedRange, canEdit]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedZone) return;
|
||||
@ -1169,6 +1223,26 @@ export default function WaveformTimeline({
|
||||
if (selectedZone.type === 'speed' && !showSpeedZones) setSelectedZone(null);
|
||||
}, [selectedZone, showCutZones, showMuteZones, showGainZones, showSpeedZones]);
|
||||
|
||||
// Capture thumbnail frames when enabled
|
||||
useEffect(() => {
|
||||
if (!showThumbnails) { setThumbnailFrames(new Map()); return; }
|
||||
const dur = displayDuration || waveformDataRef.current?.duration || 0;
|
||||
if (dur <= 0) return;
|
||||
const video = document.querySelector('video') as HTMLVideoElement | null;
|
||||
if (!video) return;
|
||||
|
||||
const interval = 10;
|
||||
const times: number[] = [];
|
||||
for (let t = 0; t < dur; t += interval) times.push(t);
|
||||
|
||||
let cancelled = false;
|
||||
extractThumbnails(video, times).then((frames) => {
|
||||
if (!cancelled) setThumbnailFrames(frames);
|
||||
});
|
||||
|
||||
return () => { cancelled = true; };
|
||||
}, [showThumbnails, videoUrl, displayDuration]);
|
||||
|
||||
if (!videoUrl) {
|
||||
return (
|
||||
<div className="w-full h-full flex items-center justify-center text-editor-text-muted text-xs">
|
||||
@ -1192,7 +1266,7 @@ export default function WaveformTimeline({
|
||||
{markOutTime !== null && <span className="text-[10px] text-yellow-300">O {markOutTime.toFixed(2)}s</span>}
|
||||
</div>
|
||||
<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
|
||||
type="checkbox"
|
||||
checked={showAdjustedTimeline}
|
||||
@ -1206,28 +1280,28 @@ export default function WaveformTimeline({
|
||||
<button
|
||||
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'}`}
|
||||
title="Toggle cut zones"
|
||||
title="Toggle cut zones on the timeline — red overlays show removed segments"
|
||||
>
|
||||
Cut
|
||||
</button>
|
||||
<button
|
||||
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'}`}
|
||||
title="Toggle mute zones"
|
||||
title="Toggle mute zones on the timeline — blue overlays show silenced segments"
|
||||
>
|
||||
Mute
|
||||
</button>
|
||||
<button
|
||||
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'}`}
|
||||
title="Toggle gain zones"
|
||||
title="Toggle gain zones on the timeline — amber overlays show volume adjustments"
|
||||
>
|
||||
Gain
|
||||
</button>
|
||||
<button
|
||||
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'}`}
|
||||
title="Toggle speed zones"
|
||||
title="Toggle speed zones on the timeline — emerald overlays show speed changes"
|
||||
>
|
||||
Speed
|
||||
</button>
|
||||
@ -1246,18 +1320,64 @@ export default function WaveformTimeline({
|
||||
>
|
||||
{audioError}
|
||||
</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 className="flex-1 relative">
|
||||
<canvas ref={waveCanvasRef} className="absolute inset-0 w-full h-full" />
|
||||
<canvas
|
||||
ref={headCanvasRef}
|
||||
className="absolute inset-0 w-full h-full"
|
||||
style={{ cursor: isDragging ? 'grabbing' : hoverCursor }}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onWheel={handleWheel}
|
||||
/>
|
||||
<div className="flex-1 relative flex flex-col">
|
||||
{showThumbnails && thumbnailFrames.size > 0 && (
|
||||
<div
|
||||
ref={thumbnailContainerRef}
|
||||
className="h-14 shrink-0 overflow-x-auto border-b border-editor-border/60"
|
||||
style={{ scrollbarWidth: 'thin' }}
|
||||
>
|
||||
<div className="relative h-full" style={{ width: '100%', minHeight: 0 }}>
|
||||
{Array.from(thumbnailFrames.entries()).map(([time, dataUrl]) => {
|
||||
const dur = displayDuration || waveformDataRef.current?.duration || 1;
|
||||
const pct = (time / dur) * 100;
|
||||
return (
|
||||
<img
|
||||
key={time}
|
||||
src={dataUrl}
|
||||
alt={`Thumbnail at ${time.toFixed(0)}s`}
|
||||
className="absolute top-1 rounded border border-editor-border/40 object-cover cursor-pointer"
|
||||
style={{ left: `${pct}%`, width: 80, height: 45, transform: 'translateX(-50%)' }}
|
||||
title={`${time.toFixed(0)}s`}
|
||||
onClick={() => {
|
||||
const video = document.querySelector('video') as HTMLVideoElement | null;
|
||||
if (video) { video.currentTime = time; setCurrentTime(time); }
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 relative">
|
||||
<canvas ref={waveCanvasRef} className="absolute inset-0 w-full h-full" />
|
||||
<canvas
|
||||
ref={headCanvasRef}
|
||||
className="absolute inset-0 w-full h-full"
|
||||
style={{ cursor: isDragging ? 'grabbing' : hoverCursor }}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseMove={handleMouseMove}
|
||||
onWheel={handleWheel}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -94,7 +94,7 @@ export default function ZoneEditor() {
|
||||
case 'cut':
|
||||
return 'border-red-500/40 bg-red-500/5';
|
||||
case 'mute':
|
||||
return 'border-orange-500/40 bg-orange-500/5';
|
||||
return 'border-blue-500/40 bg-blue-500/20';
|
||||
case 'gain':
|
||||
return 'border-amber-500/40 bg-amber-500/5';
|
||||
case 'speed':
|
||||
@ -120,6 +120,7 @@ export default function ZoneEditor() {
|
||||
);
|
||||
|
||||
const removeZone = useCallback((type: 'cut' | 'mute' | 'gain' | 'speed', id: string) => {
|
||||
if (!window.confirm("Delete this zone?")) return;
|
||||
if (type === 'cut') removeCutRange(id);
|
||||
else if (type === 'mute') removeMuteRange(id);
|
||||
else if (type === 'gain') removeGainRange(id);
|
||||
@ -193,6 +194,7 @@ export default function ZoneEditor() {
|
||||
? 'bg-editor-accent text-white'
|
||||
: 'text-editor-text-muted hover:text-editor-text'
|
||||
}`}
|
||||
title="Show all zones"
|
||||
>
|
||||
All
|
||||
</button>
|
||||
@ -203,6 +205,7 @@ export default function ZoneEditor() {
|
||||
? 'bg-red-500/30 text-red-500'
|
||||
: 'text-editor-text-muted hover:text-editor-text'
|
||||
}`}
|
||||
title="Show only Cut zones"
|
||||
>
|
||||
Cut
|
||||
</button>
|
||||
@ -210,9 +213,10 @@ export default function ZoneEditor() {
|
||||
onClick={() => setViewMode('mute')}
|
||||
className={`px-2 py-1 text-xs rounded transition-colors ${
|
||||
viewMode === 'mute'
|
||||
? 'bg-orange-500/30 text-orange-500'
|
||||
? 'bg-blue-500/20 text-blue-400'
|
||||
: 'text-editor-text-muted hover:text-editor-text'
|
||||
}`}
|
||||
title="Show only Mute zones"
|
||||
>
|
||||
Mute
|
||||
</button>
|
||||
@ -223,6 +227,7 @@ export default function ZoneEditor() {
|
||||
? 'bg-amber-500/30 text-amber-500'
|
||||
: 'text-editor-text-muted hover:text-editor-text'
|
||||
}`}
|
||||
title="Show only Gain zones"
|
||||
>
|
||||
Gain
|
||||
</button>
|
||||
@ -233,6 +238,7 @@ export default function ZoneEditor() {
|
||||
? 'bg-emerald-500/30 text-emerald-500'
|
||||
: 'text-editor-text-muted hover:text-editor-text'
|
||||
}`}
|
||||
title="Show only Speed zones"
|
||||
>
|
||||
Speed
|
||||
</button>
|
||||
@ -240,7 +246,7 @@ export default function ZoneEditor() {
|
||||
</div>
|
||||
|
||||
{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">
|
||||
No zones yet. Create zones from the toolbar or by highlighting words.
|
||||
</p>
|
||||
@ -259,13 +265,12 @@ export default function ZoneEditor() {
|
||||
<div
|
||||
key={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="font-medium truncate">
|
||||
{formatTimelineLikeTime(range.start)} - {formatTimelineLikeTime(range.end)}
|
||||
</div>
|
||||
<div className="text-editor-text-muted text-[10px]">{range.id}</div>
|
||||
</div>
|
||||
{renderPreviewButton(range.start, range.end, 'hover:bg-red-500/20 text-red-500/70 hover:text-red-500')}
|
||||
<button
|
||||
@ -287,7 +292,7 @@ export default function ZoneEditor() {
|
||||
{/* Mute Zones */}
|
||||
{(viewMode === 'all' || viewMode === 'mute') && muteRanges.length > 0 && (
|
||||
<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" />
|
||||
Mute Zones ({muteRanges.length})
|
||||
</div>
|
||||
@ -296,21 +301,20 @@ export default function ZoneEditor() {
|
||||
<div
|
||||
key={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="font-medium truncate">
|
||||
{formatTimelineLikeTime(range.start)} - {formatTimelineLikeTime(range.end)}
|
||||
</div>
|
||||
<div className="text-editor-text-muted text-[10px]">{range.id}</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
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
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"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
@ -321,12 +325,12 @@ export default function ZoneEditor() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Gain Zones */}
|
||||
{/* Sound Gain */}
|
||||
{(viewMode === 'all' || viewMode === 'gain') && gainRanges.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-semibold text-amber-500/80 flex items-center gap-2">
|
||||
<SlidersHorizontal className="w-3.5 h-3.5" />
|
||||
Gain Zones ({gainRanges.length})
|
||||
Sound Gain ({gainRanges.length})
|
||||
</div>
|
||||
|
||||
{/* Global Gain Slider */}
|
||||
@ -350,6 +354,7 @@ export default function ZoneEditor() {
|
||||
value={globalGainDb}
|
||||
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"
|
||||
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>
|
||||
</div>
|
||||
@ -360,7 +365,7 @@ export default function ZoneEditor() {
|
||||
<div
|
||||
key={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="font-medium truncate">
|
||||
@ -379,7 +384,7 @@ export default function ZoneEditor() {
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
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"
|
||||
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')}
|
||||
<button
|
||||
@ -398,19 +403,19 @@ export default function ZoneEditor() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Speed Zones */}
|
||||
{/* Speed Adjust */}
|
||||
{(viewMode === 'all' || viewMode === 'speed') && speedRanges.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-semibold text-emerald-500/80 flex items-center gap-2">
|
||||
<Gauge className="w-3.5 h-3.5" />
|
||||
Speed Zones ({speedRanges.length})
|
||||
Speed Adjust ({speedRanges.length})
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
{speedRanges.map((range) => (
|
||||
<div
|
||||
key={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="font-medium truncate">
|
||||
@ -429,7 +434,7 @@ export default function ZoneEditor() {
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
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"
|
||||
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')}
|
||||
<button
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useEditorStore } from '../store/editorStore';
|
||||
import { loadBindings, DEFAULT_PRESETS } from '../lib/keybindings';
|
||||
import type { KeyBinding } from '../types/project';
|
||||
|
||||
export function useKeyboardShortcuts() {
|
||||
const addCutRange = useEditorStore((s) => s.addCutRange);
|
||||
@ -10,9 +12,13 @@ export function useKeyboardShortcuts() {
|
||||
const clearMarkRange = useEditorStore((s) => s.clearMarkRange);
|
||||
const selectedWordIndices = useEditorStore((s) => s.selectedWordIndices);
|
||||
const words = useEditorStore((s) => s.words);
|
||||
|
||||
const playbackRateRef = useRef(1);
|
||||
|
||||
// Read bindings fresh from localStorage on every call to avoid stale closures
|
||||
const getBindings = (): KeyBinding[] => {
|
||||
try { return loadBindings(); } catch { return []; }
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const getVideo = (): HTMLVideoElement | null => document.querySelector('video');
|
||||
|
||||
@ -22,81 +28,58 @@ export function useKeyboardShortcuts() {
|
||||
|
||||
const video = getVideo();
|
||||
|
||||
switch (true) {
|
||||
// --- Undo / Redo ---
|
||||
case e.key === 'z' && (e.ctrlKey || e.metaKey) && e.shiftKey: {
|
||||
e.preventDefault();
|
||||
useEditorStore.temporal.getState().redo();
|
||||
return;
|
||||
}
|
||||
case e.key === 'z' && (e.ctrlKey || e.metaKey): {
|
||||
e.preventDefault();
|
||||
// Build a key string from the event for matching
|
||||
const parts: string[] = [];
|
||||
if (e.ctrlKey || e.metaKey) parts.push('Ctrl');
|
||||
if (e.shiftKey && !['Shift'].includes(e.key)) parts.push('Shift');
|
||||
if (e.altKey) parts.push('Alt');
|
||||
const keyStr = e.key === ' ' ? 'Space' : e.key.length === 1 ? e.key.toUpperCase() : e.key;
|
||||
parts.push(keyStr);
|
||||
const combo = parts.join('+');
|
||||
|
||||
// Look up binding — fresh read every keystroke so Settings changes take effect immediately
|
||||
const currentBindings = getBindings();
|
||||
const binding = currentBindings.find((b) => b.keys === combo);
|
||||
if (!binding) return; // Unbound key — ignore
|
||||
|
||||
e.preventDefault();
|
||||
|
||||
switch (binding.id) {
|
||||
case 'undo':
|
||||
useEditorStore.temporal.getState().undo();
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Delete / Backspace: cut selected words ---
|
||||
case e.key === 'Delete' || e.key === 'Backspace': {
|
||||
case 'redo':
|
||||
useEditorStore.temporal.getState().redo();
|
||||
return;
|
||||
case 'cut': {
|
||||
if (selectedWordIndices.length > 0) {
|
||||
e.preventDefault();
|
||||
const sorted = [...selectedWordIndices].sort((a, b) => a - b);
|
||||
const startTime = words[sorted[0]].start;
|
||||
const endTime = words[sorted[sorted.length - 1]].end;
|
||||
addCutRange(startTime, endTime);
|
||||
addCutRange(words[sorted[0]].start, words[sorted[sorted.length - 1]].end);
|
||||
return;
|
||||
}
|
||||
|
||||
if (markInTime !== null && markOutTime !== null) {
|
||||
e.preventDefault();
|
||||
const start = Math.min(markInTime, markOutTime);
|
||||
const end = Math.max(markInTime, markOutTime);
|
||||
if (end - start >= 0.01) {
|
||||
addCutRange(start, end);
|
||||
}
|
||||
if (end - start >= 0.01) addCutRange(start, end);
|
||||
clearMarkRange();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Space: play / pause ---
|
||||
case e.key === ' ' && !e.ctrlKey: {
|
||||
e.preventDefault();
|
||||
if (video) {
|
||||
if (video.paused) video.play();
|
||||
else video.pause();
|
||||
}
|
||||
case 'play-pause':
|
||||
if (video) { if (video.paused) video.play(); else video.pause(); }
|
||||
return;
|
||||
}
|
||||
|
||||
// --- J: reverse / slow down ---
|
||||
case e.key === 'j' || e.key === 'J': {
|
||||
e.preventDefault();
|
||||
case 'slow-down': {
|
||||
if (video) {
|
||||
playbackRateRef.current = Math.max(-2, playbackRateRef.current - 0.5);
|
||||
if (playbackRateRef.current < 0) {
|
||||
// HTML5 video doesn't support negative rates natively; step back
|
||||
video.currentTime = Math.max(0, video.currentTime - 2);
|
||||
} else {
|
||||
video.playbackRate = playbackRateRef.current;
|
||||
if (video.paused) video.play();
|
||||
}
|
||||
if (playbackRateRef.current < 0) video.currentTime = Math.max(0, video.currentTime - 2);
|
||||
else { video.playbackRate = playbackRateRef.current; if (video.paused) video.play(); }
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// --- K: pause ---
|
||||
case e.key === 'k' || e.key === 'K': {
|
||||
e.preventDefault();
|
||||
if (video) {
|
||||
video.pause();
|
||||
playbackRateRef.current = 1;
|
||||
}
|
||||
case 'pause':
|
||||
if (video) { video.pause(); playbackRateRef.current = 1; }
|
||||
return;
|
||||
}
|
||||
|
||||
// --- L: forward / speed up ---
|
||||
case e.key === 'l' || e.key === 'L': {
|
||||
e.preventDefault();
|
||||
case 'speed-up': {
|
||||
if (video) {
|
||||
playbackRateRef.current = Math.min(4, playbackRateRef.current + 0.5);
|
||||
video.playbackRate = Math.max(0.25, playbackRateRef.current);
|
||||
@ -104,60 +87,37 @@ export function useKeyboardShortcuts() {
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Arrow Left: seek back 5s ---
|
||||
case e.key === 'ArrowLeft' && !e.ctrlKey: {
|
||||
e.preventDefault();
|
||||
case 'rewind':
|
||||
if (video) video.currentTime = Math.max(0, video.currentTime - 5);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Arrow Right: seek forward 5s ---
|
||||
case e.key === 'ArrowRight' && !e.ctrlKey: {
|
||||
e.preventDefault();
|
||||
case 'forward':
|
||||
if (video) video.currentTime = Math.min(video.duration, video.currentTime + 5);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- I: mark in-point ---
|
||||
case e.key === 'i' || e.key === 'I': {
|
||||
e.preventDefault();
|
||||
case 'mark-in':
|
||||
if (video) setMarkInTime(video.currentTime);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- O: mark out-point ---
|
||||
case e.key === 'o' || e.key === 'O': {
|
||||
e.preventDefault();
|
||||
case 'mark-out':
|
||||
if (video) setMarkOutTime(video.currentTime);
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Ctrl+S: save project ---
|
||||
case e.key === 's' && (e.ctrlKey || e.metaKey): {
|
||||
e.preventDefault();
|
||||
case 'save': {
|
||||
const saveBtn = document.querySelector('[title="Save"]') as HTMLButtonElement | null;
|
||||
if (saveBtn) saveBtn.click();
|
||||
else saveProject();
|
||||
return;
|
||||
}
|
||||
|
||||
// --- Ctrl+E: export ---
|
||||
case e.key === 'e' && (e.ctrlKey || e.metaKey): {
|
||||
e.preventDefault();
|
||||
// Trigger export panel via DOM click
|
||||
case 'export': {
|
||||
const exportBtn = document.querySelector('[title="Export"]') as HTMLButtonElement;
|
||||
if (exportBtn) exportBtn.click();
|
||||
return;
|
||||
}
|
||||
|
||||
// --- ?: show shortcut cheatsheet ---
|
||||
case e.key === '?' || (e.key === '/' && e.shiftKey): {
|
||||
e.preventDefault();
|
||||
toggleCheatsheet();
|
||||
case 'search': {
|
||||
const findBtn = document.querySelector('[title="Find (Ctrl+F)"]') as HTMLButtonElement;
|
||||
if (findBtn) findBtn.click();
|
||||
return;
|
||||
}
|
||||
|
||||
case 'help':
|
||||
toggleCheatsheet(currentBindings);
|
||||
return;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
@ -205,7 +165,7 @@ async function saveProject() {
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCheatsheet() {
|
||||
function toggleCheatsheet(bindings: KeyBinding[]) {
|
||||
const existing = document.getElementById('keyboard-cheatsheet');
|
||||
if (existing) {
|
||||
existing.remove();
|
||||
@ -220,33 +180,33 @@ function toggleCheatsheet() {
|
||||
overlay.remove();
|
||||
};
|
||||
|
||||
const shortcuts = [
|
||||
['Space', 'Play / Pause'],
|
||||
['J', 'Reverse / Slow down'],
|
||||
['K', 'Pause'],
|
||||
['L', 'Forward / Speed up'],
|
||||
['\u2190 / \u2192', 'Seek \u00b15 seconds'],
|
||||
['I / O', 'Mark in / out points'],
|
||||
['Delete', 'Cut selected words'],
|
||||
['Ctrl+Z', 'Undo'],
|
||||
['Ctrl+Shift+Z', 'Redo'],
|
||||
['Ctrl+S', 'Save project'],
|
||||
['Ctrl+E', 'Export'],
|
||||
['?', 'This cheatsheet'],
|
||||
];
|
||||
const presetName = JSON.stringify(bindings) === JSON.stringify(DEFAULT_PRESETS['left-hand']) ? 'Left-Hand Preset' : 'Standard Preset';
|
||||
|
||||
const rows = shortcuts
|
||||
const rows = bindings
|
||||
.map(
|
||||
([key, desc]) =>
|
||||
`<tr><td style="padding:6px 16px 6px 0;font-family:monospace;color:#818cf8;font-weight:600">${key}</td><td style="padding:6px 0;color:#e2e8f0">${desc}</td></tr>`,
|
||||
(b) =>
|
||||
`<tr><td style="padding:6px 16px 6px 0;font-family:monospace;color:#818cf8;font-weight:600;white-space:nowrap">${b.keys}</td><td style="padding:6px 0;color:#e2e8f0">${b.label}</td><td style="padding:6px 0 6px 12px;font-size:10px;color:#94a3b8">${b.category}</td></tr>`,
|
||||
)
|
||||
.join('');
|
||||
|
||||
overlay.innerHTML = `<div style="background:#1a1d27;border:1px solid #2a2d3a;border-radius:12px;padding:24px 32px;max-width:400px;" 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>
|
||||
<table style="font-size:13px">${rows}</table>
|
||||
<p style="margin:16px 0 0;font-size:11px;color:#94a3b8;text-align:center">Press ? or click outside to close</p>
|
||||
<p style="margin:16px 0 0;font-size:11px;color:#94a3b8;text-align:center">Customize in Settings • 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;">×</button>
|
||||
</div>`;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@ -8,6 +8,12 @@
|
||||
100% { transform: scaleY(0.3); opacity: 0.5; }
|
||||
}
|
||||
|
||||
@keyframes audioBounce {
|
||||
0% { height: 12px; }
|
||||
50% { height: var(--bar-peak); }
|
||||
100% { height: 12px; }
|
||||
}
|
||||
|
||||
.wave-bar {
|
||||
animation: waveBar 0.9s ease-in-out infinite;
|
||||
transform-origin: bottom;
|
||||
@ -46,3 +52,7 @@ body {
|
||||
video::-webkit-media-controls {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
26
frontend/src/lib/assert.test.ts
Normal file
26
frontend/src/lib/assert.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
11
frontend/src/lib/assert.ts
Normal file
11
frontend/src/lib/assert.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
83
frontend/src/lib/keybindings.ts
Normal file
83
frontend/src/lib/keybindings.ts
Normal file
@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Configurable keyboard shortcuts system.
|
||||
* Stores bindings in localStorage under 'talkedit:keybindings'.
|
||||
* Provides default presets and conflict detection.
|
||||
*/
|
||||
import type { KeyBinding, HotkeyPreset } from '../types/project';
|
||||
|
||||
const STORAGE_KEY = 'talkedit:keybindings';
|
||||
|
||||
export const DEFAULT_PRESETS: Record<HotkeyPreset, KeyBinding[]> = {
|
||||
'left-hand': [
|
||||
{ id: 'play-pause', label: 'Play / Pause', keys: 'Space', category: 'transport' },
|
||||
{ id: 'rewind', label: 'Rewind 5s', keys: 'Q', category: 'transport' },
|
||||
{ id: 'forward', label: 'Forward 5s', keys: 'E', category: 'transport' },
|
||||
{ id: 'speed-up', label: 'Speed Up', keys: 'W', category: 'transport' },
|
||||
{ id: 'slow-down', label: 'Slow Down', keys: 'S', category: 'transport' },
|
||||
{ id: 'pause', label: 'Pause', keys: 'D', category: 'transport' },
|
||||
{ id: 'mark-in', label: 'Mark In Point', keys: 'A', category: 'edit' },
|
||||
{ id: 'mark-out', label: 'Mark Out Point', keys: 'F', category: 'edit' },
|
||||
{ id: 'cut', label: 'Cut Selection', keys: 'X', category: 'edit' },
|
||||
{ id: 'undo', label: 'Undo', keys: 'Ctrl+Z', category: 'edit' },
|
||||
{ id: 'redo', label: 'Redo', keys: 'Ctrl+Shift+Z', category: 'edit' },
|
||||
{ id: 'save', label: 'Save', keys: 'Ctrl+S', category: 'file' },
|
||||
{ id: 'export', label: 'Export', keys: 'Ctrl+E', category: 'file' },
|
||||
{ id: 'search', label: 'Find in Transcript', keys: 'Ctrl+F', category: 'edit' },
|
||||
{ id: 'help', label: 'Shortcut Help', keys: '?', category: 'view' },
|
||||
],
|
||||
'standard': [
|
||||
{ id: 'play-pause', label: 'Play / Pause', keys: 'Space', category: 'transport' },
|
||||
{ id: 'rewind', label: 'Rewind 5s', keys: 'ArrowLeft', category: 'transport' },
|
||||
{ id: 'forward', label: 'Forward 5s', keys: 'ArrowRight', category: 'transport' },
|
||||
{ id: 'speed-up', label: 'Speed Up', keys: 'L', category: 'transport' },
|
||||
{ id: 'slow-down', label: 'Slow Down', keys: 'J', category: 'transport' },
|
||||
{ id: 'pause', label: 'Pause', keys: 'K', category: 'transport' },
|
||||
{ id: 'mark-in', label: 'Mark In Point', keys: 'I', category: 'edit' },
|
||||
{ id: 'mark-out', label: 'Mark Out Point', keys: 'O', category: 'edit' },
|
||||
{ id: 'cut', label: 'Cut Selection', keys: 'Delete', category: 'edit' },
|
||||
{ id: 'undo', label: 'Undo', keys: 'Ctrl+Z', category: 'edit' },
|
||||
{ id: 'redo', label: 'Redo', keys: 'Ctrl+Shift+Z', category: 'edit' },
|
||||
{ id: 'save', label: 'Save', keys: 'Ctrl+S', category: 'file' },
|
||||
{ id: 'export', label: 'Export', keys: 'Ctrl+E', category: 'file' },
|
||||
{ id: 'search', label: 'Find in Transcript', keys: 'Ctrl+F', category: 'edit' },
|
||||
{ id: 'help', label: 'Shortcut Help', keys: '?', category: 'view' },
|
||||
],
|
||||
};
|
||||
|
||||
export function loadBindings(): KeyBinding[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) return JSON.parse(stored);
|
||||
} catch { /* use defaults */ }
|
||||
return DEFAULT_PRESETS['standard'];
|
||||
}
|
||||
|
||||
export function saveBindings(bindings: KeyBinding[]) {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(bindings));
|
||||
}
|
||||
|
||||
export function applyPreset(preset: HotkeyPreset): KeyBinding[] {
|
||||
const bindings = DEFAULT_PRESETS[preset];
|
||||
saveBindings(bindings);
|
||||
return bindings;
|
||||
}
|
||||
|
||||
export function detectConflicts(bindings: KeyBinding[]): string[] {
|
||||
const conflicts: string[] = [];
|
||||
const seen = new Map<string, string>();
|
||||
for (const b of bindings) {
|
||||
if (seen.has(b.keys)) {
|
||||
conflicts.push(`"${b.keys}" is used by both "${seen.get(b.keys)}" and "${b.label}"`);
|
||||
}
|
||||
seen.set(b.keys, b.label);
|
||||
}
|
||||
return conflicts;
|
||||
}
|
||||
|
||||
export function findBinding(bindings: KeyBinding[], id: string): KeyBinding | undefined {
|
||||
return bindings.find((b) => b.id === id);
|
||||
}
|
||||
|
||||
export function getBoundKey(bindings: KeyBinding[], id: string): string {
|
||||
return findBinding(bindings, id)?.keys || '';
|
||||
}
|
||||
@ -96,4 +96,48 @@ window.electronAPI = {
|
||||
await writeTextFile(path, content);
|
||||
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');
|
||||
},
|
||||
};
|
||||
|
||||
81
frontend/src/lib/thumbnails.ts
Normal file
81
frontend/src/lib/thumbnails.ts
Normal file
@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Frontend-side video thumbnail extraction.
|
||||
* Captures frames from the <video> element using canvas.
|
||||
*/
|
||||
|
||||
const THUMBNAIL_CACHE = new Map<string, string>();
|
||||
|
||||
export function extractThumbnail(video: HTMLVideoElement, time: number, width = 160, height = 90): string | null {
|
||||
const cacheKey = `${video.src}_${time.toFixed(3)}_${width}x${height}`;
|
||||
const cached = THUMBNAIL_CACHE.get(cacheKey);
|
||||
if (cached) return cached;
|
||||
|
||||
// Seek to the time, wait for seeked, then capture
|
||||
// Since this is synchronous, we use the current ready frame
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return null;
|
||||
|
||||
// Try to draw the current frame at the requested time
|
||||
const originalTime = video.currentTime;
|
||||
video.currentTime = time;
|
||||
|
||||
// We can't synchronously wait for seek, so catch the 'seeked' event externally
|
||||
// For now, draw whatever video frame is available
|
||||
ctx.drawImage(video, 0, 0, width, height);
|
||||
|
||||
// Return to original time (best-effort)
|
||||
video.currentTime = originalTime;
|
||||
|
||||
const dataUrl = canvas.toDataURL('image/jpeg', 0.6);
|
||||
THUMBNAIL_CACHE.set(cacheKey, dataUrl);
|
||||
return dataUrl;
|
||||
}
|
||||
|
||||
export async function extractThumbnails(
|
||||
video: HTMLVideoElement,
|
||||
times: number[],
|
||||
width = 160,
|
||||
height = 90,
|
||||
): Promise<Map<number, string>> {
|
||||
const results = new Map<number, string>();
|
||||
const originalTime = video.currentTime;
|
||||
|
||||
for (const time of times) {
|
||||
const cacheKey = `${video.src}_${time.toFixed(3)}_${width}x${height}`;
|
||||
const cached = THUMBNAIL_CACHE.get(cacheKey);
|
||||
if (cached) {
|
||||
results.set(time, cached);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Seek and wait for the frame to be available
|
||||
video.currentTime = time;
|
||||
await new Promise<void>((resolve) => {
|
||||
const handler = () => {
|
||||
video.removeEventListener('seeked', handler);
|
||||
resolve();
|
||||
};
|
||||
video.addEventListener('seeked', handler);
|
||||
// Fallback: resolve after a short timeout
|
||||
setTimeout(resolve, 500);
|
||||
});
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
ctx.drawImage(video, 0, 0, width, height);
|
||||
const dataUrl = canvas.toDataURL('image/jpeg', 0.5);
|
||||
THUMBNAIL_CACHE.set(cacheKey, dataUrl);
|
||||
results.set(time, dataUrl);
|
||||
}
|
||||
}
|
||||
|
||||
// Restore original position
|
||||
video.currentTime = originalTime;
|
||||
return results;
|
||||
}
|
||||
@ -5,10 +5,30 @@ import './lib/dev-logger';
|
||||
// Tauri bridge polyfill: must be imported before App so window.electronAPI is available to all components
|
||||
import './lib/tauri-bridge';
|
||||
import App from './App';
|
||||
import ErrorBoundary from './components/ErrorBoundary';
|
||||
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(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
|
||||
127
frontend/src/store/aiStore.test.ts
Normal file
127
frontend/src/store/aiStore.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -3,30 +3,405 @@ import { beforeEach, describe, expect, test } from 'vitest';
|
||||
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(() => {
|
||||
useEditorStore.getState().reset();
|
||||
});
|
||||
|
||||
test('clamps global gain to valid bounds', () => {
|
||||
const state = useEditorStore.getState();
|
||||
describe('global gain', () => {
|
||||
test('clamps to upper bound', () => {
|
||||
useEditorStore.getState().setGlobalGainDb(100);
|
||||
expect(useEditorStore.getState().globalGainDb).toBe(24);
|
||||
});
|
||||
|
||||
state.setGlobalGainDb(100);
|
||||
expect(useEditorStore.getState().globalGainDb).toBe(24);
|
||||
test('clamps to lower bound', () => {
|
||||
useEditorStore.getState().setGlobalGainDb(-100);
|
||||
expect(useEditorStore.getState().globalGainDb).toBe(-24);
|
||||
});
|
||||
|
||||
state.setGlobalGainDb(-100);
|
||||
expect(useEditorStore.getState().globalGainDb).toBe(-24);
|
||||
test('rejects NaN by falling back to 0', () => {
|
||||
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', () => {
|
||||
const state = useEditorStore.getState();
|
||||
describe('zone ranges', () => {
|
||||
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;
|
||||
expect(ranges.length).toBe(1);
|
||||
expect(ranges[0].start).toBe(1.2);
|
||||
expect(ranges[0].end).toBe(2.4);
|
||||
expect(ranges[0].gainDb).toBe(3.5);
|
||||
test('addCutRange generates unique ids', () => {
|
||||
useEditorStore.getState().addCutRange(1, 2);
|
||||
useEditorStore.getState().addCutRange(3, 4);
|
||||
const ranges = useEditorStore.getState().cutRanges;
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { create } from 'zustand';
|
||||
import { temporal } from 'zundo';
|
||||
import { assert } from '../lib/assert';
|
||||
import type {
|
||||
Word,
|
||||
Segment,
|
||||
@ -12,6 +13,11 @@ import type {
|
||||
SilenceDetectionRange,
|
||||
SilenceTrimSettings,
|
||||
SilenceTrimGroup,
|
||||
TimelineMarker,
|
||||
Chapter,
|
||||
ZoomConfig,
|
||||
ClipInfo,
|
||||
BackgroundMusicConfig,
|
||||
} from '../types/project';
|
||||
|
||||
interface EditorState {
|
||||
@ -27,6 +33,7 @@ interface EditorState {
|
||||
speedRanges: SpeedRange[];
|
||||
globalGainDb: number;
|
||||
silenceTrimGroups: SilenceTrimGroup[];
|
||||
timelineMarkers: TimelineMarker[];
|
||||
transcriptionModel: string | null;
|
||||
language: string;
|
||||
|
||||
@ -47,6 +54,10 @@ interface EditorState {
|
||||
|
||||
backendUrl: string;
|
||||
zonePreviewPaddingSeconds: number;
|
||||
|
||||
zoomConfig: ZoomConfig;
|
||||
additionalClips: ClipInfo[];
|
||||
backgroundMusic: BackgroundMusicConfig | null;
|
||||
}
|
||||
|
||||
interface EditorActions {
|
||||
@ -89,14 +100,24 @@ interface EditorActions {
|
||||
settings: SilenceTrimSettings;
|
||||
}) => { groupId: string; appliedCount: number };
|
||||
removeSilenceTrimGroup: (groupId: string) => void;
|
||||
addTimelineMarker: (time: number, label?: string, color?: string) => void;
|
||||
updateTimelineMarker: (id: string, updates: Partial<TimelineMarker>) => void;
|
||||
removeTimelineMarker: (id: string) => void;
|
||||
getChapters: () => Chapter[];
|
||||
setTranscribing: (active: boolean, progress?: number, status?: string) => void;
|
||||
setExporting: (active: boolean, progress?: number) => void;
|
||||
setZonePreviewPaddingSeconds: (seconds: number) => void;
|
||||
replaceWordRange: (startIndex: number, endIndex: number, newWords: Word[]) => void;
|
||||
getKeepSegments: () => Array<{ start: number; end: number }>;
|
||||
getWordAtTime: (time: number) => number;
|
||||
loadProject: (projectData: any) => void;
|
||||
loadProject: (projectData: any) => number;
|
||||
reset: () => void;
|
||||
setZoomConfig: (config: Partial<ZoomConfig>) => void;
|
||||
addAdditionalClip: (path: string, label?: string) => void;
|
||||
removeAdditionalClip: (id: string) => void;
|
||||
reorderAdditionalClip: (id: string, direction: -1 | 1) => void;
|
||||
setBackgroundMusic: (config: BackgroundMusicConfig | null) => void;
|
||||
updateBackgroundMusic: (updates: Partial<BackgroundMusicConfig>) => void;
|
||||
}
|
||||
|
||||
const ZONE_PREVIEW_PADDING_KEY = 'talkedit-zone-preview-padding-seconds';
|
||||
@ -122,6 +143,7 @@ const initialState: EditorState = {
|
||||
speedRanges: [],
|
||||
globalGainDb: 0,
|
||||
silenceTrimGroups: [],
|
||||
timelineMarkers: [],
|
||||
transcriptionModel: null,
|
||||
language: '',
|
||||
currentTime: 0,
|
||||
@ -138,6 +160,9 @@ const initialState: EditorState = {
|
||||
exportProgress: 0,
|
||||
backendUrl: 'http://127.0.0.1:8000',
|
||||
zonePreviewPaddingSeconds: getStoredZonePreviewPaddingSeconds(),
|
||||
zoomConfig: { enabled: false, zoomFactor: 1, panX: 0, panY: 0 },
|
||||
additionalClips: [],
|
||||
backgroundMusic: null,
|
||||
};
|
||||
|
||||
let nextRangeId = 1;
|
||||
@ -179,10 +204,13 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
|
||||
setExportedAudioPath: (path) => set({ exportedAudioPath: path }),
|
||||
|
||||
setTranscriptionModel: (model) => set({ transcriptionModel: model }),
|
||||
setTranscriptionModel: (model) => {
|
||||
if (model === null || model === '') return;
|
||||
set({ transcriptionModel: model });
|
||||
},
|
||||
|
||||
saveProject: (): ProjectFile => {
|
||||
const { videoPath, words, segments, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, silenceTrimGroups, transcriptionModel, language, exportedAudioPath } = get();
|
||||
const { videoPath, words, segments, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, silenceTrimGroups, timelineMarkers, transcriptionModel, language, exportedAudioPath, zoomConfig, additionalClips, backgroundMusic } = get();
|
||||
if (!videoPath) throw new Error('No video loaded');
|
||||
const now = new Date().toISOString();
|
||||
// Strip globalStartIndex (runtime-only field) before persisting.
|
||||
@ -204,13 +232,18 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
speedRanges,
|
||||
globalGainDb,
|
||||
silenceTrimGroups,
|
||||
timelineMarkers,
|
||||
language,
|
||||
createdAt: now, // will be overwritten if we track original creation time later
|
||||
createdAt: now,
|
||||
modifiedAt: now,
|
||||
zoomConfig,
|
||||
additionalClips,
|
||||
backgroundMusic: backgroundMusic ?? undefined,
|
||||
};
|
||||
},
|
||||
|
||||
loadVideo: (path) => {
|
||||
if (!path) return;
|
||||
const { backendUrl, zonePreviewPaddingSeconds } = get();
|
||||
const url = `${backendUrl}/file?path=${encodeURIComponent(path)}`;
|
||||
set({
|
||||
@ -224,6 +257,10 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
},
|
||||
|
||||
setTranscription: (result) => {
|
||||
if (!result.words || result.words.length === 0) {
|
||||
set({ words: [], segments: [], selectedWordIndices: [] });
|
||||
return;
|
||||
}
|
||||
let globalIdx = 0;
|
||||
const annotatedSegments = result.segments.map((seg) => {
|
||||
const annotated = { ...seg, globalStartIndex: globalIdx };
|
||||
@ -241,8 +278,14 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
setCurrentTime: (time) => set({ currentTime: time }),
|
||||
setDuration: (duration) => set({ duration }),
|
||||
setIsPlaying: (playing) => set({ isPlaying: playing }),
|
||||
setMarkInTime: (time) => set({ markInTime: time }),
|
||||
setMarkOutTime: (time) => set({ markOutTime: time }),
|
||||
setMarkInTime: (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 }),
|
||||
setSelectedWordIndices: (indices) => set({ selectedWordIndices: indices }),
|
||||
setHoveredWordIndex: (index) => set({ hoveredWordIndex: index }),
|
||||
@ -266,7 +309,7 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
|
||||
updateWordText: (index, text) => {
|
||||
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) =>
|
||||
i === index ? { ...w, word: text } : w
|
||||
);
|
||||
@ -292,7 +335,8 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
},
|
||||
|
||||
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 = {
|
||||
id: `cut_${nextRangeId++}`,
|
||||
start,
|
||||
@ -303,7 +347,8 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
},
|
||||
|
||||
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 = {
|
||||
id: `mute_${nextRangeId++}`,
|
||||
start,
|
||||
@ -313,7 +358,8 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
},
|
||||
|
||||
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 = {
|
||||
id: `gain_${nextRangeId++}`,
|
||||
start,
|
||||
@ -324,7 +370,8 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
},
|
||||
|
||||
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 = {
|
||||
id: `speed_${nextRangeId++}`,
|
||||
start,
|
||||
@ -409,6 +456,10 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
},
|
||||
|
||||
setGlobalGainDb: (gainDb) => {
|
||||
if (!isFinite(gainDb)) {
|
||||
set({ globalGainDb: 0 });
|
||||
return;
|
||||
}
|
||||
set({ globalGainDb: Math.max(-24, Math.min(24, gainDb)) });
|
||||
},
|
||||
|
||||
@ -453,6 +504,42 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
});
|
||||
},
|
||||
|
||||
addTimelineMarker: (time, label, color) => {
|
||||
if (!isFinite(time) || time < 0) return;
|
||||
const { timelineMarkers, duration } = get();
|
||||
if (time > duration) return;
|
||||
const newMarker: TimelineMarker = {
|
||||
id: `marker_${nextRangeId++}`,
|
||||
time,
|
||||
label: label || 'Marker',
|
||||
color: color || '#6366f1',
|
||||
};
|
||||
set({ timelineMarkers: [...timelineMarkers, newMarker].sort((a, b) => a.time - b.time) });
|
||||
},
|
||||
|
||||
updateTimelineMarker: (id, updates) => {
|
||||
const { timelineMarkers } = get();
|
||||
set({
|
||||
timelineMarkers: timelineMarkers
|
||||
.map((m) => (m.id === id ? { ...m, ...updates } : m))
|
||||
.sort((a, b) => a.time - b.time),
|
||||
});
|
||||
},
|
||||
|
||||
removeTimelineMarker: (id) => {
|
||||
const { timelineMarkers } = get();
|
||||
set({ timelineMarkers: timelineMarkers.filter((m) => m.id !== id) });
|
||||
},
|
||||
|
||||
getChapters: () => {
|
||||
const { timelineMarkers } = get();
|
||||
return timelineMarkers.map((m) => ({
|
||||
markerId: m.id,
|
||||
label: m.label,
|
||||
startTime: m.time,
|
||||
}));
|
||||
},
|
||||
|
||||
setTranscribing: (active, progress, status) =>
|
||||
set({
|
||||
isTranscribing: active,
|
||||
@ -467,6 +554,7 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
}),
|
||||
|
||||
setZonePreviewPaddingSeconds: (seconds) => {
|
||||
if (!isFinite(seconds)) return;
|
||||
const nextSeconds = Math.max(0, Math.min(10, seconds));
|
||||
if (typeof window !== 'undefined') {
|
||||
window.localStorage.setItem(ZONE_PREVIEW_PADDING_KEY, String(nextSeconds));
|
||||
@ -476,6 +564,9 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
|
||||
replaceWordRange: (startIndex, endIndex, newWords) => {
|
||||
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;
|
||||
|
||||
// Replace words in the range with new words
|
||||
@ -557,10 +648,70 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
return lo < words.length ? lo : words.length - 1;
|
||||
},
|
||||
|
||||
setZoomConfig: (config) => {
|
||||
const { zoomConfig } = get();
|
||||
set({ zoomConfig: { ...zoomConfig, ...config } });
|
||||
},
|
||||
|
||||
addAdditionalClip: (path, label) => {
|
||||
const { additionalClips } = get();
|
||||
const id = `clip_${Date.now()}_${Math.random().toString(36).slice(2, 6)}`;
|
||||
set({ additionalClips: [...additionalClips, { id, path, label: label || path.split(/[/\\]/).pop() || 'Clip' }] });
|
||||
},
|
||||
|
||||
removeAdditionalClip: (id) => {
|
||||
const { additionalClips } = get();
|
||||
set({ additionalClips: additionalClips.filter((c) => c.id !== id) });
|
||||
},
|
||||
|
||||
reorderAdditionalClip: (id, direction) => {
|
||||
const { additionalClips } = get();
|
||||
const idx = additionalClips.findIndex((c) => c.id === id);
|
||||
if (idx === -1) return;
|
||||
const target = idx + direction;
|
||||
if (target < 0 || target >= additionalClips.length) return;
|
||||
const reordered = [...additionalClips];
|
||||
[reordered[idx], reordered[target]] = [reordered[target], reordered[idx]];
|
||||
set({ additionalClips: reordered });
|
||||
},
|
||||
|
||||
setBackgroundMusic: (config) => {
|
||||
if (!config || !config.path) {
|
||||
set({ backgroundMusic: null });
|
||||
return;
|
||||
}
|
||||
set({ backgroundMusic: config });
|
||||
},
|
||||
|
||||
updateBackgroundMusic: (updates) => {
|
||||
const { backgroundMusic } = get();
|
||||
if (!backgroundMusic) return;
|
||||
set({ backgroundMusic: { ...backgroundMusic, ...updates } });
|
||||
},
|
||||
|
||||
loadProject: (data) => {
|
||||
const { backendUrl, zonePreviewPaddingSeconds, projectFilePath } = get();
|
||||
const { backendUrl, zonePreviewPaddingSeconds, projectFilePath, duration } = get();
|
||||
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;
|
||||
const annotatedSegments = (data.segments || []).map((seg: Segment) => {
|
||||
const annotated = { ...seg, globalStartIndex: globalIdx };
|
||||
@ -577,20 +728,22 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
||||
videoUrl: url,
|
||||
words: data.words || [],
|
||||
segments: annotatedSegments,
|
||||
// Backward compat: merge legacy deletedRanges into cutRanges as time-range cuts
|
||||
cutRanges: [
|
||||
...(data.cutRanges || []),
|
||||
...(data.deletedRanges || []).map((r: any) => ({ id: r.id, start: r.start, end: r.end })),
|
||||
],
|
||||
muteRanges: data.muteRanges || [],
|
||||
gainRanges: data.gainRanges || [],
|
||||
speedRanges: data.speedRanges || [],
|
||||
cutRanges: cleanedCutRanges,
|
||||
muteRanges: cleanedMuteRanges,
|
||||
gainRanges: cleanedGainRanges,
|
||||
speedRanges: cleanedSpeedRanges,
|
||||
globalGainDb: typeof data.globalGainDb === 'number' ? data.globalGainDb : 0,
|
||||
silenceTrimGroups: data.silenceTrimGroups || [],
|
||||
timelineMarkers: data.timelineMarkers || [],
|
||||
transcriptionModel: data.transcriptionModel ?? null,
|
||||
language: data.language || '',
|
||||
exportedAudioPath: data.exportedAudioPath ?? null,
|
||||
zoomConfig: data.zoomConfig || { enabled: false, zoomFactor: 1, panX: 0, panY: 0 },
|
||||
additionalClips: data.additionalClips || [],
|
||||
backgroundMusic: data.backgroundMusic || null,
|
||||
});
|
||||
|
||||
return removed;
|
||||
},
|
||||
|
||||
reset: () => {
|
||||
|
||||
213
frontend/src/store/licenseStore.test.ts
Normal file
213
frontend/src/store/licenseStore.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
110
frontend/src/store/licenseStore.ts
Normal file
110
frontend/src/store/licenseStore.ts
Normal 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 {};
|
||||
},
|
||||
},
|
||||
),
|
||||
);
|
||||
@ -72,9 +72,13 @@ export interface ProjectFile {
|
||||
speedRanges?: SpeedRange[];
|
||||
globalGainDb?: number;
|
||||
silenceTrimGroups?: SilenceTrimGroup[];
|
||||
timelineMarkers?: TimelineMarker[];
|
||||
language: string;
|
||||
createdAt: string;
|
||||
modifiedAt: string;
|
||||
zoomConfig?: ZoomConfig;
|
||||
additionalClips?: ClipInfo[];
|
||||
backgroundMusic?: BackgroundMusicConfig;
|
||||
}
|
||||
|
||||
export interface TranscriptionResult {
|
||||
@ -83,6 +87,28 @@ export interface TranscriptionResult {
|
||||
language: string;
|
||||
}
|
||||
|
||||
export interface ZoomConfig {
|
||||
enabled: boolean;
|
||||
zoomFactor: number; // 1.0 = no zoom, 2.0 = 2x zoom
|
||||
panX: number; // -1 to 1, normalized pan offset
|
||||
panY: number;
|
||||
}
|
||||
|
||||
export interface ClipInfo {
|
||||
id: string;
|
||||
path: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface BackgroundMusicConfig {
|
||||
path: string;
|
||||
volumeDb: number; // gain in dB for music track
|
||||
duckingEnabled: boolean;
|
||||
duckingDb: number; // how much to duck (dB reduction)
|
||||
duckingAttackMs: number;
|
||||
duckingReleaseMs: number;
|
||||
}
|
||||
|
||||
export interface ExportOptions {
|
||||
outputPath: string;
|
||||
mode: 'fast' | 'reencode';
|
||||
@ -91,8 +117,34 @@ export interface ExportOptions {
|
||||
enhanceAudio: boolean;
|
||||
captions: 'none' | 'burn-in' | 'sidecar';
|
||||
captionStyle?: CaptionStyle;
|
||||
zoom?: ZoomConfig;
|
||||
removeBackground?: boolean;
|
||||
backgroundReplacement?: 'blur' | 'color' | 'image';
|
||||
backgroundReplacementValue?: string;
|
||||
}
|
||||
|
||||
export interface TimelineMarker {
|
||||
id: string;
|
||||
time: number;
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface Chapter {
|
||||
markerId: string;
|
||||
label: string;
|
||||
startTime: number;
|
||||
}
|
||||
|
||||
export interface KeyBinding {
|
||||
id: string;
|
||||
label: string;
|
||||
keys: string; // e.g. "Ctrl+Z"
|
||||
category: string; // "transport", "edit", "file", "view"
|
||||
}
|
||||
|
||||
export type HotkeyPreset = 'left-hand' | 'standard';
|
||||
|
||||
export interface CaptionStyle {
|
||||
fontName: string;
|
||||
fontSize: number;
|
||||
|
||||
18
frontend/src/vite-env.d.ts
vendored
18
frontend/src/vite-env.d.ts
vendored
@ -8,6 +8,13 @@ interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
interface ModelInfo {
|
||||
name: string;
|
||||
path: string;
|
||||
size_bytes: number;
|
||||
kind: string;
|
||||
}
|
||||
|
||||
interface DesktopAPI {
|
||||
openFile: (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>;
|
||||
readFile: (path: string) => Promise<string>;
|
||||
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 {
|
||||
|
||||
@ -1 +1 @@
|
||||
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/AIPanel.tsx","./src/components/DevPanel.tsx","./src/components/ExportDialog.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/tauri-bridge.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"}
|
||||
8
frontend/vitest.config.ts
Normal file
8
frontend/vitest.config.ts
Normal file
@ -0,0 +1,8 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
globals: true,
|
||||
},
|
||||
});
|
||||
197
plan.md
197
plan.md
@ -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
|
||||
- **Goal**: Create an offline Descript alternative with word-level editing, transcription, and export. Users download one file (~10–20MB), 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.
|
||||
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.
|
||||
|
||||
## 2. Tech Stack
|
||||
- **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.
|
||||
**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.
|
||||
|
||||
## 3. Step-by-Step Development Plan
|
||||
1. **Set Up Tauri in CutScript** (1–2 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** (2–3 weeks):
|
||||
- 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).
|
||||
## Phase 1: Polish ✅ COMPLETED
|
||||
|
||||
3. **Migrate Audio/Video Logic** (2 weeks):
|
||||
- Port FFmpeg calls to Rust (use `ffmpeg-next` for cutting/export).
|
||||
- Implement segment calculation: From edited transcript, build keep_segments with padding/fades.
|
||||
- Add audio cleaning (noise reduction via bundled tools or Rust libs).
|
||||
### Reliability & error handling ✅
|
||||
- [x] Backend health check — polls `/health` every 30s, shows reconnecting banner
|
||||
- [x] Export failure reporting — surfaces FFmpeg stderr with copy-to-clipboard
|
||||
- [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** (1–2 weeks):
|
||||
- Update UI for Tauri (file dialogs via `tauri-plugin-dialog`).
|
||||
- Refine transcript editor: Better timestamp syncing, manual adjustments.
|
||||
- Add export options (MP4 with subs, audio-only).
|
||||
### UX polish ✅
|
||||
- [x] Tooltips on every button/control across all panels
|
||||
- [x] Loading spinners for waveform, waveform retry button
|
||||
- [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):
|
||||
- Test on Windows/macOS/Linux; ensure Whisper runs offline.
|
||||
- Bundle with `tauri build`; verify no external deps.
|
||||
- Add auto-updater for Pro features.
|
||||
### Consistency ✅
|
||||
- [x] Mute zone color unified (blue everywhere)
|
||||
- [x] Disabled opacity unified (40% everywhere)
|
||||
- [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):
|
||||
- Open-source core on GitHub.
|
||||
- Market on Product Hunt, Reddit; gather feedback.
|
||||
### Trial & licensing ✅
|
||||
- [x] Trial duration: 7 days
|
||||
- [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)
|
||||
Focus on what creators need for spoken content:
|
||||
- **Drag-and-drop import**: Audio/video files; auto-extract audio.
|
||||
- **One-click transcription**: Whisper.cpp with model choice (Fast - less accurate: tiny/base; Slow - more accurate: small/medium/large).
|
||||
- **Text-based editing**: Scrollable transcript; click word → jump to video; select/delete words → auto-cut audio with 150ms fades.
|
||||
- **Smart cleanup**: Remove fillers ("um", pauses >0.8s) via local AI.
|
||||
- **Preview & Export**: Synced preview; export MP4/audio with optional SRT subs.
|
||||
- **Undo/Redo**: Full edit history.
|
||||
### Robustness ✅
|
||||
- [x] React ErrorBoundary
|
||||
- [x] Store-level input validation (reject NaN, clamp bounds, enforce min zone duration)
|
||||
- [x] Runtime assertions in critical paths (TranscriptEditor, WaveformTimeline, ExportDialog)
|
||||
- [x] Auto-save crash recovery
|
||||
- [x] CI pipeline (GitHub Actions: Rust + Frontend + Python)
|
||||
- [x] Bad project state recovery (auto-prunes invalid zones on load, Dev Panel reset button)
|
||||
- [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
|
||||
- Consider adding Parakeet TDT as a transcription option in the future for users who want alternatives to Whisper.
|
||||
## Phase 2: Beta Launch (🚧 next — 2–4 weeks)
|
||||
|
||||
## 5. Monetization Model
|
||||
- **Free Forever**: Core editing/transcription (unlimited local use).
|
||||
- **Pro License** ($29–49 one-time): Batch processing, high-quality voices (if adding TTS), custom presets, priority support.
|
||||
- **Optional Add-Ons**: Cloud credits for long videos (rarely needed).
|
||||
**Goal:** Get working builds into real podcasters' hands. Validate the core promise (long-form, offline) before investing in edge-case features.
|
||||
|
||||
## 6. Timeline & Milestones
|
||||
- **Weeks 1–4**: Tauri setup + Whisper integration.
|
||||
- **Weeks 5–6**: Audio logic migration + frontend tweaks.
|
||||
- **Weeks 7–8**: Testing, packaging, launch prep.
|
||||
- **Total**: 6–10 weeks to MVP (solo dev + AI).
|
||||
### Must-have for beta
|
||||
|
||||
## 7. Risks & Tips
|
||||
- **Risks**: Whisper.cpp compilation issues; Rust learning curve if new to it.
|
||||
- **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).
|
||||
- **Resources**: Tauri docs, Whisper.cpp GitHub, Rust audio crates.</content>
|
||||
<parameter name="filePath">/home/dillon/_code/audio_editor/plan.md
|
||||
- [ ] **Smart chunking for transcription** — files >2hr. Without this the niche promise is unproven. Breaks transcription into overlapping chunks, reassembles with correct timestamps.
|
||||
- [ ] **Hardware detection & model selection** — detect CUDA/ROCm/MPS at startup; expose model backend choice in Settings so beta users can configure their system.
|
||||
- [ ] **GitHub v1.0.0 release** — tag, binary builds (AppImage + .deb), release notes.
|
||||
|
||||
### 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 (30min–2hr 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 10–90s 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
342
polish_plan.md
Normal file
@ -0,0 +1,342 @@
|
||||
# TalkEdit — Testing & 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
|
||||
|
||||
110
src-tauri/Cargo.lock
generated
110
src-tauri/Cargo.lock
generated
@ -79,7 +79,9 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
name = "app"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"dirs 5.0.1",
|
||||
"ed25519-dalek",
|
||||
"hound",
|
||||
"log",
|
||||
"serde",
|
||||
@ -146,6 +148,12 @@ version = "0.22.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
@ -450,6 +458,12 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-oid"
|
||||
version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "convert_case"
|
||||
version = "0.4.0"
|
||||
@ -599,6 +613,33 @@ dependencies = [
|
||||
"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]]
|
||||
name = "darling"
|
||||
version = "0.23.0"
|
||||
@ -633,6 +674,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "deranged"
|
||||
version = "0.5.8"
|
||||
@ -826,6 +877,30 @@ version = "1.0.20"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "embed-resource"
|
||||
version = "3.0.8"
|
||||
@ -907,6 +982,12 @@ dependencies = [
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fiat-crypto"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||
|
||||
[[package]]
|
||||
name = "field-offset"
|
||||
version = "0.3.6"
|
||||
@ -2536,6 +2617,16 @@ version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "pkg-config"
|
||||
version = "0.3.32"
|
||||
@ -3397,6 +3488,15 @@ version = "1.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "simd-adler32"
|
||||
version = "0.3.8"
|
||||
@ -3491,6 +3591,16 @@ dependencies = [
|
||||
"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]]
|
||||
name = "stable_deref_trait"
|
||||
version = "1.2.1"
|
||||
|
||||
@ -1,15 +1,13 @@
|
||||
[package]
|
||||
name = "app"
|
||||
version = "0.1.0"
|
||||
description = "A Tauri App"
|
||||
description = "TalkEdit - AI-powered video editor"
|
||||
authors = ["you"]
|
||||
license = ""
|
||||
repository = ""
|
||||
edition = "2021"
|
||||
rust-version = "1.77.2"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[lib]
|
||||
name = "app_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
@ -29,3 +27,5 @@ dirs = "5.0"
|
||||
ureq = "2.9"
|
||||
hound = "3.5"
|
||||
tempfile = "3.10"
|
||||
ed25519-dalek = "2"
|
||||
base64 = "0.22"
|
||||
|
||||
@ -11,8 +11,8 @@
|
||||
"dialog:allow-open",
|
||||
"dialog:allow-save",
|
||||
"fs:default",
|
||||
{ "identifier": "fs:allow-read-text-file", "allow": [{ "path": "$HOME/**" }] },
|
||||
{ "identifier": "fs:allow-write-text-file", "allow": [{ "path": "$HOME/**" }] },
|
||||
{ "identifier": "fs:allow-read-text-file", "allow": [{ "path": "$HOME/**" }, { "path": "**" }] },
|
||||
{ "identifier": "fs:allow-write-text-file", "allow": [{ "path": "$HOME/**" }, { "path": "**" }] },
|
||||
"fs:allow-app-read-recursive",
|
||||
"fs:allow-app-write-recursive"
|
||||
]
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
// --- Commands ---
|
||||
|
||||
use tauri::Manager;
|
||||
|
||||
mod paths;
|
||||
mod transcription;
|
||||
mod video_editor;
|
||||
@ -8,6 +10,8 @@ mod diarization;
|
||||
mod ai_provider;
|
||||
mod caption_generator;
|
||||
mod background_removal;
|
||||
mod licensing;
|
||||
mod models;
|
||||
|
||||
#[tauri::command]
|
||||
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))?
|
||||
}
|
||||
|
||||
/// 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
|
||||
#[tauri::command]
|
||||
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))?
|
||||
}
|
||||
|
||||
/// 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 ---
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
@ -239,6 +376,27 @@ pub fn run() {
|
||||
.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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
@ -263,6 +421,18 @@ pub fn run() {
|
||||
save_captions,
|
||||
is_background_removal_available,
|
||||
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!())
|
||||
.expect("error while running tauri application");
|
||||
|
||||
293
src-tauri/src/licensing.rs
Normal file
293
src-tauri/src/licensing.rs
Normal 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
190
src-tauri/src/models.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
@ -6,7 +6,7 @@
|
||||
"build": {
|
||||
"frontendDist": "../frontend/dist",
|
||||
"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"
|
||||
},
|
||||
"app": {
|
||||
|
||||
@ -45,7 +45,16 @@ def main():
|
||||
device = "cpu"
|
||||
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
|
||||
print(f"Starting transcription of {wav_path} with model {model_name}", file=sys.stderr)
|
||||
|
||||
Reference in New Issue
Block a user