Compare commits
25 Commits
4d4dfa7f7c
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 3093b41033 | |||
| a64ae78833 | |||
| b558ef8a7f | |||
| f1e6c010eb | |||
| 124f215a0a | |||
| 1993aabeac | |||
| 573ac9c9f5 | |||
| 5d52c8aec5 | |||
| 8bd1ad5b69 | |||
| 850b373d42 | |||
| 2212d7b265 | |||
| 813877a7b4 | |||
| e4484a57f9 | |||
| 10437c02ca | |||
| 4004312994 | |||
| 9a301fe2a2 | |||
| 6ac1d68887 | |||
| acf7f2e64c | |||
| a96e42c9f9 | |||
| fd6697b48e | |||
| 09ebcbc9ec | |||
| 88cd9a21d0 | |||
| 91217f6db0 | |||
| 835719a907 | |||
| 810957747b |
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
|
||||||
94
.github/workflows/release.yml
vendored
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags:
|
||||||
|
- 'v*'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
linux:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
- run: npm ci
|
||||||
|
working-directory: frontend
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y \
|
||||||
|
libwebkit2gtk-4.1-dev \
|
||||||
|
librsvg2-dev \
|
||||||
|
patchelf \
|
||||||
|
libssl-dev \
|
||||||
|
libgtk-3-dev \
|
||||||
|
libayatana-appindicator3-dev \
|
||||||
|
rpm
|
||||||
|
- name: Download FFmpeg (bundled sidecar)
|
||||||
|
run: |
|
||||||
|
mkdir -p src-tauri/binaries
|
||||||
|
curl -sL "https://johnvansickle.com/ffmpeg/releases/ffmpeg-release-amd64-static.tar.xz" -o /tmp/ffmpeg.tar.xz
|
||||||
|
tar -xf /tmp/ffmpeg.tar.xz -C /tmp
|
||||||
|
cp /tmp/ffmpeg-*-amd64-static/ffmpeg src-tauri/binaries/ffmpeg-x86_64-unknown-linux-gnu
|
||||||
|
cp /tmp/ffmpeg-*-amd64-static/ffprobe src-tauri/binaries/ffprobe-x86_64-unknown-linux-gnu
|
||||||
|
chmod +x src-tauri/binaries/*
|
||||||
|
- uses: tauri-apps/tauri-action@v0
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
tagName: ${{ github.ref_name }}
|
||||||
|
releaseName: 'TalkEdit ${{ github.ref_name }}'
|
||||||
|
releaseBody: 'See the assets to download and install this version.'
|
||||||
|
releaseDraft: false
|
||||||
|
includeUpdaterJson: true
|
||||||
|
args: --bundles deb,rpm
|
||||||
|
|
||||||
|
windows:
|
||||||
|
runs-on: windows-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 20
|
||||||
|
cache: npm
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
- run: npm ci
|
||||||
|
working-directory: frontend
|
||||||
|
- uses: dtolnay/rust-toolchain@stable
|
||||||
|
- uses: tauri-apps/tauri-action@v0
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
with:
|
||||||
|
tagName: ${{ github.ref_name }}
|
||||||
|
releaseName: 'TalkEdit ${{ github.ref_name }}'
|
||||||
|
releaseBody: 'See the assets to download and install this version.'
|
||||||
|
releaseDraft: false
|
||||||
|
includeUpdaterJson: true
|
||||||
|
args: --bundles msi
|
||||||
|
|
||||||
|
# macos:
|
||||||
|
# runs-on: macos-latest
|
||||||
|
# steps:
|
||||||
|
# - uses: actions/checkout@v4
|
||||||
|
# - uses: actions/setup-node@v4
|
||||||
|
# with:
|
||||||
|
# node-version: 20
|
||||||
|
# cache: npm
|
||||||
|
# cache-dependency-path: frontend/package-lock.json
|
||||||
|
# - run: npm ci
|
||||||
|
# working-directory: frontend
|
||||||
|
# - uses: dtolnay/rust-toolchain@stable
|
||||||
|
# - uses: tauri-apps/tauri-action@v0
|
||||||
|
# env:
|
||||||
|
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
# with:
|
||||||
|
# tagName: ${{ github.ref_name }}
|
||||||
|
# releaseName: 'TalkEdit ${{ github.ref_name }}'
|
||||||
|
# releaseBody: 'See the assets to download and install this version.'
|
||||||
|
# releaseDraft: false
|
||||||
|
# includeUpdaterJson: true
|
||||||
|
# args: --bundles dmg
|
||||||
262
FEATURES.md
@ -1,93 +1,181 @@
|
|||||||
# TalkEdit — Feature Roadmap
|
# TalkEdit — Features & Roadmap
|
||||||
|
|
||||||
Features are grouped by priority. Check off items as they are implemented.
|
**Niche:** "Descript for long-form content" — works on hour+ files without degrading, fully offline, one-time payment.
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🔴 Highest Impact Next — Conversion and retention
|
|
||||||
|
|
||||||
- [x] [#015] **Word text correction** — double-click any word to edit its text in-place. Preserves timing and confidence. Pure frontend state change. (2026-05-04)
|
|
||||||
|
|
||||||
- [x] [#013] **Re-transcribe selection** — select any word range in the transcript and click "Re-transcribe" to re-run Whisper on just that segment. Backend extracts audio via FFmpeg, transcribes with offset-adjusted timestamps. (2026-05-04)
|
|
||||||
|
|
||||||
- [x] [#012] **Low-confidence word highlighting** — words with `confidence < 0.6` (configurable in Settings) get an orange dotted underline. Hover shows exact confidence %. (2026-05-04)
|
|
||||||
|
|
||||||
- [x] [#018] **Audio normalization / loudness targeting** — Integrated checkbox in Export panel with LUFS target selector (-14 YouTube, -16 Spotify, -23 Broadcast). Applied during export via FFmpeg `loudnorm` in the audio filter chain. No intermediate files. (2026-05-04)
|
|
||||||
|
|
||||||
- [x] [#024] **Export to transcript text / SRT only** — "Export Transcript Only" section in Export panel with format selector (plain text or SRT). Uses `POST /export/transcript` backend endpoint. Respects word cuts. (2026-05-04)
|
|
||||||
|
|
||||||
- [x] [#023] **Batch silence removal** — full-file scan + remove all pauses above threshold in one click. Implemented by `SilenceTrimmerPanel` + `POST /audio/detect-silence` (FFmpeg silencedetect).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🟡 Medium Impact — Workflow completeness
|
|
||||||
|
|
||||||
- [x] [#016] **Named timeline markers** — colored marker pins on the waveform canvas. Add at current playback position with label/color picker in Markers panel. Editable labels, deletable. Persisted in project file. (2026-05-04)
|
|
||||||
|
|
||||||
- [x] [#017] **Chapters** — sorted markers auto-form chapters. "Copy as YouTube timestamps" button exports `MM:SS Label` format to clipboard. (2026-05-04)
|
|
||||||
|
|
||||||
- [x] [#041] **Customizable hotkeys / keymap editor** — two presets (Standard: J/K/L/I/O/arrows; Left-hand: Q/W/E/A/S/D/F). Settings panel shows all bindings with click-to-remap, conflict detection, per-key reset to default. Cheatsheet (press `?`) shows current bindings. (2026-05-04)
|
|
||||||
|
|
||||||
- [x] [#022] **Clip thumbnail strip** — frontend-side canvas capture from the `<video>` element. Toggle "Thumbnails" button above waveform. Extracts frames at 10s intervals, clickable to seek. Zero backend dependency. (2026-05-04)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 🟢 Lower Impact — Expansion and advanced scope
|
|
||||||
|
|
||||||
- [x] [#020] **Video zoom / punch-in** — scale and position the video (crop, zoom, pan). Used constantly on talking-head videos for emphasis. Backend: FFmpeg crop/scale post-process. Frontend: sliders in Export dialog. (2026-05-05)
|
|
||||||
|
|
||||||
- [x] [#021] **Multi-clip / append** — load additional video clips via Append Clip panel and concatenate during export. Uses FFmpeg concat demuxer. (2026-05-05)
|
|
||||||
|
|
||||||
- [x] [#019] **Background music track** — a second audio track for background music with volume ducking. Uses FFmpeg amix + sidechaincompress for auto-ducking. Configurable in Background Music panel. (2026-05-05)
|
|
||||||
|
|
||||||
- [ ] [#014] **Optional VibeVoice-ASR-HF transcription backend (future)** — evaluate as an alternate transcription mode for long-form, speaker-attributed transcripts. Keep WhisperX as the default for word-level timestamp editing.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ✅ Completed high-impact foundation
|
|
||||||
|
|
||||||
- [x] [#001] **Cut / Mute sections**
|
|
||||||
- [x] [#002] **Silence / pause trimmer**
|
|
||||||
- [x] [#003] **Operation-level undo for batch actions**
|
|
||||||
- [x] [#004] **Grouped silence-trim zones (editable batch)**
|
|
||||||
- [x] [#005] **Edit silence-trim group settings after apply**
|
|
||||||
- [x] [#006] **Volume / gain control**
|
|
||||||
- [x] [#007] **Speed adjustment (4th zone type)**
|
|
||||||
- [x] [#008] **Cut preview**
|
|
||||||
- [x] [#009] **Timeline shows output length**
|
|
||||||
- [x] [#010] **Transcript search (Ctrl+F)**
|
|
||||||
- [x] [#011] **Mark In / Out + delete (I / O keys)**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
- [x] [#042] **Background removal** — MediaPipe Selfie Segmentation + FFmpeg frame processing for person/background separation. Configurable replacement: blur, solid color, or custom image. Applied during export. Falls back to FFmpeg colorkey when MediaPipe unavailable. (2026-05-05)
|
|
||||||
|
|
||||||
## 💡 TalkEdit competitive advantages to lean into
|
|
||||||
|
|
||||||
These aren't features to build — they're things to make more visible in the UI and README:
|
|
||||||
|
|
||||||
- **100% offline / no account required** — CapCut requires login and sends data to servers. Descript is cloud-first. TalkEdit never leaves the machine.
|
|
||||||
- **Local AI models** — Ollama support means no API costs and no data leaving the device.
|
|
||||||
- **Word-level precision** — editing by deleting words (not dragging razor cuts) is faster for talking-head content than any timeline-based editor.
|
|
||||||
- **Works on long files** — virtualized transcript + chunked waveform handles 1hr+ content that bogs down CapCut.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## ✅ Already Implemented
|
## ✅ Already Implemented
|
||||||
|
|
||||||
- [#025] Word-level transcript editing (select, drag, shift-click, delete)
|
### Core editing
|
||||||
- [#026] Ctrl+click word → seek timeline to that position
|
- [x] [#001] **Cut / Mute sections** — remove or silence segments from output
|
||||||
- [#027] Waveform timeline with zoom (Ctrl+scroll), scroll, drag-to-scrub playhead
|
- [x] [#002] **Silence / pause trimmer** — batch detect and remove silent pauses
|
||||||
- [#028] Auto-scroll waveform when playhead goes off-screen
|
- [x] [#006] **Volume / gain control** — per-zone and global gain adjustment
|
||||||
- [#029] AI filler word detection and removal (Ollama / OpenAI / Claude)
|
- [x] [#007] **Speed adjustment** — per-zone playback speed changes (0.25x–4x)
|
||||||
- [#030] AI clip suggestions for social media
|
- [x] [#008] **Cut preview** — preview zones before export with configurable padding
|
||||||
- [#031] Noise reduction (DeepFilterNet or FFmpeg ANLMDN)
|
- [x] [#009] **Timeline shows output length** — adjusted timeline with cut compression
|
||||||
- [#032] Export: fast stream-copy or full reencode (MP4/MOV/WebM/WAV, 720p/1080p/4K). WAV available for audio-only inputs.
|
- [x] [#011] **Mark In / Out** — I/O keys to set selection range on timeline
|
||||||
- [#033] Captions: SRT, VTT, ASS burn-in with font/color/position options
|
|
||||||
- [#034] Speaker diarization
|
### Transcript
|
||||||
- [#035] Project save / load (.aive JSON format)
|
- [x] [#010] **Transcript search (Ctrl+F)** — find words, navigate matches
|
||||||
- [#036] Undo / redo (100-level history via Zundo)
|
- [x] [#012] **Low-confidence word highlighting** — orange dotted underline with confidence %
|
||||||
- [#037] Multi-format input (MP4, MKV, MOV, AVI, WebM, M4A)
|
- [x] [#013] **Re-transcribe selection** — re-run Whisper on a selected word range
|
||||||
- [#038] Keyboard shortcuts (Space, J/K/L, arrows, Ctrl+Z/Shift+Z, Ctrl+S, Ctrl+E)
|
- [x] [#015] **Word text correction** — double-click any word to edit text in-place
|
||||||
- [#039] Settings panel: AI provider config (Ollama, OpenAI, Claude)
|
- [x] [#016] **Named timeline markers** — colored pins with labels, editable
|
||||||
- [#040] Cut/mute range creation on timeline with draggable zone edits and Delete-to-remove
|
- [x] [#017] **Chapters** — auto-form from markers, copy as YouTube timestamps
|
||||||
|
- [x] [#025] Word-level transcript editing (click, shift+click, drag select)
|
||||||
|
- [x] [#026] Ctrl+click word → seek video to that timestamp
|
||||||
|
- [x] [#027] Waveform timeline with zoom (Ctrl+scroll), scroll, drag-to-scrub
|
||||||
|
- [x] [#028] Auto-scroll waveform when playhead goes off-screen
|
||||||
|
|
||||||
|
### AI features
|
||||||
|
- [x] [#029] **AI filler word detection** — find and remove "um", "uh", "like" etc.
|
||||||
|
- [x] [#030] **AI clip suggestions** — find best 20-60s segments for social media
|
||||||
|
- [x] [#031] **Noise reduction** — DeepFilterNet or FFmpeg ANLMDN
|
||||||
|
- [x] [#034] **Speaker diarization** — label speakers in transcript
|
||||||
|
- [x] [#042] **Background removal** — MediaPipe segmentation, blur/color/image replacement
|
||||||
|
|
||||||
|
### Export
|
||||||
|
- [x] [#018] **Audio loudness normalization** — LUFS targets (-14 YouTube, -16 Spotify, -23 Broadcast)
|
||||||
|
- [x] [#019] **Background music** — auto-ducking via FFmpeg sidechain compress
|
||||||
|
- [x] [#020] **Video zoom / punch-in** — crop, zoom, pan during export
|
||||||
|
- [x] [#021] **Multi-clip / append** — concatenate multiple video files
|
||||||
|
- [x] [#024] **Export transcript** — plain text or SRT without video
|
||||||
|
- [x] [#032] **Export** — fast stream-copy or full re-encode (MP4/MOV/WebM/WAV, 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
@ -1,26 +1,58 @@
|
|||||||
# CutScript
|
# TalkEdit
|
||||||
|
|
||||||
An open-source, local-first, Descript-like text-based audio and video editor powered by AI. Edit audio/video by editing text — delete a word from the transcript and it's cut from the audio/video.
|
**Edit video by editing text.** An offline, local-first desktop video editor where deleting a word from the transcript cuts it from the video.
|
||||||
|
|
||||||
<img width="1034" height="661" alt="image" src="https://github.com/user-attachments/assets/b1ed9505-792e-42ca-bb73-85458d0f02a5" />
|
<img width="1034" height="661" alt="TalkEdit screenshot" src="https://github.com/user-attachments/assets/b1ed9505-792e-42ca-bb73-85458d0f02a5" />
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Architecture
|
## Features
|
||||||
|
|
||||||
- **Tauri + React** desktop app with Tailwind CSS
|
- **Text-based editing** — delete, reorder, or correct words in the transcript to edit the underlying video. No razor tool, no timeline slicing.
|
||||||
- **FastAPI** Python backend (spawned as child process)
|
- **Word-level transcription** — Whisper.cpp with per-word timestamps and confidence scores. Low-confidence words get a visual warning.
|
||||||
- **WhisperX** for word-level transcription with alignment
|
- **Four zone types** — Cut, Mute, Sound Gain, and Speed Adjust. Create zones on the waveform timeline and drag edges to refine.
|
||||||
- **FFmpeg** for video processing (stream-copy and re-encode)
|
- **Waveform timeline** — zoomable, scrollable waveform with playhead scrubbing, zone visualization, markers, chapters, and thumbnail strips.
|
||||||
- **Ollama / OpenAI / Claude** for AI features (filler removal, clip creation)
|
- **AI-powered editing**
|
||||||
|
- Filler word detection and removal
|
||||||
|
- Smart Clean: one-click filler removal + silence trim + noise reduction + loudness normalization
|
||||||
|
- Clip suggestions for social media shorts
|
||||||
|
- Sentence rephrase with AI alternatives
|
||||||
|
- Supports **Ollama** (local), **OpenAI**, and **Claude** backends
|
||||||
|
- **Background music** — import a second audio track with auto-ducking via sidechain compression.
|
||||||
|
- **Export** — fast stream-copy or full re-encode to MP4, MOV, WebM, or WAV. Resolution up to 4K.
|
||||||
|
- **Captions** — generate SRT, VTT, or burn-in ASS subtitles with configurable font, color, and position.
|
||||||
|
- **Speaker diarization** — identify and label multiple speakers.
|
||||||
|
- **Audio tools** — noise reduction (DeepFilterNet), loudness normalization (LUFS targeting), background removal (MediaPipe), batch silence removal, video zoom/punch-in.
|
||||||
|
- **Project save/load** — `.aive` JSON format preserves all edits, zones, markers, and AI config.
|
||||||
|
- **Customizable hotkeys** — two presets (Standard / Left-hand) with per-key remapping and conflict detection.
|
||||||
|
- **100% offline, no account required** — everything runs on your machine. No telemetry, no cloud dependency.
|
||||||
|
- **7-day free trial** with one-time license key purchase. No subscription.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
| Layer | Technology |
|
||||||
|
|-------|------------|
|
||||||
|
| Desktop shell | **Tauri 2.0** (Rust) |
|
||||||
|
| Frontend | **React** + **TypeScript** + **Tailwind CSS** |
|
||||||
|
| State management | **Zustand** with Zundo undo/redo |
|
||||||
|
| Transcription | **Whisper.cpp** (word-level timestamps) |
|
||||||
|
| AI / LLM | **Ollama**, **OpenAI**, **Claude** (plugable backends) |
|
||||||
|
| Media processing | **FFmpeg** |
|
||||||
|
| Python services | **FastAPI** (spawned as a child process) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Node.js 18+
|
- **Node.js** 18+
|
||||||
- Python 3.10+
|
- **Python** 3.10+
|
||||||
- FFmpeg (in PATH)
|
- **FFmpeg** (in PATH)
|
||||||
- (Optional) Ollama for local AI features
|
- **Rust** toolchain (for Tauri)
|
||||||
|
- **Ollama** (optional, for local AI features)
|
||||||
|
|
||||||
### Install
|
### Install
|
||||||
|
|
||||||
@ -36,65 +68,89 @@ cd backend && pip install -r requirements.txt && cd ..
|
|||||||
### Run (Development)
|
### Run (Development)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Start Tauri dev environment (includes backend + frontend)
|
# Start everything: backend + frontend + Tauri
|
||||||
npm run dev:tauri
|
npm run dev:tauri
|
||||||
```
|
```
|
||||||
|
|
||||||
Or run them separately:
|
Or run components separately:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Terminal 1: Backend
|
# Terminal 1: Python backend
|
||||||
cd backend && python -m uvicorn main:app --reload --port 8642
|
npm run dev:backend
|
||||||
|
|
||||||
# Terminal 2: Frontend + Tauri
|
# Terminal 2: Frontend + Tauri
|
||||||
cd frontend && cargo tauri dev
|
cd frontend && cargo tauri dev
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run build:tauri
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
talkedit/
|
talkedit/
|
||||||
├── src-tauri/ # Tauri Rust runtime
|
├── src-tauri/ # Tauri 2.0 Rust runtime
|
||||||
│ ├── Cargo.toml
|
│ ├── Cargo.toml
|
||||||
│ ├── src/
|
|
||||||
│ │ ├── main.rs # App entry & backend spawner
|
|
||||||
│ │ └── commands/ # Tauri IPC handlers
|
|
||||||
├── frontend/ # React + Vite + Tailwind
|
|
||||||
│ └── src/
|
│ └── src/
|
||||||
│ ├── components/ # VideoPlayer, TranscriptEditor, etc.
|
│ ├── main.rs # App entry, backend spawner
|
||||||
│ ├── store/ # Zustand state (editorStore, aiStore)
|
│ ├── lib.rs # Command handlers (IPC bridge)
|
||||||
│ ├── lib/tauri-bridge.ts # Tauri API polyfill
|
│ ├── transcription.rs # Whisper.cpp integration
|
||||||
│ └── types/ # TypeScript interfaces
|
│ ├── video_editor.rs # FFmpeg-based editing
|
||||||
├── backend/ # FastAPI Python backend
|
│ ├── caption_generator.rs
|
||||||
|
│ ├── diarization.rs
|
||||||
|
│ ├── ai_provider.rs # Ollama / OpenAI / Claude
|
||||||
|
│ ├── audio_cleaner.rs
|
||||||
|
│ ├── background_removal.rs
|
||||||
|
│ ├── licensing.rs # Trial + key activation
|
||||||
|
│ ├── models.rs # Shared data types
|
||||||
|
│ └── paths.rs
|
||||||
|
├── frontend/ # React + Vite + Tailwind
|
||||||
|
│ └── src/
|
||||||
|
│ ├── components/ # UI components
|
||||||
|
│ │ ├── TranscriptEditor.tsx
|
||||||
|
│ │ ├── WaveformTimeline.tsx
|
||||||
|
│ │ ├── VideoPlayer.tsx
|
||||||
|
│ │ ├── AIPanel.tsx
|
||||||
|
│ │ ├── ExportDialog.tsx
|
||||||
|
│ │ ├── SettingsPanel.tsx
|
||||||
|
│ │ ├── BackgroundMusicPanel.tsx
|
||||||
|
│ │ ├── MarkersPanel.tsx
|
||||||
|
│ │ ├── ZoneEditor.tsx
|
||||||
|
│ │ ├── SilenceTrimmerPanel.tsx
|
||||||
|
│ │ ├── AppendClipPanel.tsx
|
||||||
|
│ │ ├── LicenseDialog.tsx
|
||||||
|
│ │ └── DevPanel.tsx
|
||||||
|
│ ├── store/ # Zustand state (editorStore, aiStore, settingsStore)
|
||||||
|
│ ├── hooks/ # Custom React hooks
|
||||||
|
│ ├── lib/ # Utilities and Tauri bridge
|
||||||
|
│ └── types/ # TypeScript interfaces
|
||||||
|
├── backend/ # FastAPI Python services
|
||||||
│ ├── main.py
|
│ ├── main.py
|
||||||
│ ├── routers/ # API endpoints
|
│ ├── routers/ # API endpoints
|
||||||
│ ├── services/ # Core logic (transcription, editing, AI)
|
│ │ ├── transcribe.py
|
||||||
│ └── utils/ # GPU, cache, audio helpers
|
│ │ ├── ai.py
|
||||||
└── shared/ # Project schema
|
│ │ ├── audio.py
|
||||||
|
│ │ ├── captions.py
|
||||||
|
│ │ └── export.py
|
||||||
|
│ ├── services/ # Core logic
|
||||||
|
│ ├── video_editor.py
|
||||||
|
│ ├── caption_generator.py
|
||||||
|
│ ├── ai_provider.py
|
||||||
|
│ ├── diarization.py
|
||||||
|
│ ├── audio_cleaner.py
|
||||||
|
│ ├── background_removal.py
|
||||||
|
│ └── license_server.py
|
||||||
|
├── shared/ # Schema definitions (project format)
|
||||||
|
├── models/ # Whisper model storage
|
||||||
|
└── docs/ # Documentation
|
||||||
```
|
```
|
||||||
|
|
||||||
## Features
|
---
|
||||||
|
|
||||||
| Feature | Status |
|
|
||||||
|---------|--------|
|
|
||||||
| Word-level transcription (WhisperX) | Done |
|
|
||||||
| Text-based video editing | Done |
|
|
||||||
| Undo/redo | Done |
|
|
||||||
| Waveform timeline | Done |
|
|
||||||
| FFmpeg stream-copy export | Done |
|
|
||||||
| FFmpeg re-encode (up to 4K) | Done |
|
|
||||||
| AI filler word removal | Done |
|
|
||||||
| AI clip creation (Shorts) | Done |
|
|
||||||
| Ollama + OpenAI + Claude | Done |
|
|
||||||
| Word-level captions (SRT/VTT/ASS) | Done |
|
|
||||||
| Caption burn-in on export | Done |
|
|
||||||
| Studio Sound (DeepFilterNet) | Done |
|
|
||||||
| Keyboard shortcuts (J/K/L) | Done |
|
|
||||||
| Speaker diarization | Done |
|
|
||||||
| Virtualized transcript (react-virtuoso) | Done |
|
|
||||||
| Encrypted API key storage | Done |
|
|
||||||
| Project save/load (.cutscript) | Done |
|
|
||||||
| AI background removal | Planned |
|
|
||||||
|
|
||||||
## Keyboard Shortcuts
|
## Keyboard Shortcuts
|
||||||
|
|
||||||
@ -102,28 +158,19 @@ talkedit/
|
|||||||
|-----|--------|
|
|-----|--------|
|
||||||
| Space | Play / Pause |
|
| Space | Play / Pause |
|
||||||
| J / K / L | Reverse / Pause / Forward |
|
| J / K / L | Reverse / Pause / Forward |
|
||||||
|
| I / O | Mark In / Mark Out |
|
||||||
| ← / → | Seek ±5 seconds |
|
| ← / → | Seek ±5 seconds |
|
||||||
| Delete | Delete selected words |
|
| Delete | Delete selected words or zones |
|
||||||
| Ctrl+Z | Undo |
|
| Ctrl+Z | Undo |
|
||||||
| Ctrl+Shift+Z | Redo |
|
| Ctrl+Shift+Z | Redo |
|
||||||
| Ctrl+S | Save project |
|
| Ctrl+S | Save project |
|
||||||
| Ctrl+E | Export |
|
| Ctrl+E | Export |
|
||||||
|
| Ctrl+F | Search transcript |
|
||||||
|
| Ctrl+Scroll | Zoom waveform |
|
||||||
| ? | Shortcut cheatsheet |
|
| ? | Shortcut cheatsheet |
|
||||||
|
|
||||||
## API Endpoints
|
---
|
||||||
|
|
||||||
| Method | Endpoint | Description |
|
|
||||||
|--------|----------|-------------|
|
|
||||||
| GET | /health | Health check |
|
|
||||||
| POST | /transcribe | Transcribe video with WhisperX |
|
|
||||||
| POST | /export | Export edited video (stream copy or re-encode) |
|
|
||||||
| POST | /ai/filler-removal | Detect filler words via LLM |
|
|
||||||
| POST | /ai/create-clip | AI-suggested clips for shorts |
|
|
||||||
| GET | /ai/ollama-models | List local Ollama models |
|
|
||||||
| POST | /captions | Generate SRT/VTT/ASS captions |
|
|
||||||
| POST | /audio/clean | Noise reduction (DeepFilterNet) |
|
|
||||||
| GET | /audio/capabilities | Check audio processing availability |
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
MIT License — see [LICENSE](LICENSE) for details.
|
Source code is MIT — see [LICENSE](LICENSE) for details. The distributed binary includes a 7-day free trial requiring a one-time license key purchase for continued use.
|
||||||
|
|||||||
@ -1,50 +1,62 @@
|
|||||||
# TalkEdit — Tech Stack, Tools, and Planned Features
|
# TalkEdit — Tech Stack, Tools, and Features
|
||||||
|
|
||||||
This document summarizes the chosen technology, tooling, the full planned feature set for the MVP, recommended additions, removals, and items to put on the back burner.
|
This document summarizes the chosen technology, tooling, the full feature set, recommended additions, and items on the back burner.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
- Goal: Offline, local text-based audio/video editor (Descript-style) focused on spoken-word creators (podcasters, YouTubers). Fast, privacy-first, single-file installer.
|
- Goal: Offline, local text-based audio/video editor (Descript-style) focused on spoken-word creators (podcasters, YouTubers). Fast, privacy-first, single-file installer.
|
||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
- Frontend: React + Vite + Tailwind CSS + shadcn/ui
|
- Frontend: React 19 + Vite + TypeScript + Tailwind CSS + Zustand (with zundo undo/redo) + Virtuoso (virtualized transcript)
|
||||||
- Backend: Tauri 2.0 (Rust) for file I/O, invoking native binaries, and exposing commands to the UI
|
- Backend: Tauri 2.0 (Rust) for file I/O, licensing, licensing crypto (Ed25519), model management, error logging
|
||||||
- Transcription: Whisper.cpp (Rust bindings like `whisper-rs` / `whisper-cpp-sys`) — word-level timestamps
|
- Transcription: Python faster-whisper with WhisperX for word-level alignment. Models downloaded on demand.
|
||||||
- Audio/Video Processing: FFmpeg invoked from Rust (or `ffmpeg-next` Rust crate)
|
- Audio/Video Processing: FFmpeg invoked from Rust via Python scripts (video_editor.py, audio_cleaner.py, caption_generator.py)
|
||||||
- State: Zustand (in-frontend store)
|
- AI: Ollama, OpenAI, Claude through Python ai_provider.py. Bundled Qwen3 LLM planned.
|
||||||
|
- State: Zustand (in-frontend store) + zundo middleware for undo/redo history
|
||||||
- Packaging: Tauri `tauri build` for cross-platform installers
|
- Packaging: Tauri `tauri build` for cross-platform installers
|
||||||
- Optional local tools: Ollama (optional local LLMs) for advanced on-device heuristics
|
|
||||||
|
|
||||||
## Developer Tools
|
## Developer Tools
|
||||||
- Rust toolchain (cargo, rustc)
|
- Rust toolchain (cargo, rustc)
|
||||||
- Node.js + npm/yarn for frontend
|
- Node.js + npm for frontend
|
||||||
|
- Python 3.11+ (faster-whisper, WhisperX, AI providers)
|
||||||
- FFmpeg binaries (platform-specific; bundled or downloaded at install)
|
- FFmpeg binaries (platform-specific; bundled or downloaded at install)
|
||||||
- Build/test: Tauri CLI, Vite dev server
|
- Build/test: Tauri CLI, Vite dev server
|
||||||
|
- Testing: Vitest (frontend), cargo test (Rust), pytest (Python)
|
||||||
|
- CI: GitHub Actions (Rust clippy/test, Frontend tsc/vitest, Python pytest)
|
||||||
|
|
||||||
## MVP Feature List (Planned)
|
## Implemented Features
|
||||||
1. Drag-and-drop import (audio/video auto audio-extract)
|
|
||||||
2. One-click local transcription (model selector: tiny/base → larger models)
|
- [x] 1. Media import via file dialog (audio/video auto audio-extract)
|
||||||
3. Scrollable, Google-Doc-style transcript editor
|
- [x] 2. One-click local transcription with model selector (tiny/base → larger models) and model-size chooser
|
||||||
|
- [x] 3. Scrollable, Google-Doc-style transcript editor (Virtuoso virtualized)
|
||||||
- Click word → seek video/audio
|
- Click word → seek video/audio
|
||||||
- Highlight + Delete → remove corresponding media segment (smart 150–250ms fades)
|
- Select words → cut corresponding media segment (smart 150–250ms fades)
|
||||||
4. One-click "Clean it" button
|
- [x] 4. Smart Cleanup
|
||||||
- Remove fillers (configurable list)
|
- Filler word removal (configurable list per-project)
|
||||||
- Remove long pauses (>0.8s) by default
|
- Silence trimming
|
||||||
5. One-click audio polish chain (FFmpeg): normalize, light compression, basic noise reduction
|
- [x] 5. Audio Polish chain (FFmpeg): normalize, compression, noise reduction
|
||||||
6. Preview with synced playback, undo/redo, project save/load
|
- [x] 6. Preview with synced playback, undo/redo (zundo), project save/load
|
||||||
7. Export MP4/audio with optional SRT/VTT captions and burned-in captions
|
- [x] 7. Export MP4/audio with SRT/VTT/ASS captions (speaker-labeled)
|
||||||
|
- [x] 8. Speaker diarization
|
||||||
|
- [x] 9. Custom filler lists per-project
|
||||||
|
- [x] 10. Background music with auto-ducking
|
||||||
|
- [x] 11. Append clips (concatenation)
|
||||||
|
- [x] 12. Settings: AI provider config (Ollama, OpenAI, Claude)
|
||||||
|
- [x] 13. Keyboard shortcuts with custom remapping
|
||||||
|
- [x] 14. Help panel + cheatsheet
|
||||||
|
- [x] 15. 7-day licensing with Ed25519-signed license keys
|
||||||
|
|
||||||
## Recommended Additions (near-term, high ROI)
|
## Recommended Additions (near-term, high ROI)
|
||||||
- Model-size chooser + progressive fallback (start fast, upgrade model later)
|
|
||||||
- Local GPU/CPU detection & recommended model/settings UI
|
- [ ] Local GPU/CPU detection & recommended model/settings UI
|
||||||
- Per-project incremental transcription: re-run only edited segments
|
- [ ] Per-project incremental transcription: re-run only edited segments
|
||||||
- "Preview cleaning" dry-run that highlights candidate removals before applying
|
- [ ] "Preview cleaning" dry-run that highlights candidate removals before applying
|
||||||
- Export size/time estimator and suggested export presets
|
- [ ] Export size/time estimator and suggested export presets
|
||||||
- Custom filler lists per-project and import/export of filler lists
|
- [ ] Accessibility export presets (podcast vs YouTube presets)
|
||||||
- High-quality offline captions export (SRT + VTT + speaker labels)
|
- [ ] Bundled Qwen3 LLM for offline AI features
|
||||||
- Accessibility export presets (podcast vs YouTube presets)
|
|
||||||
|
|
||||||
## Remove / Defer (Back Burner)
|
## Remove / Defer (Back Burner)
|
||||||
These broaden scope or add legal/privacy surface — defer for now.
|
These broaden scope or add legal/privacy surface — defer for now.
|
||||||
|
|
||||||
- Voice cloning / TTS: DEFER
|
- Voice cloning / TTS: DEFER
|
||||||
- Multi-track, full timeline NLE features: DEFER
|
- Multi-track, full timeline NLE features: DEFER
|
||||||
- Real-time collaboration / cloud sync: DEFER
|
- Real-time collaboration / cloud sync: DEFER
|
||||||
@ -52,18 +64,20 @@ These broaden scope or add legal/privacy surface — defer for now.
|
|||||||
|
|
||||||
## Risks & Mitigations
|
## Risks & Mitigations
|
||||||
- Large model sizes: don't bundle large models; download on-demand and document storage location.
|
- Large model sizes: don't bundle large models; download on-demand and document storage location.
|
||||||
- Timestamp accuracy: provide manual word-adjust UI and per-segment re-run.
|
- Timestamp accuracy: WhisperX word-level alignment + manual per-segment re-run available.
|
||||||
- FFmpeg packaging/licensing: ship platform-specific binaries or use Tauri bundling guidance; document license compliance.
|
- FFmpeg packaging/licensing: ship platform-specific binaries or use Tauri bundling guidance; document license compliance.
|
||||||
|
|
||||||
## Prioritized Quick Wins
|
## Prioritized Quick Wins
|
||||||
1. Model chooser UI + auto-fallback settings
|
1. Per-project incremental transcription
|
||||||
2. "Preview cleaning" dry-run UI
|
2. "Preview cleaning" dry-run UI
|
||||||
3. Per-project incremental transcription saving
|
3. Export presets (podcast vs YouTube)
|
||||||
|
|
||||||
## Next Steps for Implementation
|
## Next Steps for Implementation
|
||||||
- Add model chooser UI and capability detection early in the frontend iteration.
|
- Bundle Qwen3 LLM for offline AI processing.
|
||||||
- Implement Rust transcription command and a compact API for incremental transcription.
|
- Implement incremental transcription to speed up re-editing workflows.
|
||||||
- Implement FFmpeg polish templates and a minimal preview pipeline.
|
- Add export presets and size estimation.
|
||||||
|
- Improve GPU/CPU detection and model recommendations.
|
||||||
|
|
||||||
---
|
---
|
||||||
Generated as requested to capture tech, tools, planned features, and the recommended add/remove/defer list.
|
|
||||||
|
Generated to capture tech, tools, implemented features, and the recommended add/remove/defer list.
|
||||||
|
|||||||
@ -9,6 +9,8 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
from routers import audio
|
||||||
|
|
||||||
app = FastAPI(title="TalkEdit Dev Backend", version="0.0.1")
|
app = FastAPI(title="TalkEdit Dev Backend", version="0.0.1")
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
@ -34,6 +36,8 @@ MIME_MAP = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
app.include_router(audio.router)
|
||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
async def health():
|
async def health():
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|||||||
223
backend/license_server.py
Normal file
@ -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()
|
||||||
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))
|
||||||
@ -175,30 +175,40 @@ def _remove_with_mediapipe(
|
|||||||
raise RuntimeError(f"MediaPipe background removal failed: {e}")
|
raise RuntimeError(f"MediaPipe background removal failed: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def _remove_with_ffmpeg_portrait(
|
def _remove_with_ffmpeg_portrait(
|
||||||
input_path: str,
|
input_path: str,
|
||||||
output_path: str,
|
output_path: str,
|
||||||
replacement: str = "blur",
|
replacement: str = "blur",
|
||||||
replacement_value: str = "",
|
replacement_value: str = "",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Fallback: use FFmpeg's colorkey + chromakey for basic background removal.
|
"""Fallback: basic FFmpeg-only background blur.
|
||||||
|
|
||||||
This is a crude approximation. For best results, install mediapipe + opencv-python.
|
Uses a strong gaussian blur as a crude background replacement.
|
||||||
|
For proper person segmentation (color/image replacement), install:
|
||||||
|
pip install mediapipe opencv-python
|
||||||
"""
|
"""
|
||||||
ffmpeg = "ffmpeg"
|
ffmpeg = "ffmpeg"
|
||||||
|
|
||||||
# Use a simple chromakey-based approach with a neutral background
|
if replacement == "blur":
|
||||||
# This won't work well for most real videos but provides a fallback
|
filter_complex = "gblur=sigma=30"
|
||||||
if replacement == "color":
|
elif replacement == "color":
|
||||||
color = replacement_value or "00FF00"
|
color = replacement_value or "00FF00"
|
||||||
filter_complex = f"colorkey=0x{color}:0.3:0.1,chromakey=0x{color}:0.3:0.1"
|
filter_complex = (
|
||||||
elif replacement == "blur":
|
f"split[fg][bg];"
|
||||||
filter_complex = "gblur=sigma=20:enable='gt(scene,0.01)'"
|
f"[bg]colorkey=0x{color}:0.3:0.1[bg_key];"
|
||||||
|
f"[fg][bg_key]overlay"
|
||||||
|
)
|
||||||
|
elif replacement == "image" and replacement_value:
|
||||||
|
escaped = replacement_value.replace("\\", "/").replace(":", "\\:")
|
||||||
|
filter_complex = (
|
||||||
|
f"movie='{escaped}':loop=0,scale=iw:ih[bg];"
|
||||||
|
f"[0:v][bg]overlay=0:0:shortest=1"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
filter_complex = "null"
|
filter_complex = "null"
|
||||||
|
|
||||||
if filter_complex == "null":
|
if filter_complex == "null":
|
||||||
# No-op, copy input to output
|
|
||||||
cmd = [ffmpeg, "-y", "-i", input_path, "-c", "copy", output_path]
|
cmd = [ffmpeg, "-y", "-i", input_path, "-c", "copy", output_path]
|
||||||
else:
|
else:
|
||||||
cmd = [
|
cmd = [
|
||||||
@ -215,5 +225,8 @@ def _remove_with_ffmpeg_portrait(
|
|||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
raise RuntimeError(f"FFmpeg background removal failed: {result.stderr[-500:]}")
|
raise RuntimeError(f"FFmpeg background removal failed: {result.stderr[-500:]}")
|
||||||
|
|
||||||
logger.info("FFmpeg portait background removal completed -> %s", output_path)
|
logger.warning(
|
||||||
|
"FFmpeg fallback background removal used (no MediaPipe). "
|
||||||
|
"Install 'mediapipe' and 'opencv-python' for proper person segmentation."
|
||||||
|
)
|
||||||
return output_path
|
return output_path
|
||||||
|
|||||||
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
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _input_has_audio_stream(ffmpeg_cmd: str, input_path: str) -> bool:
|
||||||
|
"""Return True if the input contains at least one audio stream."""
|
||||||
|
ffprobe = ffmpeg_cmd.replace("ffmpeg", "ffprobe")
|
||||||
|
cmd = [
|
||||||
|
ffprobe,
|
||||||
|
"-v", "error",
|
||||||
|
"-select_streams", "a:0",
|
||||||
|
"-show_entries", "stream=index",
|
||||||
|
"-of", "csv=p=0",
|
||||||
|
str(input_path),
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
return result.returncode == 0 and bool(result.stdout.strip())
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _clamp_speed(speed: float) -> float:
|
def _clamp_speed(speed: float) -> float:
|
||||||
return max(0.25, min(4.0, float(speed)))
|
return max(0.25, min(4.0, float(speed)))
|
||||||
|
|
||||||
@ -144,39 +162,65 @@ def mix_background_music(
|
|||||||
ducking_release_ms: float = 200.0,
|
ducking_release_ms: float = 200.0,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Mix background music into a video with optional ducking.
|
"""Mix background music into a video with optional ducking.
|
||||||
|
|
||||||
Uses FFmpeg amix + sidechaincompress. Output is written to output_path.
|
Uses FFmpeg amix + sidechaincompress. If the input has no audio,
|
||||||
|
the music track becomes the sole audio track. Output is written to output_path.
|
||||||
"""
|
"""
|
||||||
ffmpeg = _find_ffmpeg()
|
ffmpeg = _find_ffmpeg()
|
||||||
escaped_music = music_path.replace("\\", "/").replace(":", "\\:")
|
escaped_music = music_path.replace("\\", "/").replace(":", "\\:")
|
||||||
|
has_audio_result = _input_has_audio_stream(ffmpeg, video_path)
|
||||||
# Build the filter graph
|
|
||||||
if ducking_enabled:
|
if not has_audio_result:
|
||||||
|
cmd = [
|
||||||
|
ffmpeg, "-y",
|
||||||
|
"-i", video_path,
|
||||||
|
"-i", music_path,
|
||||||
|
"-map", "0:v",
|
||||||
|
"-map", "1:a",
|
||||||
|
"-c:v", "copy",
|
||||||
|
"-c:a", "aac", "-b:a", "192k",
|
||||||
|
"-shortest",
|
||||||
|
"-movflags", "+faststart",
|
||||||
|
output_path,
|
||||||
|
]
|
||||||
|
elif ducking_enabled:
|
||||||
|
music_source = f"amovie='{escaped_music}',volume={volume_db}dB[music]"
|
||||||
filter_complex = (
|
filter_complex = (
|
||||||
f"[0:a]asplit[main][sidechain];"
|
f"[0:a]asplit[main][sidechain];"
|
||||||
f"movie='{escaped_music}':loop=0,volume={volume_db}dB[music];"
|
f"{music_source};"
|
||||||
f"[main][music]amix=inputs=2:duration=first:dropout_transition=2[mixed];"
|
f"[main][music]amix=inputs=2:duration=first:dropout_transition=2[mixed];"
|
||||||
f"[mixed][sidechain]sidechaincompress="
|
f"[mixed][sidechain]sidechaincompress="
|
||||||
f"threshold=-30dB:ratio=100:attack={ducking_attack_ms}ms:"
|
f"threshold=-30dB:ratio=20:attack={ducking_attack_ms / 1000}:"
|
||||||
f"release={ducking_release_ms}ms:makeup=1:level_sc={ducking_db}[outa]"
|
f"release={ducking_release_ms / 1000}:makeup=1:level_sc={ducking_db}[outa]"
|
||||||
)
|
)
|
||||||
|
cmd = [
|
||||||
|
ffmpeg, "-y",
|
||||||
|
"-i", video_path,
|
||||||
|
"-filter_complex", filter_complex,
|
||||||
|
"-map", "0:v",
|
||||||
|
"-map", "[outa]",
|
||||||
|
"-c:v", "copy",
|
||||||
|
"-c:a", "aac", "-b:a", "192k",
|
||||||
|
"-shortest",
|
||||||
|
output_path,
|
||||||
|
]
|
||||||
else:
|
else:
|
||||||
|
music_source = f"amovie='{escaped_music}',volume={volume_db}dB[music]"
|
||||||
filter_complex = (
|
filter_complex = (
|
||||||
f"movie='{escaped_music}':loop=0,volume={volume_db}dB[music];"
|
f"{music_source};"
|
||||||
f"[0:a][music]amix=inputs=2:duration=first:dropout_transition=2[outa]"
|
f"[0:a][music]amix=inputs=2:duration=first:dropout_transition=2[outa]"
|
||||||
)
|
)
|
||||||
|
cmd = [
|
||||||
cmd = [
|
ffmpeg, "-y",
|
||||||
ffmpeg, "-y",
|
"-i", video_path,
|
||||||
"-i", video_path,
|
"-filter_complex", filter_complex,
|
||||||
"-filter_complex", filter_complex,
|
"-map", "0:v",
|
||||||
"-map", "0:v",
|
"-map", "[outa]",
|
||||||
"-map", "[outa]",
|
"-c:v", "copy",
|
||||||
"-c:v", "copy",
|
"-c:a", "aac", "-b:a", "192k",
|
||||||
"-c:a", "aac", "-b:a", "192k",
|
"-shortest",
|
||||||
"-shortest",
|
output_path,
|
||||||
output_path,
|
]
|
||||||
]
|
|
||||||
|
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
@ -191,28 +235,29 @@ def concat_clips(
|
|||||||
output_path: str,
|
output_path: str,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Concatenate multiple video clips using FFmpeg concat demuxer.
|
"""Concatenate multiple video clips using FFmpeg concat demuxer.
|
||||||
|
|
||||||
The main_path is kept as-is. append_paths are appended after it.
|
The main_path is kept as-is. append_paths are appended after it.
|
||||||
"""
|
"""
|
||||||
if not append_paths:
|
if not append_paths:
|
||||||
raise ValueError("No clips to concatenate")
|
raise ValueError("No clips to concatenate")
|
||||||
|
|
||||||
ffmpeg = _find_ffmpeg()
|
ffmpeg = _find_ffmpeg()
|
||||||
import tempfile
|
resolved_main = str(Path(main_path).resolve())
|
||||||
import os
|
|
||||||
|
# If output_path collides with an input, write to temp first
|
||||||
|
all_inputs = [resolved_main] + [str(Path(p).resolve()) for p in append_paths]
|
||||||
|
needs_rename = str(Path(output_path).resolve()) in all_inputs
|
||||||
|
final_output = output_path
|
||||||
|
if needs_rename:
|
||||||
|
final_output = output_path + ".concat_tmp.mp4"
|
||||||
|
|
||||||
temp_dir = tempfile.mkdtemp(prefix="aive_concat_")
|
temp_dir = tempfile.mkdtemp(prefix="aive_concat_")
|
||||||
try:
|
try:
|
||||||
segment_files = [main_path]
|
|
||||||
segment_files.extend(append_paths)
|
|
||||||
|
|
||||||
# Create concat file list
|
|
||||||
concat_file = os.path.join(temp_dir, "concat.txt")
|
concat_file = os.path.join(temp_dir, "concat.txt")
|
||||||
with open(concat_file, "w") as f:
|
with open(concat_file, "w") as f:
|
||||||
for path in segment_files:
|
for path in all_inputs:
|
||||||
resolved = os.path.abspath(path)
|
f.write(f"file '{path}'\n")
|
||||||
f.write(f"file '{resolved}'\n")
|
|
||||||
|
|
||||||
cmd = [
|
cmd = [
|
||||||
ffmpeg, "-y",
|
ffmpeg, "-y",
|
||||||
"-f", "concat",
|
"-f", "concat",
|
||||||
@ -220,13 +265,16 @@ def concat_clips(
|
|||||||
"-i", concat_file,
|
"-i", concat_file,
|
||||||
"-c", "copy",
|
"-c", "copy",
|
||||||
"-movflags", "+faststart",
|
"-movflags", "+faststart",
|
||||||
output_path,
|
final_output,
|
||||||
]
|
]
|
||||||
|
|
||||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
if result.returncode != 0:
|
if result.returncode != 0:
|
||||||
raise RuntimeError(f"Clip concat failed: {result.stderr[-500:]}")
|
raise RuntimeError(f"Clip concat failed: {result.stderr[-500:]}")
|
||||||
|
|
||||||
|
if needs_rename:
|
||||||
|
os.replace(final_output, output_path)
|
||||||
|
|
||||||
return output_path
|
return output_path
|
||||||
finally:
|
finally:
|
||||||
for f in os.listdir(temp_dir):
|
for f in os.listdir(temp_dir):
|
||||||
@ -570,11 +618,9 @@ def export_reencode(
|
|||||||
|
|
||||||
# Apply zoom post-processing if configured
|
# Apply zoom post-processing if configured
|
||||||
if zoom_config and zoom_config.get("enabled") and has_video:
|
if zoom_config and zoom_config.get("enabled") and has_video:
|
||||||
import tempfile as _tf
|
|
||||||
import os as _os
|
|
||||||
zoomed_path = output_path + ".zoomed.mp4"
|
zoomed_path = output_path + ".zoomed.mp4"
|
||||||
_apply_zoom_post(output_path, zoomed_path, zoom_config)
|
_apply_zoom_post(output_path, zoomed_path, zoom_config)
|
||||||
_os.replace(zoomed_path, output_path)
|
os.replace(zoomed_path, output_path)
|
||||||
logger.info("Zoom/punch-in applied to %s (factor=%s)", output_path, zoom_config.get("zoomFactor", 1.0))
|
logger.info("Zoom/punch-in applied to %s (factor=%s)", output_path, zoom_config.get("zoomFactor", 1.0))
|
||||||
|
|
||||||
return output_path
|
return output_path
|
||||||
@ -737,11 +783,9 @@ def export_reencode_with_subs(
|
|||||||
|
|
||||||
# Apply zoom post-processing if configured
|
# Apply zoom post-processing if configured
|
||||||
if zoom_config and zoom_config.get("enabled"):
|
if zoom_config and zoom_config.get("enabled"):
|
||||||
import tempfile as _tf
|
|
||||||
import os as _os
|
|
||||||
zoomed_path = output_path + ".zoomed.mp4"
|
zoomed_path = output_path + ".zoomed.mp4"
|
||||||
_apply_zoom_post(output_path, zoomed_path, zoom_config)
|
_apply_zoom_post(output_path, zoomed_path, zoom_config)
|
||||||
_os.replace(zoomed_path, output_path)
|
os.replace(zoomed_path, output_path)
|
||||||
logger.info("Zoom/punch-in applied to %s (factor=%s)", output_path, zoom_config.get("zoomFactor", 1.0))
|
logger.info("Zoom/punch-in applied to %s (factor=%s)", output_path, zoom_config.get("zoomFactor", 1.0))
|
||||||
|
|
||||||
return output_path
|
return output_path
|
||||||
|
|||||||
44
docs/gitea-runner-windows.md
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
# Gitea Runner — Windows Laptop
|
||||||
|
|
||||||
|
Self-hosted runner registered as `windows-laptop` with label `windows-latest`.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# Download
|
||||||
|
Invoke-WebRequest -Uri "https://gitea.com/gitea/runner/releases/download/v1.0.1/gitea-runner-1.0.1-windows-amd64.exe" -OutFile "$env:USERPROFILE\gitea-runner-windows-amd64.exe"
|
||||||
|
|
||||||
|
# Register (Admin PowerShell)
|
||||||
|
.\gitea-runner-windows-amd64.exe register --instance http://143.244.157.110:3000 --token NS5LXzLzNOvPKD9Id4SrLQ09bReHOrn6T2c4EyGM --name windows-laptop --labels windows-latest --no-interactive
|
||||||
|
|
||||||
|
# Start (foreground)
|
||||||
|
.\gitea-runner-windows-amd64.exe daemon
|
||||||
|
|
||||||
|
# Install as Windows service (auto-starts on boot)
|
||||||
|
.\gitea-runner-windows-amd64.exe service install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Logs
|
||||||
|
|
||||||
|
### Workflow job logs (step output)
|
||||||
|
|
||||||
|
Stored on the Gitea server (not locally). Download from:
|
||||||
|
`http://143.244.157.110:3000/<owner>/<repo>/actions/runs/<run_id>`
|
||||||
|
|
||||||
|
Click a job, then the **Download log** button at the top-right.
|
||||||
|
|
||||||
|
### Runner daemon logs (runner itself)
|
||||||
|
|
||||||
|
| Mode | Log location |
|
||||||
|
|---|---|
|
||||||
|
| Foreground (`daemon`) | PowerShell console stdout |
|
||||||
|
| Windows service (`service install`) | `%ProgramData%\gitea-runner\log\` or Windows Event Viewer → Windows Logs → Application |
|
||||||
|
|
||||||
|
## Diagnostics
|
||||||
|
|
||||||
|
If a CI job fails, download the full log from the Gitea Actions UI (as above), then search for the first error:
|
||||||
|
|
||||||
|
- **Rust**: look for `error[E...]`, `error: could not compile`, or `cargo test` failures
|
||||||
|
- **Python**: look for `FAILED`, `AssertionError`, or `ModuleNotFoundError`
|
||||||
|
|
||||||
|
The runner's own logs (`daemon` mode) will show which job it picked up, container lifecycle, and any infrastructure issues (disk full, Docker unavailable, etc.).
|
||||||
523
frontend/package-lock.json
generated
@ -30,6 +30,7 @@
|
|||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^17.5.0",
|
"globals": "^17.5.0",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^3.4.0",
|
||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
@ -51,6 +52,57 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@asamuzakjp/css-color": {
|
||||||
|
"version": "5.1.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz",
|
||||||
|
"integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@asamuzakjp/generational-cache": "^1.0.1",
|
||||||
|
"@csstools/css-calc": "^3.2.0",
|
||||||
|
"@csstools/css-color-parser": "^4.1.0",
|
||||||
|
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||||
|
"@csstools/css-tokenizer": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@asamuzakjp/dom-selector": {
|
||||||
|
"version": "7.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz",
|
||||||
|
"integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@asamuzakjp/generational-cache": "^1.0.1",
|
||||||
|
"@asamuzakjp/nwsapi": "^2.3.9",
|
||||||
|
"bidi-js": "^1.0.3",
|
||||||
|
"css-tree": "^3.2.1",
|
||||||
|
"is-potential-custom-element-name": "^1.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@asamuzakjp/generational-cache": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@asamuzakjp/nwsapi": {
|
||||||
|
"version": "2.3.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz",
|
||||||
|
"integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@babel/code-frame": {
|
"node_modules/@babel/code-frame": {
|
||||||
"version": "7.29.0",
|
"version": "7.29.0",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||||
@ -333,6 +385,159 @@
|
|||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@bramus/specificity": {
|
||||||
|
"version": "2.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz",
|
||||||
|
"integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"css-tree": "^3.0.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"specificity": "bin/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/color-helpers": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT-0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/css-calc": {
|
||||||
|
"version": "3.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz",
|
||||||
|
"integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||||
|
"@csstools/css-tokenizer": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/css-color-parser": {
|
||||||
|
"version": "4.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz",
|
||||||
|
"integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@csstools/color-helpers": "^6.0.2",
|
||||||
|
"@csstools/css-calc": "^3.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@csstools/css-parser-algorithms": "^4.0.0",
|
||||||
|
"@csstools/css-tokenizer": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/css-parser-algorithms": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@csstools/css-tokenizer": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/css-syntax-patches-for-csstree": {
|
||||||
|
"version": "1.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz",
|
||||||
|
"integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT-0",
|
||||||
|
"peerDependencies": {
|
||||||
|
"css-tree": "^3.2.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"css-tree": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@csstools/css-tokenizer": {
|
||||||
|
"version": "4.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz",
|
||||||
|
"integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==",
|
||||||
|
"dev": true,
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/csstools"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/csstools"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@esbuild/aix-ppc64": {
|
"node_modules/@esbuild/aix-ppc64": {
|
||||||
"version": "0.25.12",
|
"version": "0.25.12",
|
||||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
|
||||||
@ -932,6 +1137,24 @@
|
|||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@exodus/bytes": {
|
||||||
|
"version": "1.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.0.tgz",
|
||||||
|
"integrity": "sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@noble/hashes": "^1.8.0 || ^2.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@noble/hashes": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@humanfs/core": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.1",
|
"version": "0.19.1",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||||
@ -2365,6 +2588,16 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/bidi-js": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"require-from-string": "^2.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/binary-extensions": {
|
"node_modules/binary-extensions": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||||
@ -2601,6 +2834,20 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/css-tree": {
|
||||||
|
"version": "3.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz",
|
||||||
|
"integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"mdn-data": "2.27.1",
|
||||||
|
"source-map-js": "^1.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cssesc": {
|
"node_modules/cssesc": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
|
||||||
@ -2621,6 +2868,20 @@
|
|||||||
"devOptional": true,
|
"devOptional": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/data-urls": {
|
||||||
|
"version": "7.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz",
|
||||||
|
"integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"whatwg-mimetype": "^5.0.0",
|
||||||
|
"whatwg-url": "^16.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/debug": {
|
"node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
@ -2639,6 +2900,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/decimal.js": {
|
||||||
|
"version": "10.6.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
|
||||||
|
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/deep-is": {
|
"node_modules/deep-is": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||||
@ -2667,6 +2935,19 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/entities": {
|
||||||
|
"version": "8.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
|
||||||
|
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.19.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/es-module-lexer": {
|
"node_modules/es-module-lexer": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.0.0.tgz",
|
||||||
@ -3183,6 +3464,19 @@
|
|||||||
"hermes-estree": "0.25.1"
|
"hermes-estree": "0.25.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/html-encoding-sniffer": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@exodus/bytes": "^1.6.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ignore": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
@ -3282,6 +3576,13 @@
|
|||||||
"node": ">=0.12.0"
|
"node": ">=0.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-potential-custom-element-name": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/isexe": {
|
"node_modules/isexe": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||||
@ -3319,6 +3620,57 @@
|
|||||||
"js-yaml": "bin/js-yaml.js"
|
"js-yaml": "bin/js-yaml.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/jsdom": {
|
||||||
|
"version": "29.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz",
|
||||||
|
"integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@asamuzakjp/css-color": "^5.1.11",
|
||||||
|
"@asamuzakjp/dom-selector": "^7.1.1",
|
||||||
|
"@bramus/specificity": "^2.4.2",
|
||||||
|
"@csstools/css-syntax-patches-for-csstree": "^1.1.3",
|
||||||
|
"@exodus/bytes": "^1.15.0",
|
||||||
|
"css-tree": "^3.2.1",
|
||||||
|
"data-urls": "^7.0.0",
|
||||||
|
"decimal.js": "^10.6.0",
|
||||||
|
"html-encoding-sniffer": "^6.0.0",
|
||||||
|
"is-potential-custom-element-name": "^1.0.1",
|
||||||
|
"lru-cache": "^11.3.5",
|
||||||
|
"parse5": "^8.0.1",
|
||||||
|
"saxes": "^6.0.0",
|
||||||
|
"symbol-tree": "^3.2.4",
|
||||||
|
"tough-cookie": "^6.0.1",
|
||||||
|
"undici": "^7.25.0",
|
||||||
|
"w3c-xmlserializer": "^5.0.0",
|
||||||
|
"webidl-conversions": "^8.0.1",
|
||||||
|
"whatwg-mimetype": "^5.0.0",
|
||||||
|
"whatwg-url": "^16.0.1",
|
||||||
|
"xml-name-validator": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.13.0 || >=24.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"canvas": "^3.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"canvas": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/jsdom/node_modules/lru-cache": {
|
||||||
|
"version": "11.3.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.6.tgz",
|
||||||
|
"integrity": "sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BlueOak-1.0.0",
|
||||||
|
"engines": {
|
||||||
|
"node": "20 || >=22"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/jsesc": {
|
"node_modules/jsesc": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
|
||||||
@ -3462,6 +3814,13 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.5.5"
|
"@jridgewell/sourcemap-codec": "^1.5.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mdn-data": {
|
||||||
|
"version": "2.27.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz",
|
||||||
|
"integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "CC0-1.0"
|
||||||
|
},
|
||||||
"node_modules/merge2": {
|
"node_modules/merge2": {
|
||||||
"version": "1.4.1",
|
"version": "1.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz",
|
||||||
@ -3655,6 +4014,19 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/parse5": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"entities": "^8.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/inikulin/parse5?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/path-exists": {
|
"node_modules/path-exists": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||||
@ -3997,6 +4369,16 @@
|
|||||||
"node": ">=8.10.0"
|
"node": ">=8.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/require-from-string": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/resolve": {
|
"node_modules/resolve": {
|
||||||
"version": "1.22.11",
|
"version": "1.22.11",
|
||||||
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz",
|
||||||
@ -4108,6 +4490,19 @@
|
|||||||
"queue-microtask": "^1.2.2"
|
"queue-microtask": "^1.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/saxes": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"xmlchars": "^2.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=v12.22.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/scheduler": {
|
"node_modules/scheduler": {
|
||||||
"version": "0.27.0",
|
"version": "0.27.0",
|
||||||
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
|
||||||
@ -4240,6 +4635,13 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/symbol-tree": {
|
||||||
|
"version": "3.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
|
||||||
|
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tailwindcss": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "3.4.19",
|
"version": "3.4.19",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||||
@ -4376,6 +4778,26 @@
|
|||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tldts": {
|
||||||
|
"version": "7.0.30",
|
||||||
|
"resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.30.tgz",
|
||||||
|
"integrity": "sha512-ELrFxuqsDdHUwoh0XxDbxuLD3Wnz49Z57IFvTtvWy1hJdcMZjXLIuonjilCiWHlT2GbE4Wlv1wKVTzDFnXH1aw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"tldts-core": "^7.0.30"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"tldts": "bin/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tldts-core": {
|
||||||
|
"version": "7.0.30",
|
||||||
|
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.30.tgz",
|
||||||
|
"integrity": "sha512-uiHN8PIB1VmWyS98eZYja4xzlYqeFZVjb4OuYlJQnZAuJhMw4PbKQOKgHKhBdJR3FE/t5mUQ1Kd80++B+qhD1Q==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/to-regex-range": {
|
"node_modules/to-regex-range": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
|
||||||
@ -4389,6 +4811,32 @@
|
|||||||
"node": ">=8.0"
|
"node": ">=8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tough-cookie": {
|
||||||
|
"version": "6.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz",
|
||||||
|
"integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"tldts": "^7.0.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=16"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/tr46": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"punycode": "^2.3.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ts-api-utils": {
|
"node_modules/ts-api-utils": {
|
||||||
"version": "2.5.0",
|
"version": "2.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz",
|
||||||
@ -4460,6 +4908,16 @@
|
|||||||
"typescript": ">=4.8.4 <6.1.0"
|
"typescript": ">=4.8.4 <6.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/undici": {
|
||||||
|
"version": "7.25.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz",
|
||||||
|
"integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20.18.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/update-browserslist-db": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||||
@ -4717,12 +5175,60 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/w3c-xmlserializer": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"xml-name-validator": "^5.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/wavesurfer.js": {
|
"node_modules/wavesurfer.js": {
|
||||||
"version": "7.12.1",
|
"version": "7.12.1",
|
||||||
"resolved": "https://registry.npmjs.org/wavesurfer.js/-/wavesurfer.js-7.12.1.tgz",
|
"resolved": "https://registry.npmjs.org/wavesurfer.js/-/wavesurfer.js-7.12.1.tgz",
|
||||||
"integrity": "sha512-NswPjVHxk0Q1F/VMRemCPUzSojjuHHisQrBqQiRXg7MVbe3f5vQ6r0rTTXA/a/neC/4hnOEC4YpXca4LpH0SUg==",
|
"integrity": "sha512-NswPjVHxk0Q1F/VMRemCPUzSojjuHHisQrBqQiRXg7MVbe3f5vQ6r0rTTXA/a/neC/4hnOEC4YpXca4LpH0SUg==",
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/webidl-conversions": {
|
||||||
|
"version": "8.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz",
|
||||||
|
"integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "BSD-2-Clause",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-mimetype": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/whatwg-url": {
|
||||||
|
"version": "16.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz",
|
||||||
|
"integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@exodus/bytes": "^1.11.0",
|
||||||
|
"tr46": "^6.0.0",
|
||||||
|
"webidl-conversions": "^8.0.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||||
@ -4766,6 +5272,23 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xml-name-validator": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/xmlchars": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/yallist": {
|
"node_modules/yallist": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||||
|
|||||||
@ -33,6 +33,7 @@
|
|||||||
"eslint-plugin-react-hooks": "^7.0.1",
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
"eslint-plugin-react-refresh": "^0.5.2",
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
"globals": "^17.5.0",
|
"globals": "^17.5.0",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
"postcss": "^8.4.49",
|
"postcss": "^8.4.49",
|
||||||
"tailwindcss": "^3.4.0",
|
"tailwindcss": "^3.4.0",
|
||||||
"typescript": "^5.7.0",
|
"typescript": "^5.7.0",
|
||||||
|
|||||||
@ -12,9 +12,11 @@ import SilenceTrimmerPanel from './components/SilenceTrimmerPanel';
|
|||||||
import ZoneEditor from './components/ZoneEditor';
|
import ZoneEditor from './components/ZoneEditor';
|
||||||
import BackgroundMusicPanel from './components/BackgroundMusicPanel';
|
import BackgroundMusicPanel from './components/BackgroundMusicPanel';
|
||||||
import AppendClipPanel from './components/AppendClipPanel';
|
import AppendClipPanel from './components/AppendClipPanel';
|
||||||
|
import LicenseDialog from './components/LicenseDialog';
|
||||||
|
import HelpContent from './components/HelpContent';
|
||||||
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
|
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
|
||||||
|
import { useLicenseStore } from './store/licenseStore';
|
||||||
import {
|
import {
|
||||||
Film,
|
|
||||||
FolderOpen,
|
FolderOpen,
|
||||||
Settings,
|
Settings,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
@ -31,44 +33,48 @@ import {
|
|||||||
MapPin,
|
MapPin,
|
||||||
Music,
|
Music,
|
||||||
ListVideo,
|
ListVideo,
|
||||||
|
Clock,
|
||||||
|
AlertTriangle,
|
||||||
|
HelpCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const LAST_MEDIA_PATH_KEY = 'talkedit:lastMediaPath';
|
const LAST_MEDIA_PATH_KEY = 'talkedit:lastMediaPath';
|
||||||
|
|
||||||
type Panel = 'ai' | 'settings' | 'export' | 'silence' | 'zones' | 'markers' | 'music' | 'append' | null;
|
type Panel = 'ai' | 'settings' | 'export' | 'silence' | 'zones' | 'markers' | 'music' | 'append' | 'help' | null;
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const {
|
const {
|
||||||
projectFilePath,
|
projectFilePath,
|
||||||
videoPath,
|
videoPath,
|
||||||
exportedAudioPath,
|
exportedAudioPath,
|
||||||
words,
|
words,
|
||||||
segments,
|
segments,
|
||||||
cutRanges,
|
cutRanges,
|
||||||
muteRanges,
|
muteRanges,
|
||||||
gainRanges,
|
gainRanges,
|
||||||
speedRanges,
|
speedRanges,
|
||||||
globalGainDb,
|
globalGainDb,
|
||||||
silenceTrimGroups,
|
silenceTrimGroups,
|
||||||
transcriptionModel,
|
transcriptionModel,
|
||||||
language,
|
language,
|
||||||
isTranscribing,
|
isTranscribing,
|
||||||
transcriptionStatus,
|
transcriptionStatus,
|
||||||
markInTime,
|
markInTime,
|
||||||
markOutTime,
|
markOutTime,
|
||||||
loadVideo,
|
loadVideo,
|
||||||
setProjectFilePath,
|
setProjectFilePath,
|
||||||
setBackendUrl,
|
setBackendUrl,
|
||||||
clearMarkRange,
|
clearMarkRange,
|
||||||
setTranscription,
|
setTranscription,
|
||||||
setTranscriptionModel,
|
setTranscriptionModel,
|
||||||
setTranscribing,
|
setTranscribing,
|
||||||
selectedWordIndices,
|
selectedWordIndices,
|
||||||
addCutRange,
|
addCutRange,
|
||||||
addMuteRange,
|
addMuteRange,
|
||||||
addGainRange,
|
addGainRange,
|
||||||
addSpeedRange,
|
addSpeedRange,
|
||||||
} = useEditorStore();
|
backendUrl,
|
||||||
|
} = useEditorStore();
|
||||||
|
|
||||||
const [activePanel, setActivePanel] = useState<Panel>(null);
|
const [activePanel, setActivePanel] = useState<Panel>(null);
|
||||||
const [projectName, setProjectName] = useState<string | null>(null);
|
const [projectName, setProjectName] = useState<string | null>(null);
|
||||||
@ -132,6 +138,14 @@ export default function App() {
|
|||||||
const [showUnsavedPrompt, setShowUnsavedPrompt] = useState(false);
|
const [showUnsavedPrompt, setShowUnsavedPrompt] = useState(false);
|
||||||
const [pendingProceedAction, setPendingProceedAction] = useState<(() => Promise<void>) | null>(null);
|
const [pendingProceedAction, setPendingProceedAction] = useState<(() => Promise<void>) | null>(null);
|
||||||
const [lastSavedSignature, setLastSavedSignature] = useState<string | null>(null);
|
const [lastSavedSignature, setLastSavedSignature] = useState<string | null>(null);
|
||||||
|
const [showFileMenu, setShowFileMenu] = useState(false);
|
||||||
|
const [showRecoveryDialog, setShowRecoveryDialog] = useState(false);
|
||||||
|
const [recoveryData, setRecoveryData] = useState<any>(null);
|
||||||
|
const [recoveryMinutesAgo, setRecoveryMinutesAgo] = useState(0);
|
||||||
|
const [backendDown, setBackendDown] = useState(false);
|
||||||
|
const canEdit = useLicenseStore((s) => s.canEdit);
|
||||||
|
const licenseStatus = useLicenseStore((s) => s.status);
|
||||||
|
const setShowLicenseDialog = useLicenseStore((s) => s.setShowDialog);
|
||||||
|
|
||||||
const projectSignature = useMemo(() => {
|
const projectSignature = useMemo(() => {
|
||||||
if (!videoPath) return null;
|
if (!videoPath) return null;
|
||||||
@ -167,7 +181,10 @@ export default function App() {
|
|||||||
const hasUnsavedChanges = Boolean(projectSignature) && projectSignature !== lastSavedSignature;
|
const hasUnsavedChanges = Boolean(projectSignature) && projectSignature !== lastSavedSignature;
|
||||||
|
|
||||||
const loadProjectFromData = (data: any) => {
|
const loadProjectFromData = (data: any) => {
|
||||||
useEditorStore.getState().loadProject(data);
|
const removedCount = useEditorStore.getState().loadProject(data);
|
||||||
|
if (removedCount > 0) {
|
||||||
|
window.alert(`${removedCount} invalid zones were removed from the loaded project.`);
|
||||||
|
}
|
||||||
const loadedSignature = JSON.stringify({
|
const loadedSignature = JSON.stringify({
|
||||||
videoPath: data.videoPath,
|
videoPath: data.videoPath,
|
||||||
exportedAudioPath: data.exportedAudioPath ?? null,
|
exportedAudioPath: data.exportedAudioPath ?? null,
|
||||||
@ -196,7 +213,23 @@ export default function App() {
|
|||||||
|
|
||||||
useKeyboardShortcuts();
|
useKeyboardShortcuts();
|
||||||
|
|
||||||
// Handle Escape key to exit timeline zone modes
|
useEffect(() => {
|
||||||
|
useLicenseStore.getState().checkStatus();
|
||||||
|
window.electronAPI?.readAutosave().then((data) => {
|
||||||
|
if (data) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(data);
|
||||||
|
const savedAt = parsed.savedAt;
|
||||||
|
const minutesAgo = savedAt ? Math.round((Date.now() - savedAt) / 60000) : 0;
|
||||||
|
setRecoveryData(parsed);
|
||||||
|
setRecoveryMinutesAgo(minutesAgo);
|
||||||
|
setShowRecoveryDialog(true);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Handle Escape key to exit timeline zone modes and close menus
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleKeyDown = (e: KeyboardEvent) => {
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
@ -204,9 +237,9 @@ export default function App() {
|
|||||||
setMuteMode(false);
|
setMuteMode(false);
|
||||||
setGainMode(false);
|
setGainMode(false);
|
||||||
setSpeedMode(false);
|
setSpeedMode(false);
|
||||||
|
setShowFileMenu(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('keydown', handleKeyDown);
|
window.addEventListener('keydown', handleKeyDown);
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||||
}, []);
|
}, []);
|
||||||
@ -231,6 +264,36 @@ export default function App() {
|
|||||||
sessionStorage.removeItem(LAST_MEDIA_PATH_KEY);
|
sessionStorage.removeItem(LAST_MEDIA_PATH_KEY);
|
||||||
}, [videoPath]);
|
}, [videoPath]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!videoPath) return;
|
||||||
|
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
const state = useEditorStore.getState();
|
||||||
|
const hasData = state.words.length > 0 || state.cutRanges.length > 0 || state.muteRanges.length > 0 || state.gainRanges.length > 0 || state.speedRanges.length > 0;
|
||||||
|
if (!hasData) return;
|
||||||
|
const autosaveData = {
|
||||||
|
savedAt: Date.now(),
|
||||||
|
videoPath: state.videoPath,
|
||||||
|
words: state.words,
|
||||||
|
segments: state.segments,
|
||||||
|
cutRanges: state.cutRanges,
|
||||||
|
muteRanges: state.muteRanges,
|
||||||
|
gainRanges: state.gainRanges,
|
||||||
|
speedRanges: state.speedRanges,
|
||||||
|
globalGainDb: state.globalGainDb,
|
||||||
|
silenceTrimGroups: state.silenceTrimGroups,
|
||||||
|
transcriptionModel: state.transcriptionModel,
|
||||||
|
language: state.language,
|
||||||
|
markInTime: state.markInTime,
|
||||||
|
markOutTime: state.markOutTime,
|
||||||
|
timelineMarkers: state.timelineMarkers,
|
||||||
|
};
|
||||||
|
window.electronAPI.writeAutosave(JSON.stringify(autosaveData));
|
||||||
|
}, 60000);
|
||||||
|
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [videoPath]);
|
||||||
|
|
||||||
const handleLoadProject = async () => {
|
const handleLoadProject = async () => {
|
||||||
await runGuarded(async () => {
|
await runGuarded(async () => {
|
||||||
try {
|
try {
|
||||||
@ -287,7 +350,9 @@ export default function App() {
|
|||||||
setProjectFilePath(null);
|
setProjectFilePath(null);
|
||||||
setProjectName(null);
|
setProjectName(null);
|
||||||
loadVideo(path);
|
loadVideo(path);
|
||||||
await transcribeVideo(path);
|
if (canEdit) {
|
||||||
|
await transcribeVideo(path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@ -385,6 +450,26 @@ export default function App() {
|
|||||||
setPendingProceedAction(null);
|
setPendingProceedAction(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleRecoverAutosave = () => {
|
||||||
|
if (!recoveryData) return;
|
||||||
|
const data = recoveryData;
|
||||||
|
const removedCount = useEditorStore.getState().loadProject(data);
|
||||||
|
if (removedCount > 0) {
|
||||||
|
window.alert(`${removedCount} invalid zones were removed from the loaded project.`);
|
||||||
|
}
|
||||||
|
if (data.markInTime != null) useEditorStore.getState().setMarkInTime(data.markInTime);
|
||||||
|
if (data.markOutTime != null) useEditorStore.getState().setMarkOutTime(data.markOutTime);
|
||||||
|
window.electronAPI.deleteAutosave();
|
||||||
|
setShowRecoveryDialog(false);
|
||||||
|
setRecoveryData(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDismissRecovery = () => {
|
||||||
|
window.electronAPI.deleteAutosave();
|
||||||
|
setShowRecoveryDialog(false);
|
||||||
|
setRecoveryData(null);
|
||||||
|
};
|
||||||
|
|
||||||
const togglePanel = (panel: Panel) => {
|
const togglePanel = (panel: Panel) => {
|
||||||
setActivePanel((prev) => (prev === panel ? null : panel));
|
setActivePanel((prev) => (prev === panel ? null : panel));
|
||||||
};
|
};
|
||||||
@ -493,234 +578,161 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!videoPath) {
|
// Health check timer
|
||||||
return (
|
useEffect(() => {
|
||||||
<div className="h-screen flex flex-col items-center justify-center gap-8 bg-editor-bg px-6">
|
const checkHealth = async () => {
|
||||||
<div className="flex flex-col items-center gap-3">
|
try {
|
||||||
<Film className="w-14 h-14 text-editor-accent opacity-80" />
|
const res = await fetch(`${backendUrl}/health`);
|
||||||
<h1 className="text-3xl font-semibold tracking-tight">TalkEdit</h1>
|
setBackendDown(!res.ok);
|
||||||
<p className="text-editor-text-muted text-sm max-w-sm text-center">
|
} catch {
|
||||||
Offline AI-powered video editor.
|
setBackendDown(true);
|
||||||
</p>
|
}
|
||||||
</div>
|
};
|
||||||
|
checkHealth();
|
||||||
{/* Whisper model selector */}
|
const interval = setInterval(checkHealth, 30000);
|
||||||
<div className="flex items-center gap-3">
|
return () => clearInterval(interval);
|
||||||
<label className="text-xs text-editor-text-muted whitespace-nowrap">Model:</label>
|
}, [videoPath, backendUrl]);
|
||||||
<select
|
|
||||||
value={whisperModel}
|
|
||||||
onChange={(e) => setWhisperModel(e.target.value)}
|
|
||||||
className="px-3 py-1.5 bg-editor-surface border border-editor-border rounded-lg text-xs text-black focus:outline-none focus:border-editor-accent"
|
|
||||||
>
|
|
||||||
<optgroup label="Multilingual (any language)">
|
|
||||||
<option value="tiny">tiny — ~75 MB · fastest, low accuracy</option>
|
|
||||||
<option value="base">base — ~140 MB · fast, decent accuracy</option>
|
|
||||||
<option value="small">small — ~460 MB · good balance</option>
|
|
||||||
<option value="medium">medium — ~1.5 GB · better accuracy</option>
|
|
||||||
<option value="large-v2">large-v2 — ~2.9 GB · high accuracy</option>
|
|
||||||
<option value="large-v3">large-v3 — ~2.9 GB · best overall ★</option>
|
|
||||||
<option value="large-v3-turbo">large-v3-turbo — ~1.6 GB · fast + accurate ★</option>
|
|
||||||
<option value="distil-large-v3">distil-large-v3 — ~1.5 GB · fast, near large-v3 quality</option>
|
|
||||||
</optgroup>
|
|
||||||
<optgroup label="English-only (faster & more accurate for English)">
|
|
||||||
<option value="tiny.en">tiny.en — ~75 MB · fastest English</option>
|
|
||||||
<option value="base.en">base.en — ~140 MB · fast English</option>
|
|
||||||
<option value="small.en">small.en — ~460 MB · good English</option>
|
|
||||||
<option value="medium.en">medium.en — ~1.5 GB · great English</option>
|
|
||||||
<option value="distil-small.en">distil-small.en — ~190 MB · fast English ★</option>
|
|
||||||
<option value="distil-medium.en">distil-medium.en — ~750 MB · best fast English ★</option>
|
|
||||||
</optgroup>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<p className="text-[11px] text-editor-text-muted text-center max-w-sm">
|
|
||||||
For noisy/YouTube videos use <span className="text-white">large-v3</span> or <span className="text-white">large-v3-turbo</span>.
|
|
||||||
English-only models are ~10% faster and more accurate for English content.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-3">
|
|
||||||
<button
|
|
||||||
onClick={handleOpenFile}
|
|
||||||
className="flex items-center gap-2 px-6 py-3 bg-editor-accent hover:bg-editor-accent-hover rounded-lg text-white font-medium transition-colors"
|
|
||||||
>
|
|
||||||
<FolderOpen className="w-5 h-5" />
|
|
||||||
Open Video File
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={handleLoadProject}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 text-sm text-editor-text-muted hover:text-editor-text hover:bg-editor-surface rounded-lg transition-colors"
|
|
||||||
>
|
|
||||||
<FileInput className="w-4 h-4" />
|
|
||||||
Load Project (.aive)
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex flex-col bg-editor-bg overflow-hidden">
|
<div className="h-screen flex flex-col bg-editor-bg overflow-hidden">
|
||||||
{/* Top bar */}
|
{/* Top bar */}
|
||||||
<header className="h-12 flex items-center px-4 border-b border-editor-border shrink-0">
|
<header className="h-12 flex items-center px-4 border-b border-editor-border shrink-0">
|
||||||
<div className="flex items-center gap-0.5">
|
<div className="flex items-center gap-0.5">
|
||||||
<ToolbarButton
|
<div className="relative">
|
||||||
icon={<FilePlus2 className="w-4 h-4" />}
|
|
||||||
label="New"
|
|
||||||
onClick={handleNewProject}
|
|
||||||
/>
|
|
||||||
<ToolbarButton
|
|
||||||
icon={<FolderOpen className="w-4 h-4" />}
|
|
||||||
label="Open"
|
|
||||||
onClick={handleOpenFile}
|
|
||||||
/>
|
|
||||||
<ToolbarButton
|
|
||||||
icon={<Save className="w-4 h-4" />}
|
|
||||||
label="Save"
|
|
||||||
onClick={handleSaveProject}
|
|
||||||
disabled={words.length === 0}
|
|
||||||
/>
|
|
||||||
<ToolbarButton
|
|
||||||
icon={<Save className="w-4 h-4" />}
|
|
||||||
label="Save As"
|
|
||||||
onClick={handleSaveProjectAs}
|
|
||||||
disabled={words.length === 0}
|
|
||||||
/>
|
|
||||||
<ToolbarButton
|
|
||||||
icon={<FileInput className="w-4 h-4" />}
|
|
||||||
label="Load"
|
|
||||||
onClick={handleLoadProject}
|
|
||||||
/>
|
|
||||||
<ToolbarButton
|
|
||||||
icon={<Scissors className="w-4 h-4" />}
|
|
||||||
label="Cut"
|
|
||||||
onClick={handleCut}
|
|
||||||
active={cutMode}
|
|
||||||
/>
|
|
||||||
<ToolbarButton
|
|
||||||
icon={<VolumeX className="w-4 h-4" />}
|
|
||||||
label="Mute"
|
|
||||||
onClick={handleMute}
|
|
||||||
active={muteMode}
|
|
||||||
/>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
icon={<SlidersHorizontal className="w-4 h-4" />}
|
icon={<FolderOpen className="w-4 h-4" />}
|
||||||
label="Gain Zone"
|
label="File"
|
||||||
onClick={handleGain}
|
onClick={() => setShowFileMenu((p) => !p)}
|
||||||
active={gainMode}
|
active={showFileMenu}
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min={-24}
|
|
||||||
max={24}
|
|
||||||
step={0.5}
|
|
||||||
value={gainModeDb}
|
|
||||||
onChange={(e) => setGainModeDb(Math.max(-24, Math.min(24, Number(e.target.value) || 0)))}
|
|
||||||
className="w-16 px-1.5 py-1 text-xs bg-editor-surface border border-editor-border rounded text-editor-text focus:outline-none focus:border-editor-accent"
|
|
||||||
title="Gain dB for new gain zones"
|
|
||||||
/>
|
/>
|
||||||
|
{showFileMenu && (
|
||||||
|
<>
|
||||||
|
<div className="fixed inset-0 z-40" onClick={() => setShowFileMenu(false)} />
|
||||||
|
<div className="absolute left-0 top-full mt-1 z-50 w-44 rounded-lg border border-editor-border bg-editor-surface shadow-xl py-1">
|
||||||
|
<DropdownItem icon={<FilePlus2 className="w-4 h-4" />} label="New Project" title="Start a new empty project" onClick={() => { setShowFileMenu(false); handleNewProject(); }} />
|
||||||
|
<DropdownItem icon={<FolderOpen className="w-4 h-4" />} label="Open File" title="Open a video or audio file for transcription" onClick={() => { setShowFileMenu(false); handleOpenFile(); }} />
|
||||||
|
<DropdownItem icon={<FileInput className="w-4 h-4" />} label="Load Project" title="Open a saved .aive project file" onClick={() => { setShowFileMenu(false); handleLoadProject(); }} />
|
||||||
|
<div className="border-t border-editor-border my-1" />
|
||||||
|
<DropdownItem icon={<Save className="w-4 h-4" />} label="Save" title="Save current project" onClick={() => { setShowFileMenu(false); handleSaveProject(); }} disabled={words.length === 0} />
|
||||||
|
<DropdownItem icon={<Save className="w-4 h-4" />} label="Save As" title="Save a copy of the current project" onClick={() => { setShowFileMenu(false); handleSaveProjectAs(); }} disabled={words.length === 0} />
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
icon={<Gauge className="w-4 h-4" />}
|
icon={<Scissors className="w-4 h-4" />}
|
||||||
label="Speed Zone"
|
label="Cut"
|
||||||
onClick={handleSpeed}
|
onClick={handleCut}
|
||||||
active={speedMode}
|
active={cutMode}
|
||||||
|
disabled={!canEdit}
|
||||||
|
title="Cut selected word range or mark in/out area — removes the segment from output"
|
||||||
/>
|
/>
|
||||||
<input
|
<ToolbarButton
|
||||||
type="number"
|
icon={<VolumeX className="w-4 h-4" />}
|
||||||
min={0.25}
|
label="Mute"
|
||||||
max={4}
|
onClick={handleMute}
|
||||||
step={0.05}
|
active={muteMode}
|
||||||
value={speedModeValue}
|
disabled={!canEdit}
|
||||||
onChange={(e) => setSpeedModeValue(Math.max(0.25, Math.min(4, Number(e.target.value) || 1)))}
|
title="Mute selected word range or mark in/out area — silences audio, keeps video"
|
||||||
className="w-16 px-1.5 py-1 text-xs bg-editor-surface border border-editor-border rounded text-editor-text focus:outline-none focus:border-editor-accent"
|
|
||||||
title="Playback rate for new speed zones"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
<div className="flex items-center gap-1">
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
icon={<Grid3x3 className="w-4 h-4" />}
|
icon={<SlidersHorizontal className="w-4 h-4" />}
|
||||||
label="Zones"
|
label="Sound Gain"
|
||||||
active={activePanel === 'zones'}
|
onClick={handleGain}
|
||||||
onClick={() => togglePanel('zones')}
|
active={gainMode}
|
||||||
disabled={!videoPath}
|
disabled={!canEdit}
|
||||||
/>
|
title="Add gain zone from selection or mark in/out — adjust volume up or down"
|
||||||
<ToolbarButton
|
/>
|
||||||
icon={<span className="text-[10px] font-semibold">PA</span>}
|
<input
|
||||||
label="Pause Trim"
|
type="number"
|
||||||
active={activePanel === 'silence'}
|
min={-24}
|
||||||
onClick={() => togglePanel('silence')}
|
max={24}
|
||||||
disabled={!videoPath}
|
step={0.5}
|
||||||
/>
|
value={gainModeDb}
|
||||||
<ToolbarButton
|
onChange={(e) => setGainModeDb(Math.max(-24, Math.min(24, Number(e.target.value) || 0)))}
|
||||||
icon={<MapPin className="w-4 h-4" />}
|
className="w-16 px-1.5 py-1 text-xs bg-editor-surface border border-editor-border rounded text-editor-text focus:outline-none focus:border-editor-accent"
|
||||||
label="Markers"
|
title="Volume adjustment in decibels for new gain zones — positive boosts, negative reduces"
|
||||||
active={activePanel === 'markers'}
|
disabled={!canEdit}
|
||||||
onClick={() => togglePanel('markers')}
|
/>
|
||||||
disabled={!videoPath}
|
</div>
|
||||||
/>
|
<div className="flex items-center gap-1">
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
icon={<Music className="w-4 h-4" />}
|
icon={<Gauge className="w-4 h-4" />}
|
||||||
label="Music"
|
label="Speed Adjust"
|
||||||
active={activePanel === 'music'}
|
onClick={handleSpeed}
|
||||||
onClick={() => togglePanel('music')}
|
active={speedMode}
|
||||||
disabled={!videoPath}
|
disabled={!canEdit}
|
||||||
/>
|
title="Add speed zone from selection or mark in/out — change playback speed"
|
||||||
<ToolbarButton
|
/>
|
||||||
icon={<ListVideo className="w-4 h-4" />}
|
<input
|
||||||
label="Append"
|
type="number"
|
||||||
active={activePanel === 'append'}
|
min={0.25}
|
||||||
onClick={() => togglePanel('append')}
|
max={4}
|
||||||
disabled={!videoPath}
|
step={0.05}
|
||||||
/>
|
value={speedModeValue}
|
||||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-editor-surface border border-editor-border">
|
onChange={(e) => setSpeedModeValue(Math.max(0.25, Math.min(4, Number(e.target.value) || 1)))}
|
||||||
<select
|
className="w-16 px-1.5 py-1 text-xs bg-editor-surface border border-editor-border rounded text-editor-text focus:outline-none focus:border-editor-accent"
|
||||||
value={whisperModel}
|
title="Playback speed multiplier for new speed zones — 1x is normal, 2x is double speed"
|
||||||
onChange={(e) => setWhisperModel(e.target.value)}
|
disabled={!canEdit}
|
||||||
className="bg-editor-surface text-xs text-editor-text focus:outline-none [color-scheme:dark]"
|
/>
|
||||||
title="Transcription model"
|
</div>
|
||||||
>
|
<div className="w-px h-5 bg-editor-border mx-1" />
|
||||||
<optgroup label="Multilingual">
|
<ToolbarButton
|
||||||
<option value="tiny">tiny</option>
|
icon={<Grid3x3 className="w-4 h-4" />}
|
||||||
<option value="base">base</option>
|
label="Edit Zones"
|
||||||
<option value="small">small</option>
|
active={activePanel === 'zones'}
|
||||||
<option value="medium">medium</option>
|
onClick={() => togglePanel('zones')}
|
||||||
<option value="large-v2">large-v2</option>
|
disabled={!videoPath || !canEdit}
|
||||||
<option value="large-v3">large-v3</option>
|
title="Open zone editor panel — view and manage all cut, mute, gain, and speed zones"
|
||||||
<option value="large-v3-turbo">large-v3-turbo</option>
|
/>
|
||||||
<option value="distil-large-v3">distil-large-v3</option>
|
<ToolbarButton
|
||||||
</optgroup>
|
icon={<span className="text-[10px] font-semibold">PA</span>}
|
||||||
<optgroup label="English">
|
label="Trim Silence"
|
||||||
<option value="tiny.en">tiny.en</option>
|
active={activePanel === 'silence'}
|
||||||
<option value="base.en">base.en</option>
|
onClick={() => togglePanel('silence')}
|
||||||
<option value="small.en">small.en</option>
|
disabled={!videoPath || !canEdit}
|
||||||
<option value="medium.en">medium.en</option>
|
title="Detect and remove silent pauses — batch-removes silence above a configurable threshold"
|
||||||
<option value="distil-small.en">distil-small.en</option>
|
/>
|
||||||
<option value="distil-medium.en">distil-medium.en</option>
|
<ToolbarButton
|
||||||
</optgroup>
|
icon={<MapPin className="w-4 h-4" />}
|
||||||
</select>
|
label="Chapter Marks"
|
||||||
<button
|
active={activePanel === 'markers'}
|
||||||
onClick={handleReprocessProject}
|
onClick={() => togglePanel('markers')}
|
||||||
disabled={isTranscribing || !videoPath}
|
disabled={!videoPath || !canEdit}
|
||||||
title="Reprocess transcript with selected model"
|
title="Add and manage timeline markers — chapter points, key moments, YouTube timestamps"
|
||||||
className="flex items-center gap-1 px-2 py-1 rounded text-xs text-editor-text hover:bg-editor-bg disabled:opacity-40 disabled:cursor-not-allowed"
|
/>
|
||||||
>
|
<ToolbarButton
|
||||||
<RefreshCw className={`w-3 h-3 ${isTranscribing ? 'animate-spin' : ''}`} />
|
icon={<Music className="w-4 h-4" />}
|
||||||
Reprocess
|
label="Bkg. Music"
|
||||||
</button>
|
active={activePanel === 'music'}
|
||||||
</div>
|
onClick={() => togglePanel('music')}
|
||||||
|
disabled={!videoPath || !canEdit}
|
||||||
|
title="Add background music track with auto-ducking — music lowers when someone speaks"
|
||||||
|
/>
|
||||||
|
<ToolbarButton
|
||||||
|
icon={<ListVideo className="w-4 h-4" />}
|
||||||
|
label="Add Clips"
|
||||||
|
active={activePanel === 'append'}
|
||||||
|
onClick={() => togglePanel('append')}
|
||||||
|
disabled={!videoPath || !canEdit}
|
||||||
|
title="Append additional video clips — concatenate multiple files during export"
|
||||||
|
/>
|
||||||
|
<div className="w-px h-5 bg-editor-border mx-1" />
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
icon={<Sparkles className="w-4 h-4" />}
|
icon={<Sparkles className="w-4 h-4" />}
|
||||||
label="AI"
|
label="AI Tools"
|
||||||
active={activePanel === 'ai'}
|
active={activePanel === 'ai'}
|
||||||
onClick={() => togglePanel('ai')}
|
onClick={() => togglePanel('ai')}
|
||||||
disabled={words.length === 0}
|
disabled={words.length === 0 || !canEdit}
|
||||||
|
title="AI filler detection, clip suggestions, and transcript analysis"
|
||||||
/>
|
/>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
icon={<Download className="w-4 h-4" />}
|
icon={<Download className="w-4 h-4" />}
|
||||||
label="Export"
|
label="Export"
|
||||||
active={activePanel === 'export'}
|
active={activePanel === 'export'}
|
||||||
onClick={() => togglePanel('export')}
|
onClick={() => togglePanel('export')}
|
||||||
disabled={words.length === 0}
|
disabled={!videoPath}
|
||||||
/>
|
/>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
icon={<Settings className="w-4 h-4" />}
|
icon={<Settings className="w-4 h-4" />}
|
||||||
@ -728,6 +740,13 @@ export default function App() {
|
|||||||
active={activePanel === 'settings'}
|
active={activePanel === 'settings'}
|
||||||
onClick={() => togglePanel('settings')}
|
onClick={() => togglePanel('settings')}
|
||||||
/>
|
/>
|
||||||
|
<ToolbarButton
|
||||||
|
icon={<HelpCircle className="w-4 h-4" />}
|
||||||
|
label="Help"
|
||||||
|
active={activePanel === 'help'}
|
||||||
|
onClick={() => togglePanel('help')}
|
||||||
|
title="View help and feature documentation"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@ -738,14 +757,51 @@ export default function App() {
|
|||||||
<div ref={splitRef} className="flex-1 flex min-h-0" style={{ position: 'relative' }}>
|
<div ref={splitRef} className="flex-1 flex min-h-0" style={{ position: 'relative' }}>
|
||||||
{/* Video player */}
|
{/* Video player */}
|
||||||
<div className="p-3 flex items-center justify-center bg-black/20 overflow-hidden" style={{ width: `${splitRatio * 100}%`, minWidth: 0 }}>
|
<div className="p-3 flex items-center justify-center bg-black/20 overflow-hidden" style={{ width: `${splitRatio * 100}%`, minWidth: 0 }}>
|
||||||
<VideoPlayer />
|
{videoPath ? (
|
||||||
|
<VideoPlayer />
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div className="w-20 h-20 rounded-[18px] bg-editor-accent/10 border border-editor-accent/20 flex items-center justify-center shadow-lg shadow-editor-accent/5">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<path d="M6 10h12a6 6 0 0 1 0 12H8l-2 4V10Z" stroke="#818cf8" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" opacity="0.7"/>
|
||||||
|
<path d="M6 10h12a6 6 0 0 1 0 12H8l-2 4V10Z" stroke="#6366f1" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"/>
|
||||||
|
<path d="M10 14v4M13 13v6M16 14v4" stroke="#6366f1" strokeWidth="1.5" strokeLinecap="round"/>
|
||||||
|
<path d="M22 16h6M22 19h4M22 22h5" stroke="#818cf8" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" opacity="0.6"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<button
|
||||||
|
onClick={handleOpenFile}
|
||||||
|
className="flex items-center gap-2 px-6 py-2.5 bg-editor-accent hover:bg-editor-accent-hover rounded-lg text-white text-sm font-medium transition-all"
|
||||||
|
>
|
||||||
|
<FolderOpen className="w-4 h-4" />
|
||||||
|
Open File
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleLoadProject}
|
||||||
|
className="flex items-center gap-2 px-4 py-1.5 text-xs text-editor-text-muted hover:text-editor-text hover:bg-editor-surface rounded-md transition-colors"
|
||||||
|
>
|
||||||
|
<FileInput className="w-3.5 h-3.5" />
|
||||||
|
Load Project
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Draggable divider */}
|
{/* Draggable divider */}
|
||||||
<div
|
<div
|
||||||
|
tabIndex={0}
|
||||||
|
role="separator"
|
||||||
|
aria-label="Resize panel"
|
||||||
className="w-1 shrink-0 bg-editor-border cursor-col-resize hover:bg-editor-accent/50 active:bg-editor-accent transition-colors relative z-10"
|
className="w-1 shrink-0 bg-editor-border cursor-col-resize hover:bg-editor-accent/50 active:bg-editor-accent transition-colors relative z-10"
|
||||||
style={{ cursor: isDraggingSplit.current ? 'col-resize' : 'col-resize' }}
|
style={{ cursor: isDraggingSplit.current ? 'col-resize' : 'col-resize' }}
|
||||||
onMouseDown={startSplitDrag}
|
onMouseDown={startSplitDrag}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'ArrowLeft') setSplitRatio(Math.max(0.15, splitRatio - 0.02));
|
||||||
|
if (e.key === 'ArrowRight') setSplitRatio(Math.min(0.85, splitRatio + 0.02));
|
||||||
|
}}
|
||||||
|
title="Drag to resize"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Transcript */}
|
{/* Transcript */}
|
||||||
@ -784,6 +840,9 @@ export default function App() {
|
|||||||
<div className="text-center space-y-1">
|
<div className="text-center space-y-1">
|
||||||
<p className="text-sm font-medium text-editor-text">Processing audio</p>
|
<p className="text-sm font-medium text-editor-text">Processing audio</p>
|
||||||
<p className="text-xs text-editor-text-muted">{transcriptionStatus || 'Please wait...'}</p>
|
<p className="text-xs text-editor-text-muted">{transcriptionStatus || 'Please wait...'}</p>
|
||||||
|
<div className="w-48 h-1.5 bg-editor-border rounded-full overflow-hidden mt-3">
|
||||||
|
<div className="h-full bg-editor-accent rounded-full animate-pulse" style={{ width: '60%' }} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : words.length > 0 ? (
|
) : words.length > 0 ? (
|
||||||
@ -821,8 +880,16 @@ export default function App() {
|
|||||||
<div className="flex shrink-0">
|
<div className="flex shrink-0">
|
||||||
{/* Draggable sidebar divider */}
|
{/* Draggable sidebar divider */}
|
||||||
<div
|
<div
|
||||||
|
tabIndex={0}
|
||||||
|
role="separator"
|
||||||
|
aria-label="Resize panel"
|
||||||
className="w-1 shrink-0 bg-editor-border cursor-col-resize hover:bg-editor-accent/50 active:bg-editor-accent transition-colors relative z-10"
|
className="w-1 shrink-0 bg-editor-border cursor-col-resize hover:bg-editor-accent/50 active:bg-editor-accent transition-colors relative z-10"
|
||||||
onMouseDown={startSidebarDrag}
|
onMouseDown={startSidebarDrag}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'ArrowUp') setSidebarWidth(Math.max(180, sidebarWidth - sidebarWidth * 0.02));
|
||||||
|
if (e.key === 'ArrowDown') setSidebarWidth(Math.min(600, sidebarWidth + sidebarWidth * 0.02));
|
||||||
|
}}
|
||||||
|
title="Drag to resize"
|
||||||
/>
|
/>
|
||||||
<div className="overflow-y-auto" style={{ width: sidebarWidth }}>
|
<div className="overflow-y-auto" style={{ width: sidebarWidth }}>
|
||||||
{activePanel === 'zones' && (
|
{activePanel === 'zones' && (
|
||||||
@ -832,15 +899,18 @@ export default function App() {
|
|||||||
{activePanel === 'markers' && <MarkersPanel />}
|
{activePanel === 'markers' && <MarkersPanel />}
|
||||||
{activePanel === 'music' && <BackgroundMusicPanel />}
|
{activePanel === 'music' && <BackgroundMusicPanel />}
|
||||||
{activePanel === 'append' && <AppendClipPanel />}
|
{activePanel === 'append' && <AppendClipPanel />}
|
||||||
{activePanel === 'ai' && <AIPanel />}
|
{activePanel === 'ai' && <AIPanel onReprocess={handleReprocessProject} whisperModel={whisperModel} setWhisperModel={setWhisperModel} />}
|
||||||
{activePanel === 'export' && <ExportDialog />}
|
{activePanel === 'export' && <ExportDialog />}
|
||||||
{activePanel === 'settings' && <SettingsPanel />}
|
{activePanel === 'settings' && <SettingsPanel />}
|
||||||
|
{activePanel === 'help' && <HelpContent />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{import.meta.env.DEV && <DevPanel />}
|
{import.meta.env.DEV && <DevPanel />}
|
||||||
|
|
||||||
|
<LicenseDialog />
|
||||||
|
|
||||||
{showReprocessConfirm && (
|
{showReprocessConfirm && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 px-4"
|
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 px-4"
|
||||||
@ -908,6 +978,44 @@ export default function App() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{showRecoveryDialog && (
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-[80] flex items-center justify-center bg-black/60 px-4"
|
||||||
|
onClick={handleDismissRecovery}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="w-full max-w-md rounded-xl border border-editor-border bg-editor-bg p-4 space-y-3"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<h3 className="text-sm font-semibold">Recover unsaved work?</h3>
|
||||||
|
<p className="text-xs text-editor-text-muted leading-relaxed">
|
||||||
|
TalkEdit recovered a project from {recoveryMinutesAgo} minute{recoveryMinutesAgo !== 1 ? 's' : ''} ago.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-end gap-2 pt-1">
|
||||||
|
<button
|
||||||
|
onClick={handleDismissRecovery}
|
||||||
|
className="px-3 py-1.5 rounded-md text-xs text-editor-text-muted hover:text-editor-text hover:bg-editor-surface"
|
||||||
|
>
|
||||||
|
Dismiss
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleRecoverAutosave}
|
||||||
|
className="px-3 py-1.5 rounded-md text-xs bg-editor-accent hover:bg-editor-accent-hover text-white"
|
||||||
|
>
|
||||||
|
Recover
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{backendDown && (
|
||||||
|
<div className="fixed bottom-0 left-0 right-0 z-[90] flex items-center justify-center gap-2 px-4 py-2 bg-amber-500/15 border-t border-amber-500/30 text-amber-400 text-xs font-medium">
|
||||||
|
<AlertTriangle className="w-3.5 h-3.5" />
|
||||||
|
Backend disconnected — retrying...
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -918,26 +1026,60 @@ function ToolbarButton({
|
|||||||
active,
|
active,
|
||||||
onClick,
|
onClick,
|
||||||
disabled,
|
disabled,
|
||||||
|
title,
|
||||||
}: {
|
}: {
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
label: string;
|
label: string;
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
title?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<span title={title || label}>
|
||||||
onClick={onClick}
|
<button
|
||||||
disabled={disabled}
|
onClick={onClick}
|
||||||
title={label}
|
disabled={disabled}
|
||||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors ${
|
||||||
active
|
active
|
||||||
? 'bg-editor-accent text-white'
|
? 'bg-editor-accent text-white'
|
||||||
: 'text-editor-text-muted hover:text-editor-text hover:bg-editor-surface'
|
: 'text-editor-text-muted hover:text-editor-text hover:bg-editor-surface'
|
||||||
} ${disabled ? 'opacity-40 cursor-not-allowed' : ''}`}
|
} ${disabled ? 'opacity-40 cursor-not-allowed' : ''}`}
|
||||||
>
|
>
|
||||||
{icon}
|
{icon}
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DropdownItem({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
disabled,
|
||||||
|
title,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
disabled?: boolean;
|
||||||
|
title?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span title={title || label}>
|
||||||
|
<button
|
||||||
|
onClick={onClick}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`w-full flex items-center gap-2 px-3 py-1.5 text-xs text-left transition-colors ${
|
||||||
|
disabled
|
||||||
|
? 'opacity-40 cursor-not-allowed'
|
||||||
|
: 'text-editor-text-muted hover:text-editor-text hover:bg-editor-bg'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,20 @@
|
|||||||
import { useCallback, useState } from 'react';
|
import { useCallback, useState } from 'react';
|
||||||
import { useEditorStore } from '../store/editorStore';
|
import { useEditorStore } from '../store/editorStore';
|
||||||
import { useAIStore } from '../store/aiStore';
|
import { useAIStore } from '../store/aiStore';
|
||||||
import { Sparkles, Scissors, Film, Loader2, Check, X, Play, Download } from 'lucide-react';
|
import { useLicenseStore } from '../store/licenseStore';
|
||||||
|
import { Sparkles, Scissors, Film, Loader2, Check, X, Play, Download, RotateCcw, RefreshCw, Lock } from 'lucide-react';
|
||||||
import type { ClipSuggestion } from '../types/project';
|
import type { ClipSuggestion } from '../types/project';
|
||||||
|
|
||||||
export default function AIPanel() {
|
interface AIPanelProps {
|
||||||
|
onReprocess: () => void;
|
||||||
|
whisperModel: string;
|
||||||
|
setWhisperModel: (model: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AIPanel({ onReprocess, whisperModel, setWhisperModel }: AIPanelProps) {
|
||||||
const { words, videoPath, backendUrl, deleteWordRange, setCurrentTime } = useEditorStore();
|
const { words, videoPath, backendUrl, deleteWordRange, setCurrentTime } = useEditorStore();
|
||||||
|
const canUseAI = useLicenseStore((s) => s.canUseAI);
|
||||||
|
const setShowLicenseDialog = useLicenseStore((s) => s.setShowDialog);
|
||||||
const {
|
const {
|
||||||
defaultProvider,
|
defaultProvider,
|
||||||
providers,
|
providers,
|
||||||
@ -20,10 +29,12 @@ export default function AIPanel() {
|
|||||||
setProcessing,
|
setProcessing,
|
||||||
} = useAIStore();
|
} = useAIStore();
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<'filler' | 'clips'>('filler');
|
const [activeTab, setActiveTab] = useState<'filler' | 'clips' | 'reprocess'>('filler');
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const detectFillers = useCallback(async () => {
|
const detectFillers = useCallback(async () => {
|
||||||
if (words.length === 0) return;
|
if (words.length === 0) return;
|
||||||
|
setError(null);
|
||||||
setProcessing(true, 'Detecting filler words...');
|
setProcessing(true, 'Detecting filler words...');
|
||||||
try {
|
try {
|
||||||
const config = providers[defaultProvider];
|
const config = providers[defaultProvider];
|
||||||
@ -41,11 +52,15 @@ export default function AIPanel() {
|
|||||||
custom_filler_words: customFillerWords || undefined,
|
custom_filler_words: customFillerWords || undefined,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('Filler detection failed');
|
if (!res.ok) {
|
||||||
|
const errData = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(errData.error || `Filler detection failed (${res.status})`);
|
||||||
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setFillerResult(data);
|
setFillerResult(data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
setError(err instanceof Error ? err.message : 'Filler detection failed');
|
||||||
} finally {
|
} finally {
|
||||||
setProcessing(false);
|
setProcessing(false);
|
||||||
}
|
}
|
||||||
@ -53,6 +68,7 @@ export default function AIPanel() {
|
|||||||
|
|
||||||
const createClips = useCallback(async () => {
|
const createClips = useCallback(async () => {
|
||||||
if (words.length === 0) return;
|
if (words.length === 0) return;
|
||||||
|
setError(null);
|
||||||
setProcessing(true, 'Finding best clip segments...');
|
setProcessing(true, 'Finding best clip segments...');
|
||||||
try {
|
try {
|
||||||
const config = providers[defaultProvider];
|
const config = providers[defaultProvider];
|
||||||
@ -75,11 +91,15 @@ export default function AIPanel() {
|
|||||||
target_duration: 60,
|
target_duration: 60,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('Clip creation failed');
|
if (!res.ok) {
|
||||||
|
const errData = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(errData.error || `Clip creation failed (${res.status})`);
|
||||||
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
setClipSuggestions(data.clips || []);
|
setClipSuggestions(data.clips || []);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
setError(err instanceof Error ? err.message : 'Clip creation failed');
|
||||||
} finally {
|
} finally {
|
||||||
setProcessing(false);
|
setProcessing(false);
|
||||||
}
|
}
|
||||||
@ -150,156 +170,275 @@ export default function AIPanel() {
|
|||||||
onClick={() => setActiveTab('filler')}
|
onClick={() => setActiveTab('filler')}
|
||||||
icon={<Scissors className="w-3.5 h-3.5" />}
|
icon={<Scissors className="w-3.5 h-3.5" />}
|
||||||
label="Filler Words"
|
label="Filler Words"
|
||||||
|
title="Detect and remove filler words from transcript"
|
||||||
/>
|
/>
|
||||||
<TabButton
|
<TabButton
|
||||||
active={activeTab === 'clips'}
|
active={activeTab === 'clips'}
|
||||||
onClick={() => setActiveTab('clips')}
|
onClick={() => setActiveTab('clips')}
|
||||||
icon={<Film className="w-3.5 h-3.5" />}
|
icon={<Film className="w-3.5 h-3.5" />}
|
||||||
label="Create Clips"
|
label="Create Clips"
|
||||||
|
title="Find the best segments for social media clips"
|
||||||
|
/>
|
||||||
|
<TabButton
|
||||||
|
active={activeTab === 'reprocess'}
|
||||||
|
onClick={() => setActiveTab('reprocess')}
|
||||||
|
icon={<RefreshCw className="w-3.5 h-3.5" />}
|
||||||
|
label="Reprocess"
|
||||||
|
title="Re-run transcription with a different Whisper model"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
{activeTab === 'filler' && (
|
{activeTab === 'filler' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-xs text-editor-text-muted">
|
{!canUseAI ? (
|
||||||
Use AI to detect and remove filler words like "um", "uh", "like", "you know" from
|
<div className="text-center py-8 px-4">
|
||||||
your transcript.
|
<Lock className="w-8 h-8 text-editor-text-muted mx-auto mb-3" />
|
||||||
</p>
|
<p className="text-sm font-medium mb-1">AI editing requires Business</p>
|
||||||
<div className="space-y-1.5">
|
<p className="text-xs text-editor-text-muted mb-4">
|
||||||
<label className="text-[11px] text-editor-text-muted font-medium">
|
Upgrade to Business to unlock filler word removal, clip suggestions, and more.
|
||||||
Custom filler words (comma-separated)
|
</p>
|
||||||
</label>
|
<button
|
||||||
<input
|
onClick={() => setShowLicenseDialog(true)}
|
||||||
type="text"
|
className="px-4 py-2 bg-editor-accent hover:bg-editor-accent-hover text-white rounded-lg text-sm font-medium transition-colors"
|
||||||
value={customFillerWords}
|
>
|
||||||
onChange={(e) => setCustomFillerWords(e.target.value)}
|
Upgrade Now
|
||||||
placeholder="e.g. okay, alright, anyway"
|
</button>
|
||||||
className="w-full px-2.5 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
|
</div>
|
||||||
/>
|
) : (
|
||||||
</div>
|
<>
|
||||||
<button
|
<p className="text-xs text-editor-text-muted">
|
||||||
onClick={detectFillers}
|
Use AI to detect and remove filler words like "um", "uh", "like", "you know" from
|
||||||
disabled={isProcessing || words.length === 0}
|
your transcript.
|
||||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-50 rounded-lg text-sm font-medium transition-colors"
|
</p>
|
||||||
>
|
<div className="space-y-1.5">
|
||||||
{isProcessing ? (
|
<label className="text-[11px] text-editor-text-muted font-medium">
|
||||||
<>
|
Custom filler words (comma-separated)
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
</label>
|
||||||
{processingMessage}
|
<input
|
||||||
</>
|
type="text"
|
||||||
) : (
|
value={customFillerWords}
|
||||||
<>
|
onChange={(e) => setCustomFillerWords(e.target.value)}
|
||||||
<Sparkles className="w-4 h-4" />
|
placeholder="e.g. okay, alright, anyway"
|
||||||
Detect Filler Words
|
className="w-full px-2.5 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
|
||||||
</>
|
/>
|
||||||
)}
|
</div>
|
||||||
</button>
|
<button
|
||||||
|
onClick={detectFillers}
|
||||||
|
disabled={isProcessing || words.length === 0}
|
||||||
|
title="Scan the entire transcript for filler words (um, uh, like, you know) and mark for removal"
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-40 rounded-lg text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{isProcessing ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
{processingMessage}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Sparkles className="w-4 h-4" />
|
||||||
|
Detect Filler Words
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
{fillerResult && fillerResult.fillerWords.length > 0 && (
|
{error && (
|
||||||
<div className="space-y-3">
|
<div className="bg-red-500/10 border border-red-500/40 rounded text-xs text-red-300 p-2 flex items-center justify-between">
|
||||||
<div className="flex items-center justify-between">
|
<span>{error}</span>
|
||||||
<span className="text-xs font-medium">
|
|
||||||
Found {fillerResult.fillerWords.length} filler words
|
|
||||||
</span>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<button
|
<button
|
||||||
onClick={applyFillerDeletions}
|
onClick={detectFillers}
|
||||||
className="flex items-center gap-1 px-2 py-1 text-xs bg-editor-success/20 text-editor-success rounded hover:bg-editor-success/30"
|
className="flex items-center gap-1 px-2 py-1 text-xs bg-red-500/20 hover:bg-red-500/30 rounded transition-colors shrink-0 ml-2"
|
||||||
>
|
>
|
||||||
<Check className="w-3 h-3" /> Apply All
|
<RotateCcw className="w-3 h-3" /> Retry
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setFillerResult(null)}
|
|
||||||
className="flex items-center gap-1 px-2 py-1 text-xs bg-editor-border text-editor-text-muted rounded hover:bg-editor-surface"
|
|
||||||
>
|
|
||||||
<X className="w-3 h-3" /> Dismiss
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
<div className="space-y-1 max-h-64 overflow-y-auto">
|
{fillerResult && fillerResult.fillerWords.length > 0 && (
|
||||||
{fillerResult.fillerWords.map((fw) => (
|
<div className="space-y-3">
|
||||||
<div
|
<div className="flex items-center justify-between">
|
||||||
key={fw.index}
|
<span className="text-xs font-medium">
|
||||||
className="flex items-center justify-between px-2 py-1.5 bg-editor-word-filler rounded text-xs"
|
Found {fillerResult.fillerWords.length} filler words
|
||||||
>
|
|
||||||
<span>
|
|
||||||
<strong>"{fw.word}"</strong>
|
|
||||||
<span className="text-editor-text-muted ml-1">— {fw.reason}</span>
|
|
||||||
</span>
|
</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={applyFillerDeletions}
|
||||||
|
title="Create cut ranges for all detected filler words at once"
|
||||||
|
className="flex items-center gap-1 px-2 py-1 text-xs bg-editor-success/20 text-editor-success rounded hover:bg-editor-success/30"
|
||||||
|
>
|
||||||
|
<Check className="w-3 h-3" /> Apply All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { setFillerResult(null); setError(null); }}
|
||||||
|
title="Clear detected filler word results without applying"
|
||||||
|
className="flex items-center gap-1 px-2 py-1 text-xs bg-editor-border text-editor-text-muted rounded hover:bg-editor-surface"
|
||||||
|
>
|
||||||
|
<X className="w-3 h-3" /> Dismiss
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div className="space-y-1 max-h-64 overflow-y-auto">
|
||||||
</div>
|
{fillerResult.fillerWords.map((fw) => (
|
||||||
</div>
|
<div
|
||||||
)}
|
key={fw.index}
|
||||||
|
className="flex items-center justify-between px-2 py-1.5 bg-editor-word-filler rounded text-xs"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<strong>"{fw.word}"</strong>
|
||||||
|
<span className="text-editor-text-muted ml-1">— {fw.reason}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{fillerResult && fillerResult.fillerWords.length === 0 && (
|
{fillerResult && fillerResult.fillerWords.length === 0 && (
|
||||||
<p className="text-xs text-editor-success">No filler words detected.</p>
|
<p className="text-xs text-editor-success">No filler words detected.</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activeTab === 'clips' && (
|
{activeTab === 'clips' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-xs text-editor-text-muted">
|
{!canUseAI ? (
|
||||||
AI analyzes your transcript and suggests the most engaging segments for a
|
<div className="text-center py-8 px-4">
|
||||||
YouTube Short or social media clip.
|
<Lock className="w-8 h-8 text-editor-text-muted mx-auto mb-3" />
|
||||||
</p>
|
<p className="text-sm font-medium mb-1">AI clip suggestions require Business</p>
|
||||||
<button
|
<p className="text-xs text-editor-text-muted mb-4">
|
||||||
onClick={createClips}
|
Upgrade to Business to find the best segments for social media clips.
|
||||||
disabled={isProcessing || words.length === 0}
|
</p>
|
||||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-50 rounded-lg text-sm font-medium transition-colors"
|
<button
|
||||||
>
|
onClick={() => setShowLicenseDialog(true)}
|
||||||
{isProcessing ? (
|
className="px-4 py-2 bg-editor-accent hover:bg-editor-accent-hover text-white rounded-lg text-sm font-medium transition-colors"
|
||||||
<>
|
>
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
Upgrade Now
|
||||||
{processingMessage}
|
</button>
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Film className="w-4 h-4" />
|
|
||||||
Find Best Clips
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{clipSuggestions.length > 0 && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
{clipSuggestions.map((clip, i) => (
|
|
||||||
<div key={i} className="p-3 bg-editor-surface rounded-lg space-y-2">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-xs font-semibold">{clip.title}</span>
|
|
||||||
<span className="text-[10px] text-editor-text-muted">
|
|
||||||
{Math.round(clip.endTime - clip.startTime)}s
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-[11px] text-editor-text-muted">{clip.reason}</p>
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => handlePreviewClip(clip)}
|
|
||||||
className="flex-1 flex items-center justify-center gap-1 px-2 py-1.5 text-xs bg-editor-accent/20 text-editor-accent rounded hover:bg-editor-accent/30 transition-colors"
|
|
||||||
>
|
|
||||||
<Play className="w-3 h-3" /> Preview
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => handleExportClip(clip, i)}
|
|
||||||
disabled={exportingClipIndex === i}
|
|
||||||
className="flex-1 flex items-center justify-center gap-1 px-2 py-1.5 text-xs bg-editor-success/20 text-editor-success rounded hover:bg-editor-success/30 disabled:opacity-50 transition-colors"
|
|
||||||
>
|
|
||||||
{exportingClipIndex === i ? (
|
|
||||||
<Loader2 className="w-3 h-3 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Download className="w-3 h-3" />
|
|
||||||
)}
|
|
||||||
Export
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<p className="text-xs text-editor-text-muted">
|
||||||
|
AI analyzes your transcript and suggests the most engaging segments for a
|
||||||
|
YouTube Short or social media clip.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
onClick={createClips}
|
||||||
|
disabled={isProcessing || words.length === 0}
|
||||||
|
title="Analyze transcript to find the most engaging 20-60 second segments for social media"
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-40 rounded-lg text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
{isProcessing ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
|
{processingMessage}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Film className="w-4 h-4" />
|
||||||
|
Find Best Clips
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="bg-red-500/10 border border-red-500/40 rounded text-xs text-red-300 p-2 flex items-center justify-between">
|
||||||
|
<span>{error}</span>
|
||||||
|
<button
|
||||||
|
onClick={createClips}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 text-xs bg-red-500/20 hover:bg-red-500/30 rounded transition-colors shrink-0 ml-2"
|
||||||
|
>
|
||||||
|
<RotateCcw className="w-3 h-3" /> Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{clipSuggestions.length > 0 && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{clipSuggestions.map((clip, i) => (
|
||||||
|
<div key={i} className="p-3 bg-editor-surface rounded-lg space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-semibold">{clip.title}</span>
|
||||||
|
<span className="text-[10px] text-editor-text-muted">
|
||||||
|
{Math.round(clip.endTime - clip.startTime)}s
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-editor-text-muted">{clip.reason}</p>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => handlePreviewClip(clip)}
|
||||||
|
title="Seek to this clip's position and play a preview"
|
||||||
|
className="flex-1 flex items-center justify-center gap-1 px-2 py-1.5 text-xs bg-editor-accent/20 text-editor-accent rounded hover:bg-editor-accent/30 transition-colors"
|
||||||
|
>
|
||||||
|
<Play className="w-3 h-3" /> Preview
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleExportClip(clip, i)}
|
||||||
|
disabled={exportingClipIndex === i}
|
||||||
|
title="Export just this segment as a standalone video file"
|
||||||
|
className="flex-1 flex items-center justify-center gap-1 px-2 py-1.5 text-xs bg-editor-success/20 text-editor-success rounded hover:bg-editor-success/30 disabled:opacity-40 transition-colors"
|
||||||
|
>
|
||||||
|
{exportingClipIndex === i ? (
|
||||||
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Download className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
Export
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'reprocess' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-xs text-editor-text-muted">
|
||||||
|
Re-run transcription with a different model — replaces the current transcript entirely.
|
||||||
|
</p>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
<label className="text-[11px] text-editor-text-muted font-medium">
|
||||||
|
Whisper Model
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={whisperModel}
|
||||||
|
onChange={(e) => setWhisperModel(e.target.value)}
|
||||||
|
className="w-full px-2.5 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
|
||||||
|
>
|
||||||
|
<optgroup label="Multilingual (any language)">
|
||||||
|
<option value="tiny">tiny — ~75 MB · fastest, low accuracy</option>
|
||||||
|
<option value="base">base — ~140 MB · fast, decent accuracy</option>
|
||||||
|
<option value="small">small — ~460 MB · good balance</option>
|
||||||
|
<option value="medium">medium — ~1.5 GB · better accuracy</option>
|
||||||
|
<option value="large-v2">large-v2 — ~2.9 GB · high accuracy</option>
|
||||||
|
<option value="large-v3">large-v3 — ~2.9 GB · best overall ★</option>
|
||||||
|
<option value="large-v3-turbo">large-v3-turbo — ~1.6 GB · fast + accurate ★</option>
|
||||||
|
<option value="distil-large-v3">distil-large-v3 — ~1.5 GB · fast, near large-v3 quality</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="English-only (faster & more accurate for English)">
|
||||||
|
<option value="tiny.en">tiny.en — ~75 MB · fastest English</option>
|
||||||
|
<option value="base.en">base.en — ~140 MB · fast English</option>
|
||||||
|
<option value="small.en">small.en — ~460 MB · good English</option>
|
||||||
|
<option value="medium.en">medium.en — ~1.5 GB · great English</option>
|
||||||
|
<option value="distil-small.en">distil-small.en — ~190 MB · fast English ★</option>
|
||||||
|
<option value="distil-medium.en">distil-medium.en — ~750 MB · best fast English ★</option>
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={onReprocess}
|
||||||
|
disabled={isProcessing || words.length === 0}
|
||||||
|
title="Re-run transcription with the selected model — this will replace all current words"
|
||||||
|
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-40 rounded-lg text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
<RefreshCw className="w-4 h-4" />
|
||||||
|
Reprocess Transcript
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -310,15 +449,18 @@ function TabButton({
|
|||||||
onClick,
|
onClick,
|
||||||
icon,
|
icon,
|
||||||
label,
|
label,
|
||||||
|
title,
|
||||||
}: {
|
}: {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
label: string;
|
label: string;
|
||||||
|
title?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
title={title}
|
||||||
className={`flex-1 flex items-center justify-center gap-1.5 px-3 py-2.5 text-xs font-medium transition-colors border-b-2 ${
|
className={`flex-1 flex items-center justify-center gap-1.5 px-3 py-2.5 text-xs font-medium transition-colors border-b-2 ${
|
||||||
active
|
active
|
||||||
? 'border-editor-accent text-editor-accent'
|
? 'border-editor-accent text-editor-accent'
|
||||||
|
|||||||
@ -5,7 +5,12 @@ export default function AppendClipPanel() {
|
|||||||
const { additionalClips, addAdditionalClip, removeAdditionalClip, reorderAdditionalClip, videoPath } = useEditorStore();
|
const { additionalClips, addAdditionalClip, removeAdditionalClip, reorderAdditionalClip, videoPath } = useEditorStore();
|
||||||
|
|
||||||
const handleAddClip = async () => {
|
const handleAddClip = async () => {
|
||||||
const path = await window.electronAPI?.openFile();
|
const path = await window.electronAPI?.openFile({
|
||||||
|
filters: [
|
||||||
|
{ name: 'Video Files', extensions: ['mp4', 'mkv', 'mov', 'avi', 'webm'] },
|
||||||
|
{ name: 'All Files', extensions: ['*'] },
|
||||||
|
],
|
||||||
|
});
|
||||||
if (path) {
|
if (path) {
|
||||||
addAdditionalClip(path);
|
addAdditionalClip(path);
|
||||||
}
|
}
|
||||||
@ -69,6 +74,7 @@ export default function AppendClipPanel() {
|
|||||||
onClick={handleAddClip}
|
onClick={handleAddClip}
|
||||||
disabled={!videoPath}
|
disabled={!videoPath}
|
||||||
className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg border-2 border-dashed border-editor-border text-xs text-editor-text-muted hover:text-editor-text hover:border-editor-text-muted disabled:opacity-40 transition-colors"
|
className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg border-2 border-dashed border-editor-border text-xs text-editor-text-muted hover:text-editor-text hover:border-editor-text-muted disabled:opacity-40 transition-colors"
|
||||||
|
title="Select a video or audio file to append during export"
|
||||||
>
|
>
|
||||||
<Plus className="w-3.5 h-3.5" />
|
<Plus className="w-3.5 h-3.5" />
|
||||||
Add Clip
|
Add Clip
|
||||||
|
|||||||
@ -5,7 +5,12 @@ export default function BackgroundMusicPanel() {
|
|||||||
const { backgroundMusic, setBackgroundMusic, updateBackgroundMusic } = useEditorStore();
|
const { backgroundMusic, setBackgroundMusic, updateBackgroundMusic } = useEditorStore();
|
||||||
|
|
||||||
const handleLoadMusic = async () => {
|
const handleLoadMusic = async () => {
|
||||||
const path = await window.electronAPI?.openFile();
|
const path = await window.electronAPI?.openFile({
|
||||||
|
filters: [
|
||||||
|
{ name: 'Audio Files', extensions: ['mp3', 'wav', 'm4a', 'flac', 'ogg', 'aac', 'wma'] },
|
||||||
|
{ name: 'All Files', extensions: ['*'] },
|
||||||
|
],
|
||||||
|
});
|
||||||
if (path) {
|
if (path) {
|
||||||
setBackgroundMusic({
|
setBackgroundMusic({
|
||||||
path,
|
path,
|
||||||
@ -33,6 +38,7 @@ export default function BackgroundMusicPanel() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleLoadMusic}
|
onClick={handleLoadMusic}
|
||||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-lg border-2 border-dashed border-editor-border text-xs text-editor-text-muted hover:text-editor-text hover:border-editor-text-muted transition-colors"
|
className="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-lg border-2 border-dashed border-editor-border text-xs text-editor-text-muted hover:text-editor-text hover:border-editor-text-muted transition-colors"
|
||||||
|
title="Select an audio file to use as background music"
|
||||||
>
|
>
|
||||||
<Disc3 className="w-4 h-4" />
|
<Disc3 className="w-4 h-4" />
|
||||||
Load Music File
|
Load Music File
|
||||||
@ -65,6 +71,7 @@ export default function BackgroundMusicPanel() {
|
|||||||
value={backgroundMusic.volumeDb}
|
value={backgroundMusic.volumeDb}
|
||||||
onChange={(e) => updateBackgroundMusic({ volumeDb: Number(e.target.value) })}
|
onChange={(e) => updateBackgroundMusic({ volumeDb: Number(e.target.value) })}
|
||||||
className="flex-1 h-1.5"
|
className="flex-1 h-1.5"
|
||||||
|
title="Background music volume relative to main audio — positive boosts, negative reduces"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-editor-text w-10 text-right">{backgroundMusic.volumeDb} dB</span>
|
<span className="text-xs text-editor-text w-10 text-right">{backgroundMusic.volumeDb} dB</span>
|
||||||
</div>
|
</div>
|
||||||
@ -76,6 +83,7 @@ export default function BackgroundMusicPanel() {
|
|||||||
checked={backgroundMusic.duckingEnabled}
|
checked={backgroundMusic.duckingEnabled}
|
||||||
onChange={(e) => updateBackgroundMusic({ duckingEnabled: e.target.checked })}
|
onChange={(e) => updateBackgroundMusic({ duckingEnabled: e.target.checked })}
|
||||||
className="w-4 h-4 rounded bg-editor-surface border-editor-border accent-editor-accent"
|
className="w-4 h-4 rounded bg-editor-surface border-editor-border accent-editor-accent"
|
||||||
|
title="Automatically lower music volume when speech is detected"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xs font-medium">Auto-ducking</span>
|
<span className="text-xs font-medium">Auto-ducking</span>
|
||||||
@ -97,6 +105,7 @@ export default function BackgroundMusicPanel() {
|
|||||||
value={backgroundMusic.duckingDb}
|
value={backgroundMusic.duckingDb}
|
||||||
onChange={(e) => updateBackgroundMusic({ duckingDb: Number(e.target.value) })}
|
onChange={(e) => updateBackgroundMusic({ duckingDb: Number(e.target.value) })}
|
||||||
className="flex-1 h-1.5"
|
className="flex-1 h-1.5"
|
||||||
|
title="How much to reduce music volume during speech (1-20 dB)"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-editor-text w-10 text-right">{backgroundMusic.duckingDb} dB</span>
|
<span className="text-xs text-editor-text w-10 text-right">{backgroundMusic.duckingDb} dB</span>
|
||||||
</div>
|
</div>
|
||||||
@ -110,6 +119,7 @@ export default function BackgroundMusicPanel() {
|
|||||||
value={backgroundMusic.duckingAttackMs}
|
value={backgroundMusic.duckingAttackMs}
|
||||||
onChange={(e) => updateBackgroundMusic({ duckingAttackMs: Number(e.target.value) })}
|
onChange={(e) => updateBackgroundMusic({ duckingAttackMs: Number(e.target.value) })}
|
||||||
className="flex-1 h-1.5"
|
className="flex-1 h-1.5"
|
||||||
|
title="How quickly the ducking effect engages when speech starts"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-editor-text w-10 text-right">{backgroundMusic.duckingAttackMs}ms</span>
|
<span className="text-xs text-editor-text w-10 text-right">{backgroundMusic.duckingAttackMs}ms</span>
|
||||||
</div>
|
</div>
|
||||||
@ -123,6 +133,7 @@ export default function BackgroundMusicPanel() {
|
|||||||
value={backgroundMusic.duckingReleaseMs}
|
value={backgroundMusic.duckingReleaseMs}
|
||||||
onChange={(e) => updateBackgroundMusic({ duckingReleaseMs: Number(e.target.value) })}
|
onChange={(e) => updateBackgroundMusic({ duckingReleaseMs: Number(e.target.value) })}
|
||||||
className="flex-1 h-1.5"
|
className="flex-1 h-1.5"
|
||||||
|
title="How quickly the ducking effect fades when speech ends"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-editor-text w-10 text-right">{backgroundMusic.duckingReleaseMs}ms</span>
|
<span className="text-xs text-editor-text w-10 text-right">{backgroundMusic.duckingReleaseMs}ms</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback } from 'react';
|
||||||
import { useEditorStore } from '../store/editorStore';
|
import { useEditorStore } from '../store/editorStore';
|
||||||
import { Terminal, ChevronDown, ChevronUp, Play, Wifi } from 'lucide-react';
|
import { Terminal, ChevronDown, ChevronUp, Play, Wifi, AlertTriangle } from 'lucide-react';
|
||||||
|
|
||||||
export default function DevPanel() {
|
export default function DevPanel() {
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [pathInput, setPathInput] = useState('');
|
const [pathInput, setPathInput] = useState('');
|
||||||
const [testResult, setTestResult] = useState<string | null>(null);
|
const [testResult, setTestResult] = useState<string | null>(null);
|
||||||
const [testing, setTesting] = useState(false);
|
const [testing, setTesting] = useState(false);
|
||||||
|
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||||
|
|
||||||
const { backendUrl, videoPath, loadVideo } = useEditorStore();
|
const { backendUrl, videoPath, loadVideo } = useEditorStore();
|
||||||
|
|
||||||
@ -121,6 +122,37 @@ export default function DevPanel() {
|
|||||||
{testResult}
|
{testResult}
|
||||||
</pre>
|
</pre>
|
||||||
)}
|
)}
|
||||||
|
{/* Danger Zone */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-[#ef4444] uppercase tracking-wider text-[9px]">Danger Zone</div>
|
||||||
|
{!showResetConfirm ? (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowResetConfirm(true)}
|
||||||
|
className="w-full px-2 py-1.5 rounded border border-red-500/40 text-red-400 hover:bg-red-500/10 text-xs flex items-center justify-center gap-1.5"
|
||||||
|
>
|
||||||
|
<AlertTriangle className="w-3 h-3" />
|
||||||
|
Reset Editor State
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div className="bg-[#1e1020] border border-red-500/40 rounded p-2 space-y-1.5">
|
||||||
|
<p className="text-[#fca5a5] text-[10px]">This will clear all editor data and reload the page. Unsaved changes will be lost.</p>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowResetConfirm(false)}
|
||||||
|
className="flex-1 px-2 py-1 rounded text-[10px] text-[#6b7280] hover:text-white hover:bg-[#2a2d3e]"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => { useEditorStore.getState().reset(); window.location.reload(); }}
|
||||||
|
className="flex-1 px-2 py-1 rounded text-[10px] border border-red-500/40 text-red-400 hover:bg-red-500/10"
|
||||||
|
>
|
||||||
|
Confirm Reset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -2,6 +2,7 @@ import { useState, useCallback } from 'react';
|
|||||||
import { useEditorStore } from '../store/editorStore';
|
import { useEditorStore } from '../store/editorStore';
|
||||||
import { Download, Loader2, Zap, Cog, Info, Volume2, FileText, ZoomIn, Video, Music } from 'lucide-react';
|
import { Download, Loader2, Zap, Cog, Info, Volume2, FileText, ZoomIn, Video, Music } from 'lucide-react';
|
||||||
import type { ExportOptions } from '../types/project';
|
import type { ExportOptions } from '../types/project';
|
||||||
|
import { assert } from '../lib/assert';
|
||||||
|
|
||||||
export default function ExportDialog() {
|
export default function ExportDialog() {
|
||||||
const { videoPath, words, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, isExporting, exportProgress, backendUrl, setExporting, getKeepSegments, additionalClips, backgroundMusic } =
|
const { videoPath, words, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, isExporting, exportProgress, backendUrl, setExporting, getKeepSegments, additionalClips, backgroundMusic } =
|
||||||
@ -9,6 +10,19 @@ export default function ExportDialog() {
|
|||||||
|
|
||||||
const hasCuts = cutRanges.length > 0;
|
const hasCuts = cutRanges.length > 0;
|
||||||
|
|
||||||
|
// Compute set of deleted word indices from cutRanges
|
||||||
|
const getDeletedSet = useCallback(() => {
|
||||||
|
const deletedSet = new Set<number>();
|
||||||
|
for (const range of cutRanges) {
|
||||||
|
for (let i = 0; i < words.length; i++) {
|
||||||
|
if (words[i].start >= range.start && words[i].end <= range.end) {
|
||||||
|
deletedSet.add(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return deletedSet;
|
||||||
|
}, [cutRanges, words]);
|
||||||
|
|
||||||
// Detect if input is audio-only by its extension
|
// Detect if input is audio-only by its extension
|
||||||
const audioExtensions = new Set(['.wav', '.mp3', '.flac', '.m4a', '.ogg', '.aac', '.wma']);
|
const audioExtensions = new Set(['.wav', '.mp3', '.flac', '.m4a', '.ogg', '.aac', '.wma']);
|
||||||
const inputExt = videoPath ? '.' + videoPath.split('.').pop()?.toLowerCase() : '';
|
const inputExt = videoPath ? '.' + videoPath.split('.').pop()?.toLowerCase() : '';
|
||||||
@ -46,14 +60,7 @@ export default function ExportDialog() {
|
|||||||
setIsTranscribingTranscript(true);
|
setIsTranscribingTranscript(true);
|
||||||
try {
|
try {
|
||||||
// Compute deleted word set
|
// Compute deleted word set
|
||||||
const deletedSet = new Set<number>();
|
const deletedSet = getDeletedSet();
|
||||||
for (const range of cutRanges) {
|
|
||||||
for (let i = 0; i < words.length; i++) {
|
|
||||||
if (words[i].start >= range.start && words[i].end <= range.end) {
|
|
||||||
deletedSet.add(i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate content entirely on the frontend — no backend needed
|
// Generate content entirely on the frontend — no backend needed
|
||||||
let content: string;
|
let content: string;
|
||||||
@ -103,7 +110,7 @@ export default function ExportDialog() {
|
|||||||
} finally {
|
} finally {
|
||||||
setIsTranscribingTranscript(false);
|
setIsTranscribingTranscript(false);
|
||||||
}
|
}
|
||||||
}, [videoPath, words, cutRanges, transcriptFormat]);
|
}, [videoPath, words, getDeletedSet, transcriptFormat]);
|
||||||
|
|
||||||
const HANDLE_EXPORT_filters = useCallback(() => {
|
const HANDLE_EXPORT_filters = useCallback(() => {
|
||||||
const ext = options.format;
|
const ext = options.format;
|
||||||
@ -130,14 +137,8 @@ export default function ExportDialog() {
|
|||||||
setExportError(null);
|
setExportError(null);
|
||||||
try {
|
try {
|
||||||
const keepSegments = getKeepSegments();
|
const keepSegments = getKeepSegments();
|
||||||
|
assert(words.length > 0, 'handleExport: words is empty before building keep segments');
|
||||||
const deletedSet = new Set<number>();
|
const deletedSet = getDeletedSet();
|
||||||
for (const range of cutRanges) {
|
|
||||||
for (let i = 0; i < words.length; i++) {
|
|
||||||
const w = words[i];
|
|
||||||
if (w.start >= range.start && w.end <= range.end) deletedSet.add(i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map frontend camelCase gain/speed fields to backend snake_case
|
// Map frontend camelCase gain/speed fields to backend snake_case
|
||||||
const backendGainRanges = gainRanges.map((r) => ({
|
const backendGainRanges = gainRanges.map((r) => ({
|
||||||
@ -213,7 +214,7 @@ export default function ExportDialog() {
|
|||||||
setExportError(err instanceof Error ? err.message : 'Export failed');
|
setExportError(err instanceof Error ? err.message : 'Export failed');
|
||||||
setExporting(false);
|
setExporting(false);
|
||||||
}
|
}
|
||||||
}, [videoPath, options, backendUrl, setExporting, getKeepSegments, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, words, HANDLE_EXPORT_filters, additionalClips, backgroundMusic]);
|
}, [videoPath, options, backendUrl, setExporting, getKeepSegments, getDeletedSet, muteRanges, gainRanges, speedRanges, globalGainDb, words, HANDLE_EXPORT_filters, additionalClips, backgroundMusic]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 space-y-5">
|
<div className="p-4 space-y-5">
|
||||||
@ -229,6 +230,7 @@ export default function ExportDialog() {
|
|||||||
icon={<Zap className="w-4 h-4" />}
|
icon={<Zap className="w-4 h-4" />}
|
||||||
title="Fast"
|
title="Fast"
|
||||||
desc="Stream copy, seconds"
|
desc="Stream copy, seconds"
|
||||||
|
tooltip="Stream copy — fast, no quality loss, but does not apply cuts or effects"
|
||||||
/>
|
/>
|
||||||
<ModeCard
|
<ModeCard
|
||||||
active={options.mode === 'reencode'}
|
active={options.mode === 'reencode'}
|
||||||
@ -236,6 +238,7 @@ export default function ExportDialog() {
|
|||||||
icon={<Cog className="w-4 h-4" />}
|
icon={<Cog className="w-4 h-4" />}
|
||||||
title="Re-encode"
|
title="Re-encode"
|
||||||
desc="Custom quality, slower"
|
desc="Custom quality, slower"
|
||||||
|
tooltip="Full re-encode — applies cuts, gain, speed, zoom, captions, and effects"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
@ -251,6 +254,7 @@ export default function ExportDialog() {
|
|||||||
{ value: '1080p', label: '1080p (Full HD)' },
|
{ value: '1080p', label: '1080p (Full HD)' },
|
||||||
{ value: '4k', label: '4K (Ultra HD)' },
|
{ value: '4k', label: '4K (Ultra HD)' },
|
||||||
]}
|
]}
|
||||||
|
title="Output video resolution — higher resolution = larger file"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -265,6 +269,7 @@ export default function ExportDialog() {
|
|||||||
{ value: 'webm', label: 'WebM (VP9)' },
|
{ value: 'webm', label: 'WebM (VP9)' },
|
||||||
...(isAudioOnly ? [{ value: 'wav' as const, label: 'WAV (Uncompressed)' }] : []),
|
...(isAudioOnly ? [{ value: 'wav' as const, label: 'WAV (Uncompressed)' }] : []),
|
||||||
]}
|
]}
|
||||||
|
title="Output container format — MP4 is most compatible"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Video zoom / punch-in */}
|
{/* Video zoom / punch-in */}
|
||||||
@ -275,6 +280,7 @@ export default function ExportDialog() {
|
|||||||
checked={options.zoom?.enabled || false}
|
checked={options.zoom?.enabled || false}
|
||||||
onChange={(e) => setOptions((o) => ({ ...o, zoom: { ...o.zoom!, enabled: e.target.checked } }))}
|
onChange={(e) => setOptions((o) => ({ ...o, zoom: { ...o.zoom!, enabled: e.target.checked } }))}
|
||||||
className="w-4 h-4 rounded bg-editor-surface border-editor-border accent-editor-accent"
|
className="w-4 h-4 rounded bg-editor-surface border-editor-border accent-editor-accent"
|
||||||
|
title="Crop and reposition the video frame — useful for removing black bars or reframing"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xs font-medium flex items-center gap-1">
|
<span className="text-xs font-medium flex items-center gap-1">
|
||||||
@ -298,6 +304,7 @@ export default function ExportDialog() {
|
|||||||
value={options.zoom?.zoomFactor || 1}
|
value={options.zoom?.zoomFactor || 1}
|
||||||
onChange={(e) => setOptions((o) => ({ ...o, zoom: { ...o.zoom!, zoomFactor: Number(e.target.value) } }))}
|
onChange={(e) => setOptions((o) => ({ ...o, zoom: { ...o.zoom!, zoomFactor: Number(e.target.value) } }))}
|
||||||
className="flex-1 h-1.5"
|
className="flex-1 h-1.5"
|
||||||
|
title="Magnification level — 1.0x is original, higher values zoom in"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-editor-text w-10 text-right">{options.zoom?.zoomFactor?.toFixed(2)}x</span>
|
<span className="text-xs text-editor-text w-10 text-right">{options.zoom?.zoomFactor?.toFixed(2)}x</span>
|
||||||
</div>
|
</div>
|
||||||
@ -311,6 +318,7 @@ export default function ExportDialog() {
|
|||||||
value={options.zoom?.panX || 0}
|
value={options.zoom?.panX || 0}
|
||||||
onChange={(e) => setOptions((o) => ({ ...o, zoom: { ...o.zoom!, panX: Number(e.target.value) } }))}
|
onChange={(e) => setOptions((o) => ({ ...o, zoom: { ...o.zoom!, panX: Number(e.target.value) } }))}
|
||||||
className="flex-1 h-1.5"
|
className="flex-1 h-1.5"
|
||||||
|
title="Horizontal position of the crop window — negative moves left, positive moves right"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-editor-text w-10 text-right">{((options.zoom?.panX || 0) * 100).toFixed(0)}%</span>
|
<span className="text-xs text-editor-text w-10 text-right">{((options.zoom?.panX || 0) * 100).toFixed(0)}%</span>
|
||||||
</div>
|
</div>
|
||||||
@ -324,6 +332,7 @@ export default function ExportDialog() {
|
|||||||
value={options.zoom?.panY || 0}
|
value={options.zoom?.panY || 0}
|
||||||
onChange={(e) => setOptions((o) => ({ ...o, zoom: { ...o.zoom!, panY: Number(e.target.value) } }))}
|
onChange={(e) => setOptions((o) => ({ ...o, zoom: { ...o.zoom!, panY: Number(e.target.value) } }))}
|
||||||
className="flex-1 h-1.5"
|
className="flex-1 h-1.5"
|
||||||
|
title="Vertical position of the crop window — negative moves up, positive moves down"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-editor-text w-10 text-right">{((options.zoom?.panY || 0) * 100).toFixed(0)}%</span>
|
<span className="text-xs text-editor-text w-10 text-right">{((options.zoom?.panY || 0) * 100).toFixed(0)}%</span>
|
||||||
</div>
|
</div>
|
||||||
@ -340,6 +349,7 @@ export default function ExportDialog() {
|
|||||||
checked={options.removeBackground || false}
|
checked={options.removeBackground || false}
|
||||||
onChange={(e) => setOptions((o) => ({ ...o, removeBackground: e.target.checked }))}
|
onChange={(e) => setOptions((o) => ({ ...o, removeBackground: e.target.checked }))}
|
||||||
className="w-4 h-4 rounded bg-editor-surface border-editor-border accent-editor-accent"
|
className="w-4 h-4 rounded bg-editor-surface border-editor-border accent-editor-accent"
|
||||||
|
title="Remove or replace the background behind the speaker"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xs font-medium flex items-center gap-1">
|
<span className="text-xs font-medium flex items-center gap-1">
|
||||||
@ -408,6 +418,7 @@ export default function ExportDialog() {
|
|||||||
checked={options.normalizeAudio}
|
checked={options.normalizeAudio}
|
||||||
onChange={(e) => setOptions((o) => ({ ...o, normalizeAudio: e.target.checked }))}
|
onChange={(e) => setOptions((o) => ({ ...o, normalizeAudio: e.target.checked }))}
|
||||||
className="w-4 h-4 rounded bg-editor-surface border-editor-border accent-editor-accent"
|
className="w-4 h-4 rounded bg-editor-surface border-editor-border accent-editor-accent"
|
||||||
|
title="Normalize audio to a consistent loudness target"
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<span className="text-xs font-medium">Normalize loudness</span>
|
<span className="text-xs font-medium">Normalize loudness</span>
|
||||||
@ -423,6 +434,7 @@ export default function ExportDialog() {
|
|||||||
value={options.normalizeTarget}
|
value={options.normalizeTarget}
|
||||||
onChange={(e) => setOptions((o) => ({ ...o, normalizeTarget: Number(e.target.value) }))}
|
onChange={(e) => setOptions((o) => ({ ...o, normalizeTarget: Number(e.target.value) }))}
|
||||||
className="flex-1 px-2 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:outline-none focus:border-editor-accent [color-scheme:dark]"
|
className="flex-1 px-2 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:outline-none focus:border-editor-accent [color-scheme:dark]"
|
||||||
|
title="Loudness target — YouTube (-14), Spotify (-16), Broadcast (-23)"
|
||||||
>
|
>
|
||||||
<option value={-14}>YouTube (-14 LUFS)</option>
|
<option value={-14}>YouTube (-14 LUFS)</option>
|
||||||
<option value={-16}>Spotify (-16 LUFS)</option>
|
<option value={-16}>Spotify (-16 LUFS)</option>
|
||||||
@ -441,6 +453,7 @@ export default function ExportDialog() {
|
|||||||
checked={options.enhanceAudio}
|
checked={options.enhanceAudio}
|
||||||
onChange={(e) => setOptions((o) => ({ ...o, enhanceAudio: e.target.checked }))}
|
onChange={(e) => setOptions((o) => ({ ...o, enhanceAudio: e.target.checked }))}
|
||||||
className="w-4 h-4 rounded bg-editor-surface border-editor-border accent-editor-accent"
|
className="w-4 h-4 rounded bg-editor-surface border-editor-border accent-editor-accent"
|
||||||
|
title="Apply noise reduction and speech enhancement"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs">Enhance audio (Studio Sound)</span>
|
<span className="text-xs">Enhance audio (Studio Sound)</span>
|
||||||
</label>
|
</label>
|
||||||
@ -455,6 +468,7 @@ export default function ExportDialog() {
|
|||||||
{ value: 'burn-in', label: 'Burn-in (permanent)' },
|
{ value: 'burn-in', label: 'Burn-in (permanent)' },
|
||||||
{ value: 'sidecar', label: 'Sidecar SRT file' },
|
{ value: 'sidecar', label: 'Sidecar SRT file' },
|
||||||
]}
|
]}
|
||||||
|
title="Burn captions into video, export as separate SRT/VTT file, or none"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Transcript-only export */}
|
{/* Transcript-only export */}
|
||||||
@ -479,6 +493,7 @@ export default function ExportDialog() {
|
|||||||
onClick={handleTranscriptExport}
|
onClick={handleTranscriptExport}
|
||||||
disabled={isTranscribingTranscript || words.length === 0}
|
disabled={isTranscribingTranscript || words.length === 0}
|
||||||
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-editor-accent/20 text-editor-accent hover:bg-editor-accent/30 disabled:opacity-40 transition-colors"
|
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-editor-accent/20 text-editor-accent hover:bg-editor-accent/30 disabled:opacity-40 transition-colors"
|
||||||
|
title="Export just the transcript text or subtitles without the video"
|
||||||
>
|
>
|
||||||
{isTranscribingTranscript ? (
|
{isTranscribingTranscript ? (
|
||||||
<Loader2 className="w-3 h-3 animate-spin" />
|
<Loader2 className="w-3 h-3 animate-spin" />
|
||||||
@ -494,21 +509,31 @@ export default function ExportDialog() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleExport}
|
onClick={handleExport}
|
||||||
disabled={isExporting || !videoPath}
|
disabled={isExporting || !videoPath}
|
||||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-50 rounded-lg text-sm font-semibold transition-colors"
|
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-40 rounded-lg text-sm font-semibold transition-colors"
|
||||||
|
title="Start export with current settings"
|
||||||
>
|
>
|
||||||
{isExporting ? (
|
<Download className="w-4 h-4" />
|
||||||
<>
|
Export Video
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
Exporting... {Math.round(exportProgress)}%
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Download className="w-4 h-4" />
|
|
||||||
Export Video
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Export progress */}
|
||||||
|
{isExporting && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin text-editor-accent" />
|
||||||
|
<span className="text-xs font-medium">Exporting...</span>
|
||||||
|
<span className="text-xs text-editor-text-muted">{Math.round(exportProgress)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 bg-editor-border rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-editor-accent rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${exportProgress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-editor-text-muted">Export in progress...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{exportError && (
|
{exportError && (
|
||||||
<div className="rounded border border-red-500/40 bg-red-500/10 px-3 py-2 text-xs text-red-300">
|
<div className="rounded border border-red-500/40 bg-red-500/10 px-3 py-2 text-xs text-red-300">
|
||||||
{exportError}
|
{exportError}
|
||||||
@ -539,16 +564,19 @@ function ModeCard({
|
|||||||
icon,
|
icon,
|
||||||
title,
|
title,
|
||||||
desc,
|
desc,
|
||||||
|
tooltip,
|
||||||
}: {
|
}: {
|
||||||
active: boolean;
|
active: boolean;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
title: string;
|
title: string;
|
||||||
desc: string;
|
desc: string;
|
||||||
|
tooltip?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
|
title={tooltip}
|
||||||
className={`flex flex-col items-center gap-1 p-3 rounded-lg border-2 transition-colors ${
|
className={`flex flex-col items-center gap-1 p-3 rounded-lg border-2 transition-colors ${
|
||||||
active
|
active
|
||||||
? 'border-editor-accent bg-editor-accent/10'
|
? 'border-editor-accent bg-editor-accent/10'
|
||||||
@ -567,16 +595,19 @@ function SelectField({
|
|||||||
value,
|
value,
|
||||||
onChange,
|
onChange,
|
||||||
options,
|
options,
|
||||||
|
title,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
options: Array<{ value: string; label: string }>;
|
options: Array<{ value: string; label: string }>;
|
||||||
|
title?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<label className="text-xs text-editor-text-muted font-medium">{label}</label>
|
<label className="text-xs text-editor-text-muted font-medium">{label}</label>
|
||||||
<select
|
<select
|
||||||
|
title={title}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
className="w-full px-3 py-2 bg-editor-surface border border-editor-border rounded-lg text-xs text-editor-text focus:outline-none focus:border-editor-accent [color-scheme:dark]"
|
className="w-full px-3 py-2 bg-editor-surface border border-editor-border rounded-lg text-xs text-editor-text focus:outline-none focus:border-editor-accent [color-scheme:dark]"
|
||||||
|
|||||||
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
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -2,6 +2,17 @@ import { useState } from 'react';
|
|||||||
import { useEditorStore } from '../store/editorStore';
|
import { useEditorStore } from '../store/editorStore';
|
||||||
import { MapPin, Trash2, PencilLine, Check, X, Copy } from 'lucide-react';
|
import { MapPin, Trash2, PencilLine, Check, X, Copy } from 'lucide-react';
|
||||||
|
|
||||||
|
const COLOR_NAMES: Record<string, string> = {
|
||||||
|
'#6366f1': 'Indigo',
|
||||||
|
'#ef4444': 'Red',
|
||||||
|
'#22c55e': 'Green',
|
||||||
|
'#f59e0b': 'Amber',
|
||||||
|
'#3b82f6': 'Blue',
|
||||||
|
'#ec4899': 'Pink',
|
||||||
|
'#8b5cf6': 'Purple',
|
||||||
|
'#14b8a6': 'Teal',
|
||||||
|
};
|
||||||
|
|
||||||
const COLORS = ['#6366f1', '#ef4444', '#22c55e', '#f59e0b', '#3b82f6', '#ec4899', '#8b5cf6', '#14b8a6'];
|
const COLORS = ['#6366f1', '#ef4444', '#22c55e', '#f59e0b', '#3b82f6', '#ec4899', '#8b5cf6', '#14b8a6'];
|
||||||
|
|
||||||
export default function MarkersPanel() {
|
export default function MarkersPanel() {
|
||||||
@ -73,6 +84,7 @@ export default function MarkersPanel() {
|
|||||||
onClick={() => setNewColor(c)}
|
onClick={() => setNewColor(c)}
|
||||||
className={`w-4 h-4 rounded-full border ${newColor === c ? 'border-white ring-1 ring-white' : 'border-transparent'}`}
|
className={`w-4 h-4 rounded-full border ${newColor === c ? 'border-white ring-1 ring-white' : 'border-transparent'}`}
|
||||||
style={{ backgroundColor: c }}
|
style={{ backgroundColor: c }}
|
||||||
|
title={COLOR_NAMES[c]}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -80,6 +92,7 @@ export default function MarkersPanel() {
|
|||||||
<button
|
<button
|
||||||
onClick={addAtCurrentTime}
|
onClick={addAtCurrentTime}
|
||||||
className="w-full flex items-center justify-center gap-1 px-2 py-1.5 text-xs bg-editor-accent/20 text-editor-accent hover:bg-editor-accent/30 rounded"
|
className="w-full flex items-center justify-center gap-1 px-2 py-1.5 text-xs bg-editor-accent/20 text-editor-accent hover:bg-editor-accent/30 rounded"
|
||||||
|
title="Add a marker at the current playhead position"
|
||||||
>
|
>
|
||||||
<MapPin className="w-3 h-3" />
|
<MapPin className="w-3 h-3" />
|
||||||
Add
|
Add
|
||||||
@ -87,7 +100,7 @@ export default function MarkersPanel() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Marker list */}
|
{/* Marker list */}
|
||||||
{timelineMarkers.length > 0 && (
|
{timelineMarkers.length > 0 ? (
|
||||||
<div className="space-y-1 max-h-60 overflow-y-auto">
|
<div className="space-y-1 max-h-60 overflow-y-auto">
|
||||||
{timelineMarkers.map((m) => (
|
{timelineMarkers.map((m) => (
|
||||||
<div
|
<div
|
||||||
@ -110,13 +123,19 @@ export default function MarkersPanel() {
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<span className="flex-1 truncate">{m.label}</span>
|
<span className="flex-1 truncate">{m.label}</span>
|
||||||
<button onClick={() => startEdit(m.id, m.label)} className="p-0.5 hover:text-editor-accent"><PencilLine className="w-3 h-3" /></button>
|
<button onClick={() => startEdit(m.id, m.label)} className="p-0.5 hover:text-editor-accent" title="Edit marker label and color"><PencilLine className="w-3 h-3" /></button>
|
||||||
<button onClick={() => removeTimelineMarker(m.id)} className="p-0.5 hover:text-editor-danger"><Trash2 className="w-3 h-3" /></button>
|
<button onClick={() => { if (window.confirm("Delete this marker?")) removeTimelineMarker(m.id); }} className="p-0.5 hover:text-editor-danger" title="Delete this marker"><Trash2 className="w-3 h-3" /></button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-4 rounded border border-dashed border-editor-border text-center">
|
||||||
|
<p className="text-xs text-editor-text-muted">
|
||||||
|
No markers yet. Press I and O on the timeline to set mark in/out points, then add a marker here.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Chapters */}
|
{/* Chapters */}
|
||||||
@ -138,6 +157,7 @@ export default function MarkersPanel() {
|
|||||||
<button
|
<button
|
||||||
onClick={exportChapters}
|
onClick={exportChapters}
|
||||||
className="flex items-center gap-1 text-[10px] text-editor-accent hover:underline"
|
className="flex items-center gap-1 text-[10px] text-editor-accent hover:underline"
|
||||||
|
title="Copy chapter timestamps to clipboard in YouTube format"
|
||||||
>
|
>
|
||||||
<Copy className="w-2.5 h-2.5" />
|
<Copy className="w-2.5 h-2.5" />
|
||||||
Copy as YouTube timestamps
|
Copy as YouTube timestamps
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { useAIStore } from '../store/aiStore';
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import type { AIProvider, KeyBinding, HotkeyPreset } from '../types/project';
|
import type { AIProvider, KeyBinding, HotkeyPreset } from '../types/project';
|
||||||
import { useEditorStore } from '../store/editorStore';
|
import { useEditorStore } from '../store/editorStore';
|
||||||
import { Bot, Cloud, Brain, RefreshCw, Keyboard } from 'lucide-react';
|
import { Bot, Cloud, Brain, RefreshCw, Keyboard, Trash2, HardDrive } from 'lucide-react';
|
||||||
import { loadBindings, saveBindings, applyPreset as applyKeyPreset, DEFAULT_PRESETS, detectConflicts as detectKeyConflicts } from '../lib/keybindings';
|
import { loadBindings, saveBindings, applyPreset as applyKeyPreset, DEFAULT_PRESETS, detectConflicts as detectKeyConflicts } from '../lib/keybindings';
|
||||||
|
|
||||||
export default function SettingsPanel() {
|
export default function SettingsPanel() {
|
||||||
@ -65,11 +65,51 @@ export default function SettingsPanel() {
|
|||||||
persistBindings(bindings.map((b, i) => (i === idx ? { ...existing } : b)));
|
persistBindings(bindings.map((b, i) => (i === idx ? { ...existing } : b)));
|
||||||
};
|
};
|
||||||
|
|
||||||
const [ollamaModels, setOllamaModels] = useState<string[]>([]);
|
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||||
const [loadingModels, setLoadingModels] = useState(false);
|
const [loadingModels, setLoadingModels] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const fetchModels = useCallback(async () => {
|
||||||
|
setLoadingModels(true);
|
||||||
|
try {
|
||||||
|
const list = await window.electronAPI.listModels();
|
||||||
|
setModels(list);
|
||||||
|
} catch {
|
||||||
|
setModels([]);
|
||||||
|
} finally {
|
||||||
|
setLoadingModels(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchModels();
|
||||||
|
}, [fetchModels]);
|
||||||
|
|
||||||
|
const handleDeleteModel = useCallback(async (model: ModelInfo) => {
|
||||||
|
if (deleting) return;
|
||||||
|
setDeleting(model.path);
|
||||||
|
try {
|
||||||
|
await window.electronAPI.deleteModel(model.path);
|
||||||
|
setModels((prev) => prev.filter((m) => m.path !== model.path));
|
||||||
|
} catch {
|
||||||
|
// Model deletion failed silently
|
||||||
|
} finally {
|
||||||
|
setDeleting(null);
|
||||||
|
}
|
||||||
|
}, [deleting]);
|
||||||
|
|
||||||
|
const formatBytes = (bytes: number) => {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const [ollamaModels, setOllamaModels] = useState<string[]>([]);
|
||||||
|
const [loadingOllamaModels, setLoadingOllamaModels] = useState(false);
|
||||||
|
|
||||||
const fetchOllamaModels = useCallback(async () => {
|
const fetchOllamaModels = useCallback(async () => {
|
||||||
setLoadingModels(true);
|
setLoadingOllamaModels(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${backendUrl}/ai/ollama-models`);
|
const res = await fetch(`${backendUrl}/ai/ollama-models`);
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
@ -79,7 +119,7 @@ export default function SettingsPanel() {
|
|||||||
} catch {
|
} catch {
|
||||||
setOllamaModels([]);
|
setOllamaModels([]);
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingModels(false);
|
setLoadingOllamaModels(false);
|
||||||
}
|
}
|
||||||
}, [backendUrl]);
|
}, [backendUrl]);
|
||||||
|
|
||||||
@ -109,6 +149,7 @@ export default function SettingsPanel() {
|
|||||||
value={zonePreviewPaddingSeconds}
|
value={zonePreviewPaddingSeconds}
|
||||||
onChange={(e) => setZonePreviewPaddingSeconds(Number(e.target.value) || 0)}
|
onChange={(e) => setZonePreviewPaddingSeconds(Number(e.target.value) || 0)}
|
||||||
className="flex-1 h-1.5"
|
className="flex-1 h-1.5"
|
||||||
|
title="Extra time in seconds to show before and after each zone during preview"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@ -118,6 +159,7 @@ export default function SettingsPanel() {
|
|||||||
value={zonePreviewPaddingSeconds}
|
value={zonePreviewPaddingSeconds}
|
||||||
onChange={(e) => setZonePreviewPaddingSeconds(Number(e.target.value) || 0)}
|
onChange={(e) => setZonePreviewPaddingSeconds(Number(e.target.value) || 0)}
|
||||||
className="w-16 px-2 py-1 bg-editor-bg border border-editor-border rounded-lg text-xs text-editor-text focus:outline-none focus:border-editor-accent"
|
className="w-16 px-2 py-1 bg-editor-bg border border-editor-border rounded-lg text-xs text-editor-text focus:outline-none focus:border-editor-accent"
|
||||||
|
title="Extra time in seconds to show before and after each zone during preview"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-editor-text-muted w-6">s</span>
|
<span className="text-xs text-editor-text-muted w-6">s</span>
|
||||||
</div>
|
</div>
|
||||||
@ -140,6 +182,7 @@ export default function SettingsPanel() {
|
|||||||
value={confidenceThreshold}
|
value={confidenceThreshold}
|
||||||
onChange={(e) => setConfidenceThreshold(Number(e.target.value))}
|
onChange={(e) => setConfidenceThreshold(Number(e.target.value))}
|
||||||
className="flex-1 h-1.5"
|
className="flex-1 h-1.5"
|
||||||
|
title="Words below this confidence get an orange underline — lower values show fewer warnings"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@ -149,6 +192,7 @@ export default function SettingsPanel() {
|
|||||||
value={confidenceThreshold}
|
value={confidenceThreshold}
|
||||||
onChange={(e) => setConfidenceThreshold(Math.max(0, Math.min(1, Number(e.target.value) || 0)))}
|
onChange={(e) => setConfidenceThreshold(Math.max(0, Math.min(1, Number(e.target.value) || 0)))}
|
||||||
className="w-16 px-2 py-1 bg-editor-bg border border-editor-border rounded-lg text-xs text-editor-text focus:outline-none focus:border-editor-accent"
|
className="w-16 px-2 py-1 bg-editor-bg border border-editor-border rounded-lg text-xs text-editor-text focus:outline-none focus:border-editor-accent"
|
||||||
|
title="Words below this confidence get an orange underline — lower values show fewer warnings"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between text-[10px]">
|
<div className="flex items-center justify-between text-[10px]">
|
||||||
@ -168,12 +212,14 @@ export default function SettingsPanel() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => applyPresetAction('standard')}
|
onClick={() => applyPresetAction('standard')}
|
||||||
className="flex-1 px-2 py-1.5 text-xs rounded bg-editor-accent/20 text-editor-accent hover:bg-editor-accent/30"
|
className="flex-1 px-2 py-1.5 text-xs rounded bg-editor-accent/20 text-editor-accent hover:bg-editor-accent/30"
|
||||||
|
title="Reset all shortcuts to the Standard preset"
|
||||||
>
|
>
|
||||||
Standard Preset
|
Standard Preset
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => applyPresetAction('left-hand')}
|
onClick={() => applyPresetAction('left-hand')}
|
||||||
className="flex-1 px-2 py-1.5 text-xs rounded bg-editor-accent/20 text-editor-accent hover:bg-editor-accent/30"
|
className="flex-1 px-2 py-1.5 text-xs rounded bg-editor-accent/20 text-editor-accent hover:bg-editor-accent/30"
|
||||||
|
title="Reset all shortcuts to the Left-Hand preset"
|
||||||
>
|
>
|
||||||
Left-Hand Preset
|
Left-Hand Preset
|
||||||
</button>
|
</button>
|
||||||
@ -197,10 +243,12 @@ export default function SettingsPanel() {
|
|||||||
onKeyDown={(e) => handleKeyCapture(e, i)}
|
onKeyDown={(e) => handleKeyCapture(e, i)}
|
||||||
className="w-28 px-2 py-1 text-[10px] font-mono bg-editor-bg border border-editor-border rounded text-center focus:outline-none focus:border-editor-accent"
|
className="w-28 px-2 py-1 text-[10px] font-mono bg-editor-bg border border-editor-border rounded text-center focus:outline-none focus:border-editor-accent"
|
||||||
placeholder="Type shortcut"
|
placeholder="Type shortcut"
|
||||||
|
title="Click then press the desired key combination"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleReset(i)}
|
onClick={() => handleReset(i)}
|
||||||
className="text-[10px] text-editor-text-muted hover:text-editor-text px-1"
|
className="text-[10px] text-editor-text-muted hover:text-editor-text px-1"
|
||||||
|
title="Reset this shortcut to default"
|
||||||
>
|
>
|
||||||
↺
|
↺
|
||||||
</button>
|
</button>
|
||||||
@ -220,6 +268,11 @@ export default function SettingsPanel() {
|
|||||||
<button
|
<button
|
||||||
key={p}
|
key={p}
|
||||||
onClick={() => setDefaultProvider(p)}
|
onClick={() => setDefaultProvider(p)}
|
||||||
|
title={`Use ${p.charAt(0).toUpperCase() + p.slice(1)} for AI features — ${
|
||||||
|
p === 'ollama' ? 'Use a local Ollama instance' :
|
||||||
|
p === 'openai' ? "Use OpenAI's API (requires API key)" :
|
||||||
|
"Use Anthropic's Claude API (requires API key)"
|
||||||
|
}`}
|
||||||
className={`flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors text-[10px] ${
|
className={`flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors text-[10px] ${
|
||||||
defaultProvider === p
|
defaultProvider === p
|
||||||
? 'border-editor-accent bg-editor-accent/10 text-editor-accent'
|
? 'border-editor-accent bg-editor-accent/10 text-editor-accent'
|
||||||
@ -233,6 +286,50 @@ export default function SettingsPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Manage downloaded models */}
|
||||||
|
<div className="space-y-2 pt-1 border-t border-editor-border">
|
||||||
|
<h4 className="text-xs font-semibold flex items-center gap-1.5">
|
||||||
|
<HardDrive className="w-3.5 h-3.5" />
|
||||||
|
Manage Models
|
||||||
|
</h4>
|
||||||
|
<p className="text-[10px] text-editor-text-muted leading-relaxed">
|
||||||
|
Downloaded Whisper transcription models and bundled LLM files.
|
||||||
|
</p>
|
||||||
|
{models.length === 0 ? (
|
||||||
|
<p className="text-xs text-editor-text-muted">No downloaded models found.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{models.map((m) => (
|
||||||
|
<div key={m.path} className="flex items-center gap-2 p-2 rounded bg-editor-bg border border-editor-border">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-xs text-editor-text truncate">{m.name}</p>
|
||||||
|
<p className="text-[10px] text-editor-text-muted">
|
||||||
|
{formatBytes(m.size_bytes)} · {m.kind === 'whisper' ? 'Whisper' : 'LLM'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => handleDeleteModel(m)}
|
||||||
|
disabled={deleting === m.path}
|
||||||
|
className="p-1.5 rounded text-editor-text-muted hover:text-red-400 hover:bg-red-500/10 transition-colors disabled:opacity-40"
|
||||||
|
title="Delete model"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={fetchModels}
|
||||||
|
disabled={loadingModels}
|
||||||
|
className="text-[10px] text-editor-accent hover:underline flex items-center gap-0.5"
|
||||||
|
title="Refresh list of downloaded models"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-2.5 h-2.5 ${loadingModels ? 'animate-spin' : ''}`} />
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h4 className="text-xs font-semibold uppercase tracking-wide text-editor-text-muted">AI Settings</h4>
|
<h4 className="text-xs font-semibold uppercase tracking-wide text-editor-text-muted">AI Settings</h4>
|
||||||
|
|
||||||
{/* Ollama settings */}
|
{/* Ollama settings */}
|
||||||
@ -242,16 +339,18 @@ export default function SettingsPanel() {
|
|||||||
value={providers.ollama.baseUrl || ''}
|
value={providers.ollama.baseUrl || ''}
|
||||||
onChange={(v) => setProviderConfig('ollama', { baseUrl: v })}
|
onChange={(v) => setProviderConfig('ollama', { baseUrl: v })}
|
||||||
placeholder="http://localhost:11434"
|
placeholder="http://localhost:11434"
|
||||||
|
title="URL of your Ollama instance — http://localhost:11434 by default"
|
||||||
/>
|
/>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<label className="text-xs text-editor-text-muted">Model</label>
|
<label className="text-xs text-editor-text-muted">Model</label>
|
||||||
<button
|
<button
|
||||||
onClick={fetchOllamaModels}
|
onClick={fetchOllamaModels}
|
||||||
disabled={loadingModels}
|
disabled={loadingOllamaModels}
|
||||||
className="text-[10px] text-editor-accent hover:underline flex items-center gap-0.5"
|
className="text-[10px] text-editor-accent hover:underline flex items-center gap-0.5"
|
||||||
|
title="Refresh available Ollama models"
|
||||||
>
|
>
|
||||||
<RefreshCw className={`w-2.5 h-2.5 ${loadingModels ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`w-2.5 h-2.5 ${loadingOllamaModels ? 'animate-spin' : ''}`} />
|
||||||
Refresh
|
Refresh
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -260,6 +359,7 @@ export default function SettingsPanel() {
|
|||||||
value={providers.ollama.model}
|
value={providers.ollama.model}
|
||||||
onChange={(e) => setProviderConfig('ollama', { model: e.target.value })}
|
onChange={(e) => setProviderConfig('ollama', { model: e.target.value })}
|
||||||
className="w-full px-3 py-2 bg-editor-surface border border-editor-border rounded-lg text-xs text-white focus:outline-none focus:border-editor-accent"
|
className="w-full px-3 py-2 bg-editor-surface border border-editor-border rounded-lg text-xs text-white focus:outline-none focus:border-editor-accent"
|
||||||
|
title="Which Ollama model to use for AI features"
|
||||||
>
|
>
|
||||||
{ollamaModels.map((m) => (
|
{ollamaModels.map((m) => (
|
||||||
<option key={m} value={m}>{m}</option>
|
<option key={m} value={m}>{m}</option>
|
||||||
@ -271,6 +371,7 @@ export default function SettingsPanel() {
|
|||||||
value={providers.ollama.model}
|
value={providers.ollama.model}
|
||||||
onChange={(v) => setProviderConfig('ollama', { model: v })}
|
onChange={(v) => setProviderConfig('ollama', { model: v })}
|
||||||
placeholder="llama3"
|
placeholder="llama3"
|
||||||
|
title="Which Ollama model to use for AI features"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -284,12 +385,14 @@ export default function SettingsPanel() {
|
|||||||
onChange={(v) => setProviderConfig('openai', { apiKey: v })}
|
onChange={(v) => setProviderConfig('openai', { apiKey: v })}
|
||||||
placeholder="sk-..."
|
placeholder="sk-..."
|
||||||
type="password"
|
type="password"
|
||||||
|
title="Your OpenAI API key — stored encrypted on your machine"
|
||||||
/>
|
/>
|
||||||
<InputField
|
<InputField
|
||||||
label="Model"
|
label="Model"
|
||||||
value={providers.openai.model}
|
value={providers.openai.model}
|
||||||
onChange={(v) => setProviderConfig('openai', { model: v })}
|
onChange={(v) => setProviderConfig('openai', { model: v })}
|
||||||
placeholder="gpt-4o"
|
placeholder="gpt-4o"
|
||||||
|
title="OpenAI model to use (e.g. gpt-4o, gpt-4o-mini)"
|
||||||
/>
|
/>
|
||||||
</ProviderSection>
|
</ProviderSection>
|
||||||
|
|
||||||
@ -301,12 +404,14 @@ export default function SettingsPanel() {
|
|||||||
onChange={(v) => setProviderConfig('claude', { apiKey: v })}
|
onChange={(v) => setProviderConfig('claude', { apiKey: v })}
|
||||||
placeholder="sk-ant-..."
|
placeholder="sk-ant-..."
|
||||||
type="password"
|
type="password"
|
||||||
|
title="Your Anthropic Claude API key — stored encrypted on your machine"
|
||||||
/>
|
/>
|
||||||
<InputField
|
<InputField
|
||||||
label="Model"
|
label="Model"
|
||||||
value={providers.claude.model}
|
value={providers.claude.model}
|
||||||
onChange={(v) => setProviderConfig('claude', { model: v })}
|
onChange={(v) => setProviderConfig('claude', { model: v })}
|
||||||
placeholder="claude-sonnet-4-20250514"
|
placeholder="claude-sonnet-4-20250514"
|
||||||
|
title="Claude model to use (e.g. claude-sonnet-4-20250514)"
|
||||||
/>
|
/>
|
||||||
</ProviderSection>
|
</ProviderSection>
|
||||||
</div>
|
</div>
|
||||||
@ -339,12 +444,14 @@ function InputField({
|
|||||||
onChange,
|
onChange,
|
||||||
placeholder,
|
placeholder,
|
||||||
type = 'text',
|
type = 'text',
|
||||||
|
title,
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
onChange: (value: string) => void;
|
onChange: (value: string) => void;
|
||||||
placeholder: string;
|
placeholder: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
|
title?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
@ -354,6 +461,7 @@ function InputField({
|
|||||||
value={value}
|
value={value}
|
||||||
onChange={(e) => onChange(e.target.value)}
|
onChange={(e) => onChange(e.target.value)}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
title={title}
|
||||||
className="w-full px-3 py-2 bg-editor-bg border border-editor-border rounded-lg text-xs text-editor-text placeholder:text-editor-text-muted/50 focus:outline-none focus:border-editor-accent"
|
className="w-full px-3 py-2 bg-editor-bg border border-editor-border rounded-lg text-xs text-editor-text placeholder:text-editor-text-muted/50 focus:outline-none focus:border-editor-accent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -134,6 +134,7 @@ export default function SilenceTrimmerPanel() {
|
|||||||
value={minSilenceMs}
|
value={minSilenceMs}
|
||||||
onChange={(e) => setMinSilenceMs(Number(e.target.value) || 500)}
|
onChange={(e) => setMinSilenceMs(Number(e.target.value) || 500)}
|
||||||
className="w-full px-2.5 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
|
className="w-full px-2.5 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
|
||||||
|
title="Minimum duration of silence to detect in milliseconds"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -149,6 +150,7 @@ export default function SilenceTrimmerPanel() {
|
|||||||
value={silenceDb}
|
value={silenceDb}
|
||||||
onChange={(e) => setSilenceDb(Number(e.target.value) || -35)}
|
onChange={(e) => setSilenceDb(Number(e.target.value) || -35)}
|
||||||
className="w-full px-2.5 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
|
className="w-full px-2.5 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
|
||||||
|
title="Volume threshold in dB — lower values detect quieter sounds as silence"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -165,6 +167,7 @@ export default function SilenceTrimmerPanel() {
|
|||||||
value={preBufferMs}
|
value={preBufferMs}
|
||||||
onChange={(e) => setPreBufferMs(Number(e.target.value) || 0)}
|
onChange={(e) => setPreBufferMs(Number(e.target.value) || 0)}
|
||||||
className="w-full px-2.5 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
|
className="w-full px-2.5 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
|
||||||
|
title="Extra time to add before each detected silence"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
@ -179,6 +182,7 @@ export default function SilenceTrimmerPanel() {
|
|||||||
value={postBufferMs}
|
value={postBufferMs}
|
||||||
onChange={(e) => setPostBufferMs(Number(e.target.value) || 0)}
|
onChange={(e) => setPostBufferMs(Number(e.target.value) || 0)}
|
||||||
className="w-full px-2.5 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
|
className="w-full px-2.5 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
|
||||||
|
title="Extra time to add after each detected silence"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -186,7 +190,8 @@ export default function SilenceTrimmerPanel() {
|
|||||||
<button
|
<button
|
||||||
onClick={detectSilence}
|
onClick={detectSilence}
|
||||||
disabled={isDetecting || !videoPath}
|
disabled={isDetecting || !videoPath}
|
||||||
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-50 rounded-lg text-sm font-medium transition-colors"
|
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-40 rounded-lg text-sm font-medium transition-colors"
|
||||||
|
title="Scan the entire audio track for silent pauses"
|
||||||
>
|
>
|
||||||
{isDetecting ? (
|
{isDetecting ? (
|
||||||
<>
|
<>
|
||||||
@ -214,6 +219,7 @@ export default function SilenceTrimmerPanel() {
|
|||||||
<button
|
<button
|
||||||
onClick={reapplySelectedGroup}
|
onClick={reapplySelectedGroup}
|
||||||
className="flex items-center gap-1 px-2 py-1 text-xs bg-editor-warning/20 text-editor-warning rounded hover:bg-editor-warning/30"
|
className="flex items-center gap-1 px-2 py-1 text-xs bg-editor-warning/20 text-editor-warning rounded hover:bg-editor-warning/30"
|
||||||
|
title="Re-apply this silence trim group with current settings"
|
||||||
>
|
>
|
||||||
<RotateCcw className="w-3 h-3" />
|
<RotateCcw className="w-3 h-3" />
|
||||||
Reapply Group
|
Reapply Group
|
||||||
@ -222,6 +228,7 @@ export default function SilenceTrimmerPanel() {
|
|||||||
<button
|
<button
|
||||||
onClick={applyAsNewGroup}
|
onClick={applyAsNewGroup}
|
||||||
className="flex items-center gap-1 px-2 py-1 text-xs bg-editor-accent/20 text-editor-accent rounded hover:bg-editor-accent/30"
|
className="flex items-center gap-1 px-2 py-1 text-xs bg-editor-accent/20 text-editor-accent rounded hover:bg-editor-accent/30"
|
||||||
|
title="Create a new silence trim group from detected pauses"
|
||||||
>
|
>
|
||||||
<Scissors className="w-3 h-3" />
|
<Scissors className="w-3 h-3" />
|
||||||
Apply As New Group
|
Apply As New Group
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
import { useCallback, useRef, useEffect, useMemo, useState } from 'react';
|
import { useCallback, useRef, useEffect, useMemo, useState } from 'react';
|
||||||
import { useEditorStore } from '../store/editorStore';
|
import { useEditorStore } from '../store/editorStore';
|
||||||
|
import { useLicenseStore } from '../store/licenseStore';
|
||||||
import { Virtuoso } from 'react-virtuoso';
|
import { Virtuoso } from 'react-virtuoso';
|
||||||
import { Scissors, VolumeX, SlidersHorizontal, Gauge, RotateCcw, Search, ChevronUp, ChevronDown, X, RefreshCw } from 'lucide-react';
|
import { Scissors, VolumeX, SlidersHorizontal, Gauge, RotateCcw, Search, ChevronUp, ChevronDown, X, RefreshCw } from 'lucide-react';
|
||||||
|
import { assert } from '../lib/assert';
|
||||||
|
|
||||||
interface TranscriptEditorProps {
|
interface TranscriptEditorProps {
|
||||||
cutMode: boolean;
|
cutMode: boolean;
|
||||||
@ -42,6 +44,7 @@ export default function TranscriptEditor({
|
|||||||
const addGainRange = useEditorStore((s) => s.addGainRange);
|
const addGainRange = useEditorStore((s) => s.addGainRange);
|
||||||
const addSpeedRange = useEditorStore((s) => s.addSpeedRange);
|
const addSpeedRange = useEditorStore((s) => s.addSpeedRange);
|
||||||
const getWordAtTime = useEditorStore((s) => s.getWordAtTime);
|
const getWordAtTime = useEditorStore((s) => s.getWordAtTime);
|
||||||
|
const canEdit = useLicenseStore((s) => s.canEdit);
|
||||||
|
|
||||||
const selectionStart = useRef<number | null>(null);
|
const selectionStart = useRef<number | null>(null);
|
||||||
const wasDragging = useRef(false);
|
const wasDragging = useRef(false);
|
||||||
@ -204,9 +207,11 @@ export default function TranscriptEditor({
|
|||||||
|
|
||||||
const handleMouseUp = useCallback(() => {
|
const handleMouseUp = useCallback(() => {
|
||||||
if (zoneDragStart.current !== null && zoneDragRange) {
|
if (zoneDragStart.current !== null && zoneDragRange) {
|
||||||
|
assert(zoneDragRange.start >= 0 && zoneDragRange.start < words.length, 'handleMouseUp: zoneDragRange.start out of bounds');
|
||||||
|
assert(zoneDragRange.end >= 0 && zoneDragRange.end < words.length, 'handleMouseUp: zoneDragRange.end out of bounds');
|
||||||
const startWord = words[zoneDragRange.start];
|
const startWord = words[zoneDragRange.start];
|
||||||
const endWord = words[zoneDragRange.end];
|
const endWord = words[zoneDragRange.end];
|
||||||
if (startWord && endWord) {
|
if (startWord && endWord && canEdit) {
|
||||||
if (cutMode) addCutRange(startWord.start, endWord.end);
|
if (cutMode) addCutRange(startWord.start, endWord.end);
|
||||||
if (muteMode) addMuteRange(startWord.start, endWord.end);
|
if (muteMode) addMuteRange(startWord.start, endWord.end);
|
||||||
if (gainMode) addGainRange(startWord.start, endWord.end, gainModeDb);
|
if (gainMode) addGainRange(startWord.start, endWord.end, gainModeDb);
|
||||||
@ -216,7 +221,7 @@ export default function TranscriptEditor({
|
|||||||
zoneDragStart.current = null;
|
zoneDragStart.current = null;
|
||||||
setZoneDragRange(null);
|
setZoneDragRange(null);
|
||||||
selectionStart.current = null;
|
selectionStart.current = null;
|
||||||
}, [zoneDragRange, words, cutMode, muteMode, gainMode, gainModeDb, speedMode, speedModeValue, addCutRange, addMuteRange, addGainRange, addSpeedRange]);
|
}, [zoneDragRange, words, cutMode, muteMode, gainMode, gainModeDb, speedMode, speedModeValue, addCutRange, addMuteRange, addGainRange, addSpeedRange, canEdit]);
|
||||||
|
|
||||||
const handleClickOutside = useCallback(
|
const handleClickOutside = useCallback(
|
||||||
(e: React.MouseEvent) => {
|
(e: React.MouseEvent) => {
|
||||||
@ -267,6 +272,7 @@ export default function TranscriptEditor({
|
|||||||
|
|
||||||
// Snapshot indices and word timings before the async gap
|
// Snapshot indices and word timings before the async gap
|
||||||
const sorted = [...selectedWordIndices].sort((a, b) => a - b);
|
const sorted = [...selectedWordIndices].sort((a, b) => a - b);
|
||||||
|
assert(sorted[0] >= 0 && sorted[sorted.length - 1] < words.length, 'handleReTranscribe: sorted indices out of bounds');
|
||||||
const startWord = words[sorted[0]];
|
const startWord = words[sorted[0]];
|
||||||
const endWord = words[sorted[sorted.length - 1]];
|
const endWord = words[sorted[sorted.length - 1]];
|
||||||
if (!startWord || !endWord) {
|
if (!startWord || !endWord) {
|
||||||
@ -303,8 +309,9 @@ export default function TranscriptEditor({
|
|||||||
|
|
||||||
const handleWordDoubleClick = useCallback((index: number) => {
|
const handleWordDoubleClick = useCallback((index: number) => {
|
||||||
if (cutMode || muteMode || gainMode || speedMode) return;
|
if (cutMode || muteMode || gainMode || speedMode) return;
|
||||||
|
if (!canEdit) return;
|
||||||
startEditing(index);
|
startEditing(index);
|
||||||
}, [cutMode, muteMode, gainMode, speedMode, startEditing]);
|
}, [cutMode, muteMode, gainMode, speedMode, startEditing, canEdit]);
|
||||||
|
|
||||||
// Focus edit input when it appears
|
// Focus edit input when it appears
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -333,6 +340,8 @@ export default function TranscriptEditor({
|
|||||||
const cutSelectedWords = useCallback(() => {
|
const cutSelectedWords = useCallback(() => {
|
||||||
if (selectedWordIndices.length === 0) return;
|
if (selectedWordIndices.length === 0) return;
|
||||||
const sorted = [...selectedWordIndices].sort((a, b) => a - b);
|
const sorted = [...selectedWordIndices].sort((a, b) => a - b);
|
||||||
|
assert(sorted[0] >= 0 && sorted[0] < words.length, 'cutSelectedWords: sorted[0] out of bounds');
|
||||||
|
assert(sorted[sorted.length - 1] >= 0 && sorted[sorted.length - 1] < words.length, 'cutSelectedWords: sorted[last] out of bounds');
|
||||||
const startTime = words[sorted[0]].start;
|
const startTime = words[sorted[0]].start;
|
||||||
const endTime = words[sorted[sorted.length - 1]].end;
|
const endTime = words[sorted[sorted.length - 1]].end;
|
||||||
addCutRange(startTime, endTime);
|
addCutRange(startTime, endTime);
|
||||||
@ -454,8 +463,8 @@ export default function TranscriptEditor({
|
|||||||
${isZoneDragSelected && muteMode ? 'bg-blue-500/30 ring-1 ring-blue-400/60' : ''}
|
${isZoneDragSelected && muteMode ? 'bg-blue-500/30 ring-1 ring-blue-400/60' : ''}
|
||||||
${isZoneDragSelected && gainMode ? 'bg-amber-500/30 ring-1 ring-amber-400/60' : ''}
|
${isZoneDragSelected && gainMode ? 'bg-amber-500/30 ring-1 ring-amber-400/60' : ''}
|
||||||
${isZoneDragSelected && speedMode ? 'bg-emerald-500/30 ring-1 ring-emerald-400/60' : ''}
|
${isZoneDragSelected && speedMode ? 'bg-emerald-500/30 ring-1 ring-emerald-400/60' : ''}
|
||||||
${isSearchMatch && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-accent/15 ring-1 ring-editor-accent/35' : ''}
|
${isSearchMatch && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-accent/15 ring-2 ring-editor-accent/50' : ''}
|
||||||
${isActiveSearchMatch && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-accent/35 ring-1 ring-editor-accent text-white' : ''}
|
${isActiveSearchMatch && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-accent/35 ring-2 ring-editor-accent text-white font-medium' : ''}
|
||||||
${isSelected && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-word-selected text-white' : ''}
|
${isSelected && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-word-selected text-white' : ''}
|
||||||
${isActive && !isSelected && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-accent/20 text-editor-accent' : ''}
|
${isActive && !isSelected && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-accent/20 text-editor-accent' : ''}
|
||||||
${isHovered && !isSelected && !isActive && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-word-hover' : ''}
|
${isHovered && !isSelected && !isActive && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-word-hover' : ''}
|
||||||
@ -556,35 +565,43 @@ export default function TranscriptEditor({
|
|||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={cutSelectedWords}
|
onClick={cutSelectedWords}
|
||||||
className="flex items-center gap-1 px-2 py-1 text-xs bg-red-500/20 text-red-300 rounded hover:bg-red-500/30 transition-colors"
|
disabled={!canEdit}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 text-xs bg-red-500/20 text-red-300 rounded hover:bg-red-500/30 transition-colors disabled:opacity-40"
|
||||||
|
title="Remove this word range from the output"
|
||||||
>
|
>
|
||||||
<Scissors className="w-3 h-3" />
|
<Scissors className="w-3 h-3" />
|
||||||
Cut
|
Cut
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={muteSelectedWords}
|
onClick={muteSelectedWords}
|
||||||
className="flex items-center gap-1 px-2 py-1 text-xs bg-blue-500/20 text-blue-300 rounded hover:bg-blue-500/30 transition-colors"
|
disabled={!canEdit}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 text-xs bg-blue-500/20 text-blue-300 rounded hover:bg-blue-500/30 transition-colors disabled:opacity-40"
|
||||||
|
title="Silence audio for this word range"
|
||||||
>
|
>
|
||||||
<VolumeX className="w-3 h-3" />
|
<VolumeX className="w-3 h-3" />
|
||||||
Mute
|
Mute
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={gainSelectedWords}
|
onClick={gainSelectedWords}
|
||||||
className="flex items-center gap-1 px-2 py-1 text-xs bg-amber-500/20 text-amber-300 rounded hover:bg-amber-500/30 transition-colors"
|
disabled={!canEdit}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 text-xs bg-amber-500/20 text-amber-300 rounded hover:bg-amber-500/30 transition-colors disabled:opacity-40"
|
||||||
|
title="Adjust volume for this word range — positive boosts, negative reduces"
|
||||||
>
|
>
|
||||||
<SlidersHorizontal className="w-3 h-3" />
|
<SlidersHorizontal className="w-3 h-3" />
|
||||||
Gain ({gainModeDb > 0 ? '+' : ''}{gainModeDb.toFixed(1)} dB)
|
Gain ({gainModeDb > 0 ? '+' : ''}{gainModeDb.toFixed(1)} dB)
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={speedSelectedWords}
|
onClick={speedSelectedWords}
|
||||||
className="flex items-center gap-1 px-2 py-1 text-xs bg-emerald-500/20 text-emerald-300 rounded hover:bg-emerald-500/30 transition-colors"
|
disabled={!canEdit}
|
||||||
|
className="flex items-center gap-1 px-2 py-1 text-xs bg-emerald-500/20 text-emerald-300 rounded hover:bg-emerald-500/30 transition-colors disabled:opacity-40"
|
||||||
|
title="Change playback speed for this word range — lower is slower, higher is faster"
|
||||||
>
|
>
|
||||||
<Gauge className="w-3 h-3" />
|
<Gauge className="w-3 h-3" />
|
||||||
Speed {speedModeValue.toFixed(2)}x
|
Speed {speedModeValue.toFixed(2)}x
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleReTranscribe}
|
onClick={handleReTranscribe}
|
||||||
disabled={isReTranscribing}
|
disabled={isReTranscribing || !canEdit}
|
||||||
className="flex items-center gap-1 px-2 py-1 text-xs bg-purple-500/20 text-purple-300 rounded hover:bg-purple-500/30 disabled:opacity-40 transition-colors"
|
className="flex items-center gap-1 px-2 py-1 text-xs bg-purple-500/20 text-purple-300 rounded hover:bg-purple-500/30 disabled:opacity-40 transition-colors"
|
||||||
title="Re-run Whisper transcription on this segment"
|
title="Re-run Whisper transcription on this segment"
|
||||||
>
|
>
|
||||||
|
|||||||
@ -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,7 +1,9 @@
|
|||||||
import { useRef, useEffect, useCallback, useState, useMemo } from 'react';
|
import { useRef, useEffect, useCallback, useState, useMemo } from 'react';
|
||||||
import { useEditorStore } from '../store/editorStore';
|
import { useEditorStore } from '../store/editorStore';
|
||||||
|
import { useLicenseStore } from '../store/licenseStore';
|
||||||
import { AlertTriangle } from 'lucide-react';
|
import { AlertTriangle } from 'lucide-react';
|
||||||
import { extractThumbnails } from '../lib/thumbnails';
|
import { extractThumbnails } from '../lib/thumbnails';
|
||||||
|
import { assert } from '../lib/assert';
|
||||||
|
|
||||||
const RULER_H = 20; // px reserved at top of canvas for the time ruler
|
const RULER_H = 20; // px reserved at top of canvas for the time ruler
|
||||||
const COLLAPSED_CUT_DISPLAY_SECONDS = 0.08;
|
const COLLAPSED_CUT_DISPLAY_SECONDS = 0.08;
|
||||||
@ -235,6 +237,7 @@ export default function WaveformTimeline({
|
|||||||
const headCanvasRef = useRef<HTMLCanvasElement>(null);
|
const headCanvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [audioError, setAudioError] = useState<string | null>(null);
|
const [audioError, setAudioError] = useState<string | null>(null);
|
||||||
|
const [waveformReady, setWaveformReady] = useState(false);
|
||||||
|
|
||||||
const videoUrl = useEditorStore((s) => s.videoUrl);
|
const videoUrl = useEditorStore((s) => s.videoUrl);
|
||||||
const videoPath = useEditorStore((s) => s.videoPath);
|
const videoPath = useEditorStore((s) => s.videoPath);
|
||||||
@ -264,6 +267,7 @@ export default function WaveformTimeline({
|
|||||||
const removeMuteRange = useEditorStore((s) => s.removeMuteRange);
|
const removeMuteRange = useEditorStore((s) => s.removeMuteRange);
|
||||||
const removeGainRange = useEditorStore((s) => s.removeGainRange);
|
const removeGainRange = useEditorStore((s) => s.removeGainRange);
|
||||||
const removeSpeedRange = useEditorStore((s) => s.removeSpeedRange);
|
const removeSpeedRange = useEditorStore((s) => s.removeSpeedRange);
|
||||||
|
const canEdit = useLicenseStore((s) => s.canEdit);
|
||||||
|
|
||||||
const waveformDataRef = useRef<WaveformData | null>(null);
|
const waveformDataRef = useRef<WaveformData | null>(null);
|
||||||
const zoomRef = useRef(1); // 1 = show all, >1 = zoomed in
|
const zoomRef = useRef(1); // 1 = show all, >1 = zoomed in
|
||||||
@ -287,10 +291,9 @@ export default function WaveformTimeline({
|
|||||||
const [showAdjustedTimeline, setShowAdjustedTimeline] = useState(false);
|
const [showAdjustedTimeline, setShowAdjustedTimeline] = useState(false);
|
||||||
|
|
||||||
const sourceDuration = duration || waveformDataRef.current?.duration || 0;
|
const sourceDuration = duration || waveformDataRef.current?.duration || 0;
|
||||||
const timelineCutRanges = showAdjustedTimeline ? cutRanges : [];
|
|
||||||
const { segments: timelineSegments, displayDuration } = useMemo(
|
const { segments: timelineSegments, displayDuration } = useMemo(
|
||||||
() => buildTimelineSegments(sourceDuration, timelineCutRanges),
|
() => buildTimelineSegments(sourceDuration, showAdjustedTimeline ? cutRanges : []),
|
||||||
[sourceDuration, timelineCutRanges],
|
[sourceDuration, cutRanges, showAdjustedTimeline],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -347,6 +350,7 @@ export default function WaveformTimeline({
|
|||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
waveformDataRef.current = waveformData;
|
waveformDataRef.current = waveformData;
|
||||||
drawStaticWaveformRef.current();
|
drawStaticWaveformRef.current();
|
||||||
|
setWaveformReady(true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (cancelled || (err instanceof DOMException && err.name === 'AbortError')) {
|
if (cancelled || (err instanceof DOMException && err.name === 'AbortError')) {
|
||||||
console.log('[WaveformTimeline] req=', requestId, 'aborted/cancelled');
|
console.log('[WaveformTimeline] req=', requestId, 'aborted/cancelled');
|
||||||
@ -472,10 +476,10 @@ export default function WaveformTimeline({
|
|||||||
// Draw resize handles
|
// Draw resize handles
|
||||||
ctx.fillStyle = '#ef4444';
|
ctx.fillStyle = '#ef4444';
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(x1, waveTop + waveH / 2, 4, 0, 2 * Math.PI);
|
ctx.arc(x1, waveTop + waveH / 2, 6, 0, 2 * Math.PI);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(x2, waveTop + waveH / 2, 4, 0, 2 * Math.PI);
|
ctx.arc(x2, waveTop + waveH / 2, 6, 0, 2 * Math.PI);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -497,10 +501,10 @@ export default function WaveformTimeline({
|
|||||||
// Draw resize handles
|
// Draw resize handles
|
||||||
ctx.fillStyle = '#3b82f6';
|
ctx.fillStyle = '#3b82f6';
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(x1, waveTop + waveH / 2, 4, 0, 2 * Math.PI);
|
ctx.arc(x1, waveTop + waveH / 2, 6, 0, 2 * Math.PI);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(x2, waveTop + waveH / 2, 4, 0, 2 * Math.PI);
|
ctx.arc(x2, waveTop + waveH / 2, 6, 0, 2 * Math.PI);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -521,10 +525,10 @@ export default function WaveformTimeline({
|
|||||||
|
|
||||||
ctx.fillStyle = '#f59e0b';
|
ctx.fillStyle = '#f59e0b';
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(x1, waveTop + waveH / 2, 4, 0, 2 * Math.PI);
|
ctx.arc(x1, waveTop + waveH / 2, 6, 0, 2 * Math.PI);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(x2, waveTop + waveH / 2, 4, 0, 2 * Math.PI);
|
ctx.arc(x2, waveTop + waveH / 2, 6, 0, 2 * Math.PI);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -545,10 +549,10 @@ export default function WaveformTimeline({
|
|||||||
|
|
||||||
ctx.fillStyle = '#10b981';
|
ctx.fillStyle = '#10b981';
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(x1, waveTop + waveH / 2, 4, 0, 2 * Math.PI);
|
ctx.arc(x1, waveTop + waveH / 2, 6, 0, 2 * Math.PI);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(x2, waveTop + waveH / 2, 4, 0, 2 * Math.PI);
|
ctx.arc(x2, waveTop + waveH / 2, 6, 0, 2 * Math.PI);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -687,7 +691,6 @@ export default function WaveformTimeline({
|
|||||||
gainMode,
|
gainMode,
|
||||||
speedMode,
|
speedMode,
|
||||||
selectedZone,
|
selectedZone,
|
||||||
showAdjustedTimeline,
|
|
||||||
markInTime,
|
markInTime,
|
||||||
markOutTime,
|
markOutTime,
|
||||||
displayDuration,
|
displayDuration,
|
||||||
@ -696,6 +699,7 @@ export default function WaveformTimeline({
|
|||||||
showGainZones,
|
showGainZones,
|
||||||
showSpeedZones,
|
showSpeedZones,
|
||||||
timelineSegments,
|
timelineSegments,
|
||||||
|
timelineMarkers,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Keep the ref in sync with the latest drawStaticWaveform closure
|
// Keep the ref in sync with the latest drawStaticWaveform closure
|
||||||
@ -820,6 +824,7 @@ export default function WaveformTimeline({
|
|||||||
}, [displayDuration, setCurrentTime, timelineSegments]);
|
}, [displayDuration, setCurrentTime, timelineSegments]);
|
||||||
|
|
||||||
const clientXToTime = useCallback((clientX: number): number => {
|
const clientXToTime = useCallback((clientX: number): number => {
|
||||||
|
assert(headCanvasRef.current !== null, 'clientXToTime: headCanvasRef.current is null');
|
||||||
const canvas = headCanvasRef.current;
|
const canvas = headCanvasRef.current;
|
||||||
const dur = waveformDataRef.current?.duration;
|
const dur = waveformDataRef.current?.duration;
|
||||||
if (!canvas || !dur) return 0;
|
if (!canvas || !dur) return 0;
|
||||||
@ -848,7 +853,7 @@ export default function WaveformTimeline({
|
|||||||
// Check if click is in waveform area
|
// Check if click is in waveform area
|
||||||
if (y < waveTop || y > waveTop + waveH) return null;
|
if (y < waveTop || y > waveTop + waveH) return null;
|
||||||
|
|
||||||
const handleSize = forHover ? 6 : 8; // Smaller hit area for hover, larger for click
|
const handleSize = forHover ? 8 : 10;
|
||||||
|
|
||||||
// Check cut ranges
|
// Check cut ranges
|
||||||
for (const range of showCutZones ? cutRanges : []) {
|
for (const range of showCutZones ? cutRanges : []) {
|
||||||
@ -1026,7 +1031,7 @@ export default function WaveformTimeline({
|
|||||||
|
|
||||||
// Check if clicking on a zone
|
// Check if clicking on a zone
|
||||||
const zoneHit = getZoneAtPosition(e.clientX, e.clientY);
|
const zoneHit = getZoneAtPosition(e.clientX, e.clientY);
|
||||||
if (zoneHit) {
|
if (zoneHit && canEdit) {
|
||||||
if (zoneHit.edge === 'move') {
|
if (zoneHit.edge === 'move') {
|
||||||
setSelectedZone({ type: zoneHit.type, id: zoneHit.id });
|
setSelectedZone({ type: zoneHit.type, id: zoneHit.id });
|
||||||
} else {
|
} else {
|
||||||
@ -1099,7 +1104,7 @@ export default function WaveformTimeline({
|
|||||||
// Clear selection if clicking elsewhere
|
// Clear selection if clicking elsewhere
|
||||||
setSelectedZone(null);
|
setSelectedZone(null);
|
||||||
|
|
||||||
if (cutMode || muteMode || gainMode || speedMode) {
|
if (canEdit && (cutMode || muteMode || gainMode || speedMode)) {
|
||||||
// Range selection mode
|
// Range selection mode
|
||||||
const startTime = clientXToTime(e.clientX);
|
const startTime = clientXToTime(e.clientX);
|
||||||
selectionStartRef.current = startTime;
|
selectionStartRef.current = startTime;
|
||||||
@ -1121,6 +1126,8 @@ export default function WaveformTimeline({
|
|||||||
setIsDragging(false);
|
setIsDragging(false);
|
||||||
|
|
||||||
if (selectionStartRef.current !== null && selectionEndRef.current !== null) {
|
if (selectionStartRef.current !== null && selectionEndRef.current !== null) {
|
||||||
|
assert(selectionStartRef.current !== null, 'handleMouseDown: selectionStartRef is null');
|
||||||
|
assert(selectionEndRef.current !== null, 'handleMouseDown: selectionEndRef is null');
|
||||||
const start = Math.min(selectionStartRef.current, selectionEndRef.current);
|
const start = Math.min(selectionStartRef.current, selectionEndRef.current);
|
||||||
const end = Math.max(selectionStartRef.current, selectionEndRef.current);
|
const end = Math.max(selectionStartRef.current, selectionEndRef.current);
|
||||||
const minDuration = 0.01;
|
const minDuration = 0.01;
|
||||||
@ -1182,11 +1189,12 @@ export default function WaveformTimeline({
|
|||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
setSelectedZone(null);
|
setSelectedZone(null);
|
||||||
editingZoneRef.current = null;
|
editingZoneRef.current = null;
|
||||||
} else if (e.key === 'Delete' || e.key === 'Backspace') {
|
} else if (canEdit && (e.key === 'Delete' || e.key === 'Backspace')) {
|
||||||
if (selectedZone) {
|
if (selectedZone) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.stopImmediatePropagation();
|
e.stopImmediatePropagation();
|
||||||
|
if (!window.confirm("Delete this zone?")) return;
|
||||||
if (selectedZone.type === 'cut') {
|
if (selectedZone.type === 'cut') {
|
||||||
removeCutRange(selectedZone.id);
|
removeCutRange(selectedZone.id);
|
||||||
} else if (selectedZone.type === 'mute') {
|
} else if (selectedZone.type === 'mute') {
|
||||||
@ -1205,7 +1213,7 @@ export default function WaveformTimeline({
|
|||||||
// Capture phase ensures zone delete runs before app-level bubble shortcuts.
|
// Capture phase ensures zone delete runs before app-level bubble shortcuts.
|
||||||
window.addEventListener('keydown', handleKeyDown, { capture: true });
|
window.addEventListener('keydown', handleKeyDown, { capture: true });
|
||||||
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true });
|
return () => window.removeEventListener('keydown', handleKeyDown, { capture: true });
|
||||||
}, [selectedZone, removeCutRange, removeMuteRange, removeGainRange, removeSpeedRange]);
|
}, [selectedZone, removeCutRange, removeMuteRange, removeGainRange, removeSpeedRange, canEdit]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!selectedZone) return;
|
if (!selectedZone) return;
|
||||||
@ -1258,7 +1266,7 @@ export default function WaveformTimeline({
|
|||||||
{markOutTime !== null && <span className="text-[10px] text-yellow-300">O {markOutTime.toFixed(2)}s</span>}
|
{markOutTime !== null && <span className="text-[10px] text-yellow-300">O {markOutTime.toFixed(2)}s</span>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="flex items-center gap-1 text-[10px] text-editor-text-muted select-none">
|
<label className="flex items-center gap-1 text-[10px] text-editor-text-muted select-none" title="Compress cut regions to preview the output timeline without gaps">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={showAdjustedTimeline}
|
checked={showAdjustedTimeline}
|
||||||
@ -1272,28 +1280,28 @@ export default function WaveformTimeline({
|
|||||||
<button
|
<button
|
||||||
onClick={() => setShowCutZones((v) => !v)}
|
onClick={() => setShowCutZones((v) => !v)}
|
||||||
className={`px-1.5 py-0.5 rounded text-[10px] border ${showCutZones ? 'border-red-500/60 text-red-300 bg-red-500/10' : 'border-editor-border text-editor-text-muted'}`}
|
className={`px-1.5 py-0.5 rounded text-[10px] border ${showCutZones ? 'border-red-500/60 text-red-300 bg-red-500/10' : 'border-editor-border text-editor-text-muted'}`}
|
||||||
title="Toggle cut zones"
|
title="Toggle cut zones on the timeline — red overlays show removed segments"
|
||||||
>
|
>
|
||||||
Cut
|
Cut
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowMuteZones((v) => !v)}
|
onClick={() => setShowMuteZones((v) => !v)}
|
||||||
className={`px-1.5 py-0.5 rounded text-[10px] border ${showMuteZones ? 'border-blue-500/60 text-blue-300 bg-blue-500/10' : 'border-editor-border text-editor-text-muted'}`}
|
className={`px-1.5 py-0.5 rounded text-[10px] border ${showMuteZones ? 'border-blue-500/60 text-blue-300 bg-blue-500/10' : 'border-editor-border text-editor-text-muted'}`}
|
||||||
title="Toggle mute zones"
|
title="Toggle mute zones on the timeline — blue overlays show silenced segments"
|
||||||
>
|
>
|
||||||
Mute
|
Mute
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowGainZones((v) => !v)}
|
onClick={() => setShowGainZones((v) => !v)}
|
||||||
className={`px-1.5 py-0.5 rounded text-[10px] border ${showGainZones ? 'border-amber-500/60 text-amber-300 bg-amber-500/10' : 'border-editor-border text-editor-text-muted'}`}
|
className={`px-1.5 py-0.5 rounded text-[10px] border ${showGainZones ? 'border-amber-500/60 text-amber-300 bg-amber-500/10' : 'border-editor-border text-editor-text-muted'}`}
|
||||||
title="Toggle gain zones"
|
title="Toggle gain zones on the timeline — amber overlays show volume adjustments"
|
||||||
>
|
>
|
||||||
Gain
|
Gain
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowSpeedZones((v) => !v)}
|
onClick={() => setShowSpeedZones((v) => !v)}
|
||||||
className={`px-1.5 py-0.5 rounded text-[10px] border ${showSpeedZones ? 'border-emerald-500/60 text-emerald-300 bg-emerald-500/10' : 'border-editor-border text-editor-text-muted'}`}
|
className={`px-1.5 py-0.5 rounded text-[10px] border ${showSpeedZones ? 'border-emerald-500/60 text-emerald-300 bg-emerald-500/10' : 'border-editor-border text-editor-text-muted'}`}
|
||||||
title="Toggle speed zones"
|
title="Toggle speed zones on the timeline — emerald overlays show speed changes"
|
||||||
>
|
>
|
||||||
Speed
|
Speed
|
||||||
</button>
|
</button>
|
||||||
@ -1312,6 +1320,22 @@ export default function WaveformTimeline({
|
|||||||
>
|
>
|
||||||
{audioError}
|
{audioError}
|
||||||
</pre>
|
</pre>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setAudioError(null);
|
||||||
|
waveformDataRef.current = null;
|
||||||
|
}}
|
||||||
|
className="px-3 py-1 text-xs rounded bg-editor-surface border border-editor-border text-editor-text hover:bg-editor-bg transition-colors"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : !waveformReady ? (
|
||||||
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
<div className="flex flex-col items-center gap-3">
|
||||||
|
<div className="animate-spin rounded-full h-8 w-8 border-2 border-editor-border border-t-indigo-400" />
|
||||||
|
<span className="text-xs text-editor-text-muted">Loading waveform...</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 relative flex flex-col">
|
<div className="flex-1 relative flex flex-col">
|
||||||
|
|||||||
@ -94,7 +94,7 @@ export default function ZoneEditor() {
|
|||||||
case 'cut':
|
case 'cut':
|
||||||
return 'border-red-500/40 bg-red-500/5';
|
return 'border-red-500/40 bg-red-500/5';
|
||||||
case 'mute':
|
case 'mute':
|
||||||
return 'border-orange-500/40 bg-orange-500/5';
|
return 'border-blue-500/40 bg-blue-500/20';
|
||||||
case 'gain':
|
case 'gain':
|
||||||
return 'border-amber-500/40 bg-amber-500/5';
|
return 'border-amber-500/40 bg-amber-500/5';
|
||||||
case 'speed':
|
case 'speed':
|
||||||
@ -120,6 +120,7 @@ export default function ZoneEditor() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const removeZone = useCallback((type: 'cut' | 'mute' | 'gain' | 'speed', id: string) => {
|
const removeZone = useCallback((type: 'cut' | 'mute' | 'gain' | 'speed', id: string) => {
|
||||||
|
if (!window.confirm("Delete this zone?")) return;
|
||||||
if (type === 'cut') removeCutRange(id);
|
if (type === 'cut') removeCutRange(id);
|
||||||
else if (type === 'mute') removeMuteRange(id);
|
else if (type === 'mute') removeMuteRange(id);
|
||||||
else if (type === 'gain') removeGainRange(id);
|
else if (type === 'gain') removeGainRange(id);
|
||||||
@ -193,6 +194,7 @@ export default function ZoneEditor() {
|
|||||||
? 'bg-editor-accent text-white'
|
? 'bg-editor-accent text-white'
|
||||||
: 'text-editor-text-muted hover:text-editor-text'
|
: 'text-editor-text-muted hover:text-editor-text'
|
||||||
}`}
|
}`}
|
||||||
|
title="Show all zones"
|
||||||
>
|
>
|
||||||
All
|
All
|
||||||
</button>
|
</button>
|
||||||
@ -203,6 +205,7 @@ export default function ZoneEditor() {
|
|||||||
? 'bg-red-500/30 text-red-500'
|
? 'bg-red-500/30 text-red-500'
|
||||||
: 'text-editor-text-muted hover:text-editor-text'
|
: 'text-editor-text-muted hover:text-editor-text'
|
||||||
}`}
|
}`}
|
||||||
|
title="Show only Cut zones"
|
||||||
>
|
>
|
||||||
Cut
|
Cut
|
||||||
</button>
|
</button>
|
||||||
@ -210,9 +213,10 @@ export default function ZoneEditor() {
|
|||||||
onClick={() => setViewMode('mute')}
|
onClick={() => setViewMode('mute')}
|
||||||
className={`px-2 py-1 text-xs rounded transition-colors ${
|
className={`px-2 py-1 text-xs rounded transition-colors ${
|
||||||
viewMode === 'mute'
|
viewMode === 'mute'
|
||||||
? 'bg-orange-500/30 text-orange-500'
|
? 'bg-blue-500/20 text-blue-400'
|
||||||
: 'text-editor-text-muted hover:text-editor-text'
|
: 'text-editor-text-muted hover:text-editor-text'
|
||||||
}`}
|
}`}
|
||||||
|
title="Show only Mute zones"
|
||||||
>
|
>
|
||||||
Mute
|
Mute
|
||||||
</button>
|
</button>
|
||||||
@ -223,6 +227,7 @@ export default function ZoneEditor() {
|
|||||||
? 'bg-amber-500/30 text-amber-500'
|
? 'bg-amber-500/30 text-amber-500'
|
||||||
: 'text-editor-text-muted hover:text-editor-text'
|
: 'text-editor-text-muted hover:text-editor-text'
|
||||||
}`}
|
}`}
|
||||||
|
title="Show only Gain zones"
|
||||||
>
|
>
|
||||||
Gain
|
Gain
|
||||||
</button>
|
</button>
|
||||||
@ -233,6 +238,7 @@ export default function ZoneEditor() {
|
|||||||
? 'bg-emerald-500/30 text-emerald-500'
|
? 'bg-emerald-500/30 text-emerald-500'
|
||||||
: 'text-editor-text-muted hover:text-editor-text'
|
: 'text-editor-text-muted hover:text-editor-text'
|
||||||
}`}
|
}`}
|
||||||
|
title="Show only Speed zones"
|
||||||
>
|
>
|
||||||
Speed
|
Speed
|
||||||
</button>
|
</button>
|
||||||
@ -240,7 +246,7 @@ export default function ZoneEditor() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{totalZones === 0 ? (
|
{totalZones === 0 ? (
|
||||||
<div className="p-4 rounded border border-dashed border-editor-border text-center">
|
<div className="p-4 rounded-lg border border-dashed border-editor-border text-center">
|
||||||
<p className="text-xs text-editor-text-muted">
|
<p className="text-xs text-editor-text-muted">
|
||||||
No zones yet. Create zones from the toolbar or by highlighting words.
|
No zones yet. Create zones from the toolbar or by highlighting words.
|
||||||
</p>
|
</p>
|
||||||
@ -259,13 +265,12 @@ export default function ZoneEditor() {
|
|||||||
<div
|
<div
|
||||||
key={range.id}
|
key={range.id}
|
||||||
onClick={() => setFocusedZone({ type: 'cut', id: range.id })}
|
onClick={() => setFocusedZone({ type: 'cut', id: range.id })}
|
||||||
className={`px-2 py-1.5 rounded border text-xs flex items-center gap-2 group cursor-pointer transition-colors ${getZoneTypeColor('cut')} ${isZoneFocused('cut', range.id) ? 'ring-1 ring-red-400 border-red-400/80 bg-red-500/12' : ''}`}
|
className={`px-2 py-1.5 rounded-lg border text-xs flex items-center gap-2 group cursor-pointer transition-colors ${getZoneTypeColor('cut')} ${isZoneFocused('cut', range.id) ? 'ring-1 ring-red-400 border-red-400/80 bg-red-500/12' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="font-medium truncate">
|
<div className="font-medium truncate">
|
||||||
{formatTimelineLikeTime(range.start)} - {formatTimelineLikeTime(range.end)}
|
{formatTimelineLikeTime(range.start)} - {formatTimelineLikeTime(range.end)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-editor-text-muted text-[10px]">{range.id}</div>
|
|
||||||
</div>
|
</div>
|
||||||
{renderPreviewButton(range.start, range.end, 'hover:bg-red-500/20 text-red-500/70 hover:text-red-500')}
|
{renderPreviewButton(range.start, range.end, 'hover:bg-red-500/20 text-red-500/70 hover:text-red-500')}
|
||||||
<button
|
<button
|
||||||
@ -287,7 +292,7 @@ export default function ZoneEditor() {
|
|||||||
{/* Mute Zones */}
|
{/* Mute Zones */}
|
||||||
{(viewMode === 'all' || viewMode === 'mute') && muteRanges.length > 0 && (
|
{(viewMode === 'all' || viewMode === 'mute') && muteRanges.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="text-xs font-semibold text-orange-500/80 flex items-center gap-2">
|
<div className="text-xs font-semibold text-blue-400 flex items-center gap-2">
|
||||||
<Volume2 className="w-3.5 h-3.5" />
|
<Volume2 className="w-3.5 h-3.5" />
|
||||||
Mute Zones ({muteRanges.length})
|
Mute Zones ({muteRanges.length})
|
||||||
</div>
|
</div>
|
||||||
@ -296,21 +301,20 @@ export default function ZoneEditor() {
|
|||||||
<div
|
<div
|
||||||
key={range.id}
|
key={range.id}
|
||||||
onClick={() => setFocusedZone({ type: 'mute', id: range.id })}
|
onClick={() => setFocusedZone({ type: 'mute', id: range.id })}
|
||||||
className={`px-2 py-1.5 rounded border text-xs flex items-center gap-2 group cursor-pointer transition-colors ${getZoneTypeColor('mute')} ${isZoneFocused('mute', range.id) ? 'ring-1 ring-orange-400 border-orange-400/80 bg-orange-500/12' : ''}`}
|
className={`px-2 py-1.5 rounded-lg border text-xs flex items-center gap-2 group cursor-pointer transition-colors ${getZoneTypeColor('mute')} ${isZoneFocused('mute', range.id) ? 'ring-1 ring-blue-400 border-blue-400/80 bg-blue-500/20' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="font-medium truncate">
|
<div className="font-medium truncate">
|
||||||
{formatTimelineLikeTime(range.start)} - {formatTimelineLikeTime(range.end)}
|
{formatTimelineLikeTime(range.start)} - {formatTimelineLikeTime(range.end)}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-editor-text-muted text-[10px]">{range.id}</div>
|
|
||||||
</div>
|
</div>
|
||||||
{renderPreviewButton(range.start, range.end, 'hover:bg-orange-500/20 text-orange-500/70 hover:text-orange-500')}
|
{renderPreviewButton(range.start, range.end, 'hover:bg-blue-500/20 text-blue-400 hover:text-blue-400')}
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
removeZone('mute', range.id);
|
removeZone('mute', range.id);
|
||||||
}}
|
}}
|
||||||
className="p-1 rounded hover:bg-orange-500/20 text-orange-500/70 hover:text-orange-500 opacity-0 group-hover:opacity-100 transition-opacity"
|
className="p-1 rounded hover:bg-blue-500/20 text-blue-400 hover:text-blue-400 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||||
title="Delete mute zone"
|
title="Delete mute zone"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-3.5 h-3.5" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
@ -321,12 +325,12 @@ export default function ZoneEditor() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Gain Zones */}
|
{/* Sound Gain */}
|
||||||
{(viewMode === 'all' || viewMode === 'gain') && gainRanges.length > 0 && (
|
{(viewMode === 'all' || viewMode === 'gain') && gainRanges.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="text-xs font-semibold text-amber-500/80 flex items-center gap-2">
|
<div className="text-xs font-semibold text-amber-500/80 flex items-center gap-2">
|
||||||
<SlidersHorizontal className="w-3.5 h-3.5" />
|
<SlidersHorizontal className="w-3.5 h-3.5" />
|
||||||
Gain Zones ({gainRanges.length})
|
Sound Gain ({gainRanges.length})
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Global Gain Slider */}
|
{/* Global Gain Slider */}
|
||||||
@ -350,6 +354,7 @@ export default function ZoneEditor() {
|
|||||||
value={globalGainDb}
|
value={globalGainDb}
|
||||||
onChange={(e) => setGlobalGainDb(Math.max(-24, Math.min(24, Number(e.target.value) || 0)))}
|
onChange={(e) => setGlobalGainDb(Math.max(-24, Math.min(24, Number(e.target.value) || 0)))}
|
||||||
className="w-14 px-1.5 py-0.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
|
className="w-14 px-1.5 py-0.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
|
||||||
|
title="Volume adjustment in decibels — +6 dB doubles volume, -6 dB halves it"
|
||||||
/>
|
/>
|
||||||
<span className="text-xs text-amber-500/80 font-medium w-6 text-right">dB</span>
|
<span className="text-xs text-amber-500/80 font-medium w-6 text-right">dB</span>
|
||||||
</div>
|
</div>
|
||||||
@ -360,7 +365,7 @@ export default function ZoneEditor() {
|
|||||||
<div
|
<div
|
||||||
key={range.id}
|
key={range.id}
|
||||||
onClick={() => setFocusedZone({ type: 'gain', id: range.id })}
|
onClick={() => setFocusedZone({ type: 'gain', id: range.id })}
|
||||||
className={`px-2 py-1.5 rounded border text-xs flex items-center gap-2 group cursor-pointer transition-colors ${getZoneTypeColor('gain')} ${isZoneFocused('gain', range.id) ? 'ring-1 ring-amber-400 border-amber-400/80 bg-amber-500/12' : ''}`}
|
className={`px-2 py-1.5 rounded-lg border text-xs flex items-center gap-2 group cursor-pointer transition-colors ${getZoneTypeColor('gain')} ${isZoneFocused('gain', range.id) ? 'ring-1 ring-amber-400 border-amber-400/80 bg-amber-500/12' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="font-medium truncate">
|
<div className="font-medium truncate">
|
||||||
@ -379,7 +384,7 @@ export default function ZoneEditor() {
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onChange={(e) => updateGainRange(range.id, Number(e.target.value) || 0)}
|
onChange={(e) => updateGainRange(range.id, Number(e.target.value) || 0)}
|
||||||
className="w-16 px-1.5 py-0.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
|
className="w-16 px-1.5 py-0.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
|
||||||
title="Gain dB"
|
title="Volume adjustment in decibels — +6 dB doubles volume, -6 dB halves it"
|
||||||
/>
|
/>
|
||||||
{renderPreviewButton(range.start, range.end, 'hover:bg-amber-500/20 text-amber-500/70 hover:text-amber-500')}
|
{renderPreviewButton(range.start, range.end, 'hover:bg-amber-500/20 text-amber-500/70 hover:text-amber-500')}
|
||||||
<button
|
<button
|
||||||
@ -398,19 +403,19 @@ export default function ZoneEditor() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Speed Zones */}
|
{/* Speed Adjust */}
|
||||||
{(viewMode === 'all' || viewMode === 'speed') && speedRanges.length > 0 && (
|
{(viewMode === 'all' || viewMode === 'speed') && speedRanges.length > 0 && (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="text-xs font-semibold text-emerald-500/80 flex items-center gap-2">
|
<div className="text-xs font-semibold text-emerald-500/80 flex items-center gap-2">
|
||||||
<Gauge className="w-3.5 h-3.5" />
|
<Gauge className="w-3.5 h-3.5" />
|
||||||
Speed Zones ({speedRanges.length})
|
Speed Adjust ({speedRanges.length})
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{speedRanges.map((range) => (
|
{speedRanges.map((range) => (
|
||||||
<div
|
<div
|
||||||
key={range.id}
|
key={range.id}
|
||||||
onClick={() => setFocusedZone({ type: 'speed', id: range.id })}
|
onClick={() => setFocusedZone({ type: 'speed', id: range.id })}
|
||||||
className={`px-2 py-1.5 rounded border text-xs flex items-center gap-2 group cursor-pointer transition-colors ${getZoneTypeColor('speed')} ${isZoneFocused('speed', range.id) ? 'ring-1 ring-emerald-400 border-emerald-400/80 bg-emerald-500/12' : ''}`}
|
className={`px-2 py-1.5 rounded-lg border text-xs flex items-center gap-2 group cursor-pointer transition-colors ${getZoneTypeColor('speed')} ${isZoneFocused('speed', range.id) ? 'ring-1 ring-emerald-400 border-emerald-400/80 bg-emerald-500/12' : ''}`}
|
||||||
>
|
>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="font-medium truncate">
|
<div className="font-medium truncate">
|
||||||
@ -429,7 +434,7 @@ export default function ZoneEditor() {
|
|||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onChange={(e) => updateSpeedRange(range.id, Number(e.target.value) || 1)}
|
onChange={(e) => updateSpeedRange(range.id, Number(e.target.value) || 1)}
|
||||||
className="w-16 px-1.5 py-0.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
|
className="w-16 px-1.5 py-0.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
|
||||||
title="Speed multiplier"
|
title="Playback speed multiplier — 1.0x is normal, 2.0x is twice as fast"
|
||||||
/>
|
/>
|
||||||
{renderPreviewButton(range.start, range.end, 'hover:bg-emerald-500/20 text-emerald-500/70 hover:text-emerald-500')}
|
{renderPreviewButton(range.start, range.end, 'hover:bg-emerald-500/20 text-emerald-500/70 hover:text-emerald-500')}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useEditorStore } from '../store/editorStore';
|
import { useEditorStore } from '../store/editorStore';
|
||||||
import { loadBindings } from '../lib/keybindings';
|
import { loadBindings, DEFAULT_PRESETS } from '../lib/keybindings';
|
||||||
import type { KeyBinding } from '../types/project';
|
import type { KeyBinding } from '../types/project';
|
||||||
|
|
||||||
export function useKeyboardShortcuts() {
|
export function useKeyboardShortcuts() {
|
||||||
@ -180,6 +180,8 @@ function toggleCheatsheet(bindings: KeyBinding[]) {
|
|||||||
overlay.remove();
|
overlay.remove();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const presetName = JSON.stringify(bindings) === JSON.stringify(DEFAULT_PRESETS['left-hand']) ? 'Left-Hand Preset' : 'Standard Preset';
|
||||||
|
|
||||||
const rows = bindings
|
const rows = bindings
|
||||||
.map(
|
.map(
|
||||||
(b) =>
|
(b) =>
|
||||||
@ -187,11 +189,24 @@ function toggleCheatsheet(bindings: KeyBinding[]) {
|
|||||||
)
|
)
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
overlay.innerHTML = `<div style="background:#1a1d27;border:1px solid #2a2d3a;border-radius:12px;padding:24px 32px;max-width:450px;" onclick="event.stopPropagation()">
|
overlay.innerHTML = `<div style="background:#1a1d27;border:1px solid #2a2d3a;border-radius:12px;padding:24px 32px;max-width:450px;position:relative;" onclick="event.stopPropagation()">
|
||||||
|
<div style="font-size:11px;color:#94a3b8;margin-bottom:12px">Active preset: <span style="color:#818cf8;font-weight:500">${presetName}</span></div>
|
||||||
<h3 style="margin:0 0 16px;font-size:14px;font-weight:600;color:#e2e8f0">Keyboard Shortcuts</h3>
|
<h3 style="margin:0 0 16px;font-size:14px;font-weight:600;color:#e2e8f0">Keyboard Shortcuts</h3>
|
||||||
<table style="font-size:13px">${rows}</table>
|
<table style="font-size:13px">${rows}</table>
|
||||||
<p style="margin:16px 0 0;font-size:11px;color:#94a3b8;text-align:center">Customize in Settings • Press ? 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>`;
|
</div>`;
|
||||||
|
|
||||||
document.body.appendChild(overlay);
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
const closeBtn = overlay.querySelector('#cheatsheet-close') as HTMLButtonElement;
|
||||||
|
if (closeBtn) closeBtn.onclick = () => overlay.remove();
|
||||||
|
|
||||||
|
const escHandler = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
overlay.remove();
|
||||||
|
document.removeEventListener('keydown', escHandler);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', escHandler);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -8,6 +8,12 @@
|
|||||||
100% { transform: scaleY(0.3); opacity: 0.5; }
|
100% { transform: scaleY(0.3); opacity: 0.5; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes audioBounce {
|
||||||
|
0% { height: 12px; }
|
||||||
|
50% { height: var(--bar-peak); }
|
||||||
|
100% { height: 12px; }
|
||||||
|
}
|
||||||
|
|
||||||
.wave-bar {
|
.wave-bar {
|
||||||
animation: waveBar 0.9s ease-in-out infinite;
|
animation: waveBar 0.9s ease-in-out infinite;
|
||||||
transform-origin: bottom;
|
transform-origin: bottom;
|
||||||
@ -46,3 +52,7 @@ body {
|
|||||||
video::-webkit-media-controls {
|
video::-webkit-media-controls {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
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
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -96,4 +96,48 @@ window.electronAPI = {
|
|||||||
await writeTextFile(path, content);
|
await writeTextFile(path, content);
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
activateLicense: (key: string): Promise<any> => {
|
||||||
|
return invoke('activate_license', { licenseKey: key });
|
||||||
|
},
|
||||||
|
|
||||||
|
verifyLicense: (key: string): Promise<any> => {
|
||||||
|
return invoke('verify_license', { licenseKey: key });
|
||||||
|
},
|
||||||
|
|
||||||
|
getAppStatus: (): Promise<any> => {
|
||||||
|
return invoke('get_app_status');
|
||||||
|
},
|
||||||
|
|
||||||
|
deactivateLicense: (): Promise<void> => {
|
||||||
|
return invoke('deactivate_license');
|
||||||
|
},
|
||||||
|
|
||||||
|
hasLicenseFeature: (feature: string): Promise<boolean> => {
|
||||||
|
return invoke('has_license_feature', { feature });
|
||||||
|
},
|
||||||
|
|
||||||
|
listModels: (): Promise<ModelInfo[]> => {
|
||||||
|
return invoke('list_models');
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteModel: (path: string): Promise<void> => {
|
||||||
|
return invoke('delete_model', { path });
|
||||||
|
},
|
||||||
|
|
||||||
|
logError: (message: string, stack: string, componentStack: string): Promise<void> => {
|
||||||
|
return invoke('log_error', { message, stack, componentStack });
|
||||||
|
},
|
||||||
|
|
||||||
|
writeAutosave: (data: string): Promise<void> => {
|
||||||
|
return invoke('write_autosave', { data });
|
||||||
|
},
|
||||||
|
|
||||||
|
readAutosave: (): Promise<string | null> => {
|
||||||
|
return invoke('read_autosave');
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteAutosave: (): Promise<void> => {
|
||||||
|
return invoke('delete_autosave');
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -5,10 +5,30 @@ import './lib/dev-logger';
|
|||||||
// Tauri bridge polyfill: must be imported before App so window.electronAPI is available to all components
|
// Tauri bridge polyfill: must be imported before App so window.electronAPI is available to all components
|
||||||
import './lib/tauri-bridge';
|
import './lib/tauri-bridge';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
import ErrorBoundary from './components/ErrorBoundary';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
|
window.addEventListener('error', (e) => {
|
||||||
|
if (e.error) {
|
||||||
|
try {
|
||||||
|
console.error('[GlobalError]', e.error.message, e.error.stack);
|
||||||
|
window.electronAPI?.logError?.(e.error.message, e.error.stack || '', '');
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('unhandledrejection', (e) => {
|
||||||
|
const reason = e.reason instanceof Error ? e.reason : new Error(String(e.reason));
|
||||||
|
try {
|
||||||
|
console.error('[UnhandledRejection]', reason.message, reason.stack);
|
||||||
|
window.electronAPI?.logError?.(reason.message, reason.stack || '', '');
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<App />
|
<ErrorBoundary>
|
||||||
|
<App />
|
||||||
|
</ErrorBoundary>
|
||||||
</React.StrictMode>,
|
</React.StrictMode>,
|
||||||
);
|
);
|
||||||
|
|||||||
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';
|
import { useEditorStore } from './editorStore';
|
||||||
|
|
||||||
|
|
||||||
describe('editorStore basics', () => {
|
function seedWords(count: number) {
|
||||||
|
const words: { word: string; start: number; end: number; confidence: number }[] = [];
|
||||||
|
for (let i = 0; i < count; i++) {
|
||||||
|
words.push({ word: `word${i}`, start: i * 0.5, end: i * 0.5 + 0.4, confidence: 0.95 });
|
||||||
|
}
|
||||||
|
const segments = [{
|
||||||
|
id: 0, start: 0, end: count * 0.5,
|
||||||
|
text: words.map(w => w.word).join(' '),
|
||||||
|
words,
|
||||||
|
globalStartIndex: 0,
|
||||||
|
}];
|
||||||
|
useEditorStore.getState().setTranscription({ words, segments, language: 'en' });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('editorStore', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useEditorStore.getState().reset();
|
useEditorStore.getState().reset();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('clamps global gain to valid bounds', () => {
|
describe('global gain', () => {
|
||||||
const state = useEditorStore.getState();
|
test('clamps to upper bound', () => {
|
||||||
|
useEditorStore.getState().setGlobalGainDb(100);
|
||||||
|
expect(useEditorStore.getState().globalGainDb).toBe(24);
|
||||||
|
});
|
||||||
|
|
||||||
state.setGlobalGainDb(100);
|
test('clamps to lower bound', () => {
|
||||||
expect(useEditorStore.getState().globalGainDb).toBe(24);
|
useEditorStore.getState().setGlobalGainDb(-100);
|
||||||
|
expect(useEditorStore.getState().globalGainDb).toBe(-24);
|
||||||
|
});
|
||||||
|
|
||||||
state.setGlobalGainDb(-100);
|
test('rejects NaN by falling back to 0', () => {
|
||||||
expect(useEditorStore.getState().globalGainDb).toBe(-24);
|
useEditorStore.getState().setGlobalGainDb(NaN);
|
||||||
|
expect(useEditorStore.getState().globalGainDb).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects Infinity', () => {
|
||||||
|
useEditorStore.getState().setGlobalGainDb(Infinity);
|
||||||
|
expect(useEditorStore.getState().globalGainDb).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('accepts value in range', () => {
|
||||||
|
useEditorStore.getState().setGlobalGainDb(6);
|
||||||
|
expect(useEditorStore.getState().globalGainDb).toBe(6);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('adds gain range to store', () => {
|
describe('zone ranges', () => {
|
||||||
const state = useEditorStore.getState();
|
beforeEach(() => {
|
||||||
|
useEditorStore.getState().setDuration(100);
|
||||||
|
});
|
||||||
|
|
||||||
state.addGainRange(1.2, 2.4, 3.5);
|
test('addCutRange creates a zone with correct times', () => {
|
||||||
|
useEditorStore.getState().addCutRange(1, 5);
|
||||||
|
const ranges = useEditorStore.getState().cutRanges;
|
||||||
|
expect(ranges.length).toBe(1);
|
||||||
|
expect(ranges[0].start).toBe(1);
|
||||||
|
expect(ranges[0].end).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
const ranges = useEditorStore.getState().gainRanges;
|
test('addCutRange generates unique ids', () => {
|
||||||
expect(ranges.length).toBe(1);
|
useEditorStore.getState().addCutRange(1, 2);
|
||||||
expect(ranges[0].start).toBe(1.2);
|
useEditorStore.getState().addCutRange(3, 4);
|
||||||
expect(ranges[0].end).toBe(2.4);
|
const ranges = useEditorStore.getState().cutRanges;
|
||||||
expect(ranges[0].gainDb).toBe(3.5);
|
expect(ranges[0].id).not.toBe(ranges[1].id);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addCutRange rejects start >= end', () => {
|
||||||
|
useEditorStore.getState().addCutRange(5, 5);
|
||||||
|
expect(useEditorStore.getState().cutRanges.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addCutRange rejects start > end', () => {
|
||||||
|
useEditorStore.getState().addCutRange(5, 1);
|
||||||
|
expect(useEditorStore.getState().cutRanges.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addCutRange rejects duration < 0.01s', () => {
|
||||||
|
useEditorStore.getState().addCutRange(0, 0.005);
|
||||||
|
expect(useEditorStore.getState().cutRanges.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addCutRange rejects negative start', () => {
|
||||||
|
useEditorStore.getState().addCutRange(-1, 5);
|
||||||
|
expect(useEditorStore.getState().cutRanges.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addCutRange rejects NaN values', () => {
|
||||||
|
useEditorStore.getState().addCutRange(NaN, 5);
|
||||||
|
expect(useEditorStore.getState().cutRanges.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addMuteRange creates a zone', () => {
|
||||||
|
useEditorStore.getState().addMuteRange(2, 6);
|
||||||
|
const ranges = useEditorStore.getState().muteRanges;
|
||||||
|
expect(ranges.length).toBe(1);
|
||||||
|
expect(ranges[0].start).toBe(2);
|
||||||
|
expect(ranges[0].end).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addGainRange creates a zone with gain value', () => {
|
||||||
|
useEditorStore.getState().addGainRange(1, 4, 3.5);
|
||||||
|
const ranges = useEditorStore.getState().gainRanges;
|
||||||
|
expect(ranges.length).toBe(1);
|
||||||
|
expect(ranges[0].gainDb).toBe(3.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addSpeedRange creates a zone with speed value', () => {
|
||||||
|
useEditorStore.getState().addSpeedRange(0, 10, 1.5);
|
||||||
|
const ranges = useEditorStore.getState().speedRanges;
|
||||||
|
expect(ranges.length).toBe(1);
|
||||||
|
expect(ranges[0].speed).toBe(1.5);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removeCutRange removes by id', () => {
|
||||||
|
useEditorStore.getState().addCutRange(1, 2);
|
||||||
|
const id = useEditorStore.getState().cutRanges[0].id;
|
||||||
|
useEditorStore.getState().removeCutRange(id);
|
||||||
|
expect(useEditorStore.getState().cutRanges.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removeCutRange does nothing for missing id', () => {
|
||||||
|
useEditorStore.getState().addCutRange(1, 2);
|
||||||
|
useEditorStore.getState().removeCutRange('nonexistent');
|
||||||
|
expect(useEditorStore.getState().cutRanges.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateCutRange updates bounds', () => {
|
||||||
|
useEditorStore.getState().addCutRange(1, 5);
|
||||||
|
const id = useEditorStore.getState().cutRanges[0].id;
|
||||||
|
useEditorStore.getState().updateCutRange(id, 2, 8);
|
||||||
|
const range = useEditorStore.getState().cutRanges[0];
|
||||||
|
expect(range.start).toBe(2);
|
||||||
|
expect(range.end).toBe(8);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removeMuteRange, removeGainRange, removeSpeedRange work', () => {
|
||||||
|
useEditorStore.getState().addMuteRange(1, 2);
|
||||||
|
useEditorStore.getState().addGainRange(2, 4, 3);
|
||||||
|
useEditorStore.getState().addSpeedRange(3, 6, 1.2);
|
||||||
|
|
||||||
|
useEditorStore.getState().removeMuteRange(useEditorStore.getState().muteRanges[0].id);
|
||||||
|
useEditorStore.getState().removeGainRange(useEditorStore.getState().gainRanges[0].id);
|
||||||
|
useEditorStore.getState().removeSpeedRange(useEditorStore.getState().speedRanges[0].id);
|
||||||
|
|
||||||
|
expect(useEditorStore.getState().muteRanges.length).toBe(0);
|
||||||
|
expect(useEditorStore.getState().gainRanges.length).toBe(0);
|
||||||
|
expect(useEditorStore.getState().speedRanges.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects zones beyond duration', () => {
|
||||||
|
useEditorStore.getState().setDuration(10);
|
||||||
|
useEditorStore.getState().addCutRange(5, 20);
|
||||||
|
expect(useEditorStore.getState().cutRanges.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects zone with end beyond duration', () => {
|
||||||
|
useEditorStore.getState().setDuration(5);
|
||||||
|
useEditorStore.getState().addCutRange(1, 10);
|
||||||
|
expect(useEditorStore.getState().cutRanges.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('word selection', () => {
|
||||||
|
beforeEach(() => { seedWords(10); });
|
||||||
|
|
||||||
|
test('setSelectedWordIndices updates selection', () => {
|
||||||
|
useEditorStore.getState().setSelectedWordIndices([0, 1, 2]);
|
||||||
|
expect(useEditorStore.getState().selectedWordIndices).toEqual([0, 1, 2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setSelectedWordIndices handles empty', () => {
|
||||||
|
useEditorStore.getState().setSelectedWordIndices([0]);
|
||||||
|
useEditorStore.getState().setSelectedWordIndices([]);
|
||||||
|
expect(useEditorStore.getState().selectedWordIndices).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateWordText updates the word at index', () => {
|
||||||
|
useEditorStore.getState().updateWordText(0, 'hello');
|
||||||
|
expect(useEditorStore.getState().words[0].word).toBe('hello');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateWordText preserves timing', () => {
|
||||||
|
const origStart = useEditorStore.getState().words[3].start;
|
||||||
|
useEditorStore.getState().updateWordText(3, 'changed');
|
||||||
|
expect(useEditorStore.getState().words[3].start).toBe(origStart);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateWordText rejects out-of-bounds index', () => {
|
||||||
|
useEditorStore.getState().updateWordText(999, 'oops');
|
||||||
|
expect(useEditorStore.getState().words.length).toBe(10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateWordText rejects empty string', () => {
|
||||||
|
useEditorStore.getState().updateWordText(0, '');
|
||||||
|
expect(useEditorStore.getState().words[0].word).toBe('word0');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('replaceWordRange replaces words in middle', () => {
|
||||||
|
const newWords = [
|
||||||
|
{ word: 'new1', start: 1.5, end: 1.9, confidence: 0.99 },
|
||||||
|
{ word: 'new2', start: 2.0, end: 2.4, confidence: 0.99 },
|
||||||
|
];
|
||||||
|
useEditorStore.getState().replaceWordRange(3, 5, newWords);
|
||||||
|
const words = useEditorStore.getState().words;
|
||||||
|
expect(words.length).toBe(10 - (5 - 3 + 1) + 2);
|
||||||
|
expect(words[3].word).toBe('new1');
|
||||||
|
expect(words[4].word).toBe('new2');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getWordAtTime returns correct index', () => {
|
||||||
|
const idx = useEditorStore.getState().getWordAtTime(1.0);
|
||||||
|
expect(idx).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getWordAtTime returns 0 for time before first word', () => {
|
||||||
|
const idx = useEditorStore.getState().getWordAtTime(-1);
|
||||||
|
expect(idx).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getWordAtTime returns -1 for no words', () => {
|
||||||
|
useEditorStore.getState().reset();
|
||||||
|
expect(useEditorStore.getState().getWordAtTime(0)).toBe(-1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('markers', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useEditorStore.getState().setDuration(120);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setMarkInTime sets and clears', () => {
|
||||||
|
useEditorStore.getState().setMarkInTime(10);
|
||||||
|
expect(useEditorStore.getState().markInTime).toBe(10);
|
||||||
|
useEditorStore.getState().setMarkInTime(null);
|
||||||
|
expect(useEditorStore.getState().markInTime).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setMarkInTime rejects NaN', () => {
|
||||||
|
useEditorStore.getState().setMarkInTime(NaN);
|
||||||
|
expect(useEditorStore.getState().markInTime).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clearMarkRange clears both', () => {
|
||||||
|
useEditorStore.getState().setMarkInTime(5);
|
||||||
|
useEditorStore.getState().setMarkOutTime(10);
|
||||||
|
useEditorStore.getState().clearMarkRange();
|
||||||
|
expect(useEditorStore.getState().markInTime).toBeNull();
|
||||||
|
expect(useEditorStore.getState().markOutTime).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addTimelineMarker adds with correct data', () => {
|
||||||
|
useEditorStore.getState().addTimelineMarker(5, 'Intro', '#ef4444');
|
||||||
|
const markers = useEditorStore.getState().timelineMarkers;
|
||||||
|
expect(markers.length).toBe(1);
|
||||||
|
expect(markers[0].time).toBe(5);
|
||||||
|
expect(markers[0].label).toBe('Intro');
|
||||||
|
expect(markers[0].color).toBe('#ef4444');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addTimelineMarker defaults empty label to Marker', () => {
|
||||||
|
useEditorStore.getState().addTimelineMarker(10, '', '#6366f1');
|
||||||
|
expect(useEditorStore.getState().timelineMarkers[0].label).toBe('Marker');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('addTimelineMarker rejects NaN time', () => {
|
||||||
|
useEditorStore.getState().addTimelineMarker(NaN, 'test', '#6366f1');
|
||||||
|
expect(useEditorStore.getState().timelineMarkers.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('removeTimelineMarker removes by id', () => {
|
||||||
|
useEditorStore.getState().addTimelineMarker(5, 'Intro', '#ef4444');
|
||||||
|
const id = useEditorStore.getState().timelineMarkers[0].id;
|
||||||
|
useEditorStore.getState().removeTimelineMarker(id);
|
||||||
|
expect(useEditorStore.getState().timelineMarkers.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('updateTimelineMarker updates label and color', () => {
|
||||||
|
useEditorStore.getState().addTimelineMarker(5, 'Intro', '#ef4444');
|
||||||
|
const id = useEditorStore.getState().timelineMarkers[0].id;
|
||||||
|
useEditorStore.getState().updateTimelineMarker(id, { label: 'Chapter 1', color: '#22c55e' });
|
||||||
|
const m = useEditorStore.getState().timelineMarkers[0];
|
||||||
|
expect(m.label).toBe('Chapter 1');
|
||||||
|
expect(m.color).toBe('#22c55e');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('transcription', () => {
|
||||||
|
test('setTranscription sets words and segments', () => {
|
||||||
|
seedWords(5);
|
||||||
|
expect(useEditorStore.getState().words.length).toBe(5);
|
||||||
|
expect(useEditorStore.getState().segments.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setTranscription clears segments when words are empty', () => {
|
||||||
|
useEditorStore.getState().setTranscription({ words: [], segments: [], language: 'en' });
|
||||||
|
expect(useEditorStore.getState().segments.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setTranscriptionModel ignores null', () => {
|
||||||
|
useEditorStore.getState().setTranscriptionModel('base');
|
||||||
|
useEditorStore.getState().setTranscriptionModel(null);
|
||||||
|
expect(useEditorStore.getState().transcriptionModel).toBe('base');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setTranscriptionModel ignores empty string', () => {
|
||||||
|
useEditorStore.getState().setTranscriptionModel('base');
|
||||||
|
useEditorStore.getState().setTranscriptionModel('');
|
||||||
|
expect(useEditorStore.getState().transcriptionModel).toBe('base');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setTranscribing toggles state and status', () => {
|
||||||
|
useEditorStore.getState().setTranscribing(true, 50, 'Loading...');
|
||||||
|
expect(useEditorStore.getState().isTranscribing).toBe(true);
|
||||||
|
expect(useEditorStore.getState().transcriptionProgress).toBe(50);
|
||||||
|
expect(useEditorStore.getState().transcriptionStatus).toBe('Loading...');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('project file', () => {
|
||||||
|
test('saveProject includes all zone types', () => {
|
||||||
|
useEditorStore.getState().loadVideo('test.mp4');
|
||||||
|
useEditorStore.getState().setDuration(100);
|
||||||
|
useEditorStore.getState().addCutRange(1, 2);
|
||||||
|
useEditorStore.getState().addMuteRange(2, 3);
|
||||||
|
useEditorStore.getState().addGainRange(3, 4, 3);
|
||||||
|
useEditorStore.getState().addSpeedRange(4, 5, 1.5);
|
||||||
|
|
||||||
|
const project = useEditorStore.getState().saveProject();
|
||||||
|
expect(project.cutRanges).toBeDefined();
|
||||||
|
expect(project.cutRanges!.length).toBe(1);
|
||||||
|
expect(project.muteRanges).toBeDefined();
|
||||||
|
expect(project.muteRanges!.length).toBe(1);
|
||||||
|
expect(project.gainRanges).toBeDefined();
|
||||||
|
expect(project.gainRanges!.length).toBe(1);
|
||||||
|
expect(project.speedRanges).toBeDefined();
|
||||||
|
expect(project.speedRanges!.length).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setProjectFilePath sets and reads back', () => {
|
||||||
|
useEditorStore.getState().setProjectFilePath('/path/to/project.aive');
|
||||||
|
expect(useEditorStore.getState().projectFilePath).toBe('/path/to/project.aive');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('duration and current time', () => {
|
||||||
|
test('setDuration sets duration value', () => {
|
||||||
|
useEditorStore.getState().setDuration(120);
|
||||||
|
expect(useEditorStore.getState().duration).toBe(120);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setCurrentTime sets time without clamping', () => {
|
||||||
|
useEditorStore.getState().setDuration(60);
|
||||||
|
useEditorStore.getState().setCurrentTime(120);
|
||||||
|
expect(useEditorStore.getState().currentTime).toBe(120);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setCurrentTime accepts negative values', () => {
|
||||||
|
useEditorStore.getState().setCurrentTime(-10);
|
||||||
|
expect(useEditorStore.getState().currentTime).toBe(-10);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('setIsPlaying toggles', () => {
|
||||||
|
useEditorStore.getState().setIsPlaying(true);
|
||||||
|
expect(useEditorStore.getState().isPlaying).toBe(true);
|
||||||
|
useEditorStore.getState().setIsPlaying(false);
|
||||||
|
expect(useEditorStore.getState().isPlaying).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('loadVideo', () => {
|
||||||
|
test('loadVideo rejects empty path', () => {
|
||||||
|
useEditorStore.getState().loadVideo('');
|
||||||
|
expect(useEditorStore.getState().videoUrl).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loadVideo resets state', () => {
|
||||||
|
seedWords(5);
|
||||||
|
useEditorStore.getState().addCutRange(1, 2);
|
||||||
|
useEditorStore.getState().loadVideo('new-video.mp4');
|
||||||
|
expect(useEditorStore.getState().words.length).toBe(0);
|
||||||
|
expect(useEditorStore.getState().cutRanges.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('zone preview padding', () => {
|
||||||
|
test('sets padding value', () => {
|
||||||
|
useEditorStore.getState().setZonePreviewPaddingSeconds(3);
|
||||||
|
expect(useEditorStore.getState().zonePreviewPaddingSeconds).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects NaN', () => {
|
||||||
|
useEditorStore.getState().setZonePreviewPaddingSeconds(2);
|
||||||
|
useEditorStore.getState().setZonePreviewPaddingSeconds(NaN);
|
||||||
|
expect(useEditorStore.getState().zonePreviewPaddingSeconds).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clamps to upper bound', () => {
|
||||||
|
useEditorStore.getState().setZonePreviewPaddingSeconds(20);
|
||||||
|
expect(useEditorStore.getState().zonePreviewPaddingSeconds).toBe(10);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { temporal } from 'zundo';
|
import { temporal } from 'zundo';
|
||||||
|
import { assert } from '../lib/assert';
|
||||||
import type {
|
import type {
|
||||||
Word,
|
Word,
|
||||||
Segment,
|
Segment,
|
||||||
@ -109,7 +110,7 @@ interface EditorActions {
|
|||||||
replaceWordRange: (startIndex: number, endIndex: number, newWords: Word[]) => void;
|
replaceWordRange: (startIndex: number, endIndex: number, newWords: Word[]) => void;
|
||||||
getKeepSegments: () => Array<{ start: number; end: number }>;
|
getKeepSegments: () => Array<{ start: number; end: number }>;
|
||||||
getWordAtTime: (time: number) => number;
|
getWordAtTime: (time: number) => number;
|
||||||
loadProject: (projectData: any) => void;
|
loadProject: (projectData: any) => number;
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
setZoomConfig: (config: Partial<ZoomConfig>) => void;
|
setZoomConfig: (config: Partial<ZoomConfig>) => void;
|
||||||
addAdditionalClip: (path: string, label?: string) => void;
|
addAdditionalClip: (path: string, label?: string) => void;
|
||||||
@ -203,7 +204,10 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
|
|
||||||
setExportedAudioPath: (path) => set({ exportedAudioPath: path }),
|
setExportedAudioPath: (path) => set({ exportedAudioPath: path }),
|
||||||
|
|
||||||
setTranscriptionModel: (model) => set({ transcriptionModel: model }),
|
setTranscriptionModel: (model) => {
|
||||||
|
if (model === null || model === '') return;
|
||||||
|
set({ transcriptionModel: model });
|
||||||
|
},
|
||||||
|
|
||||||
saveProject: (): ProjectFile => {
|
saveProject: (): ProjectFile => {
|
||||||
const { videoPath, words, segments, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, silenceTrimGroups, timelineMarkers, transcriptionModel, language, exportedAudioPath, zoomConfig, additionalClips, backgroundMusic } = get();
|
const { videoPath, words, segments, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, silenceTrimGroups, timelineMarkers, transcriptionModel, language, exportedAudioPath, zoomConfig, additionalClips, backgroundMusic } = get();
|
||||||
@ -239,6 +243,7 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
loadVideo: (path) => {
|
loadVideo: (path) => {
|
||||||
|
if (!path) return;
|
||||||
const { backendUrl, zonePreviewPaddingSeconds } = get();
|
const { backendUrl, zonePreviewPaddingSeconds } = get();
|
||||||
const url = `${backendUrl}/file?path=${encodeURIComponent(path)}`;
|
const url = `${backendUrl}/file?path=${encodeURIComponent(path)}`;
|
||||||
set({
|
set({
|
||||||
@ -252,6 +257,10 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
setTranscription: (result) => {
|
setTranscription: (result) => {
|
||||||
|
if (!result.words || result.words.length === 0) {
|
||||||
|
set({ words: [], segments: [], selectedWordIndices: [] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
let globalIdx = 0;
|
let globalIdx = 0;
|
||||||
const annotatedSegments = result.segments.map((seg) => {
|
const annotatedSegments = result.segments.map((seg) => {
|
||||||
const annotated = { ...seg, globalStartIndex: globalIdx };
|
const annotated = { ...seg, globalStartIndex: globalIdx };
|
||||||
@ -269,8 +278,14 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
setCurrentTime: (time) => set({ currentTime: time }),
|
setCurrentTime: (time) => set({ currentTime: time }),
|
||||||
setDuration: (duration) => set({ duration }),
|
setDuration: (duration) => set({ duration }),
|
||||||
setIsPlaying: (playing) => set({ isPlaying: playing }),
|
setIsPlaying: (playing) => set({ isPlaying: playing }),
|
||||||
setMarkInTime: (time) => set({ markInTime: time }),
|
setMarkInTime: (time) => {
|
||||||
setMarkOutTime: (time) => set({ markOutTime: time }),
|
if (time !== null && !isFinite(time)) return;
|
||||||
|
set({ markInTime: time });
|
||||||
|
},
|
||||||
|
setMarkOutTime: (time) => {
|
||||||
|
if (time !== null && !isFinite(time)) return;
|
||||||
|
set({ markOutTime: time });
|
||||||
|
},
|
||||||
clearMarkRange: () => set({ markInTime: null, markOutTime: null }),
|
clearMarkRange: () => set({ markInTime: null, markOutTime: null }),
|
||||||
setSelectedWordIndices: (indices) => set({ selectedWordIndices: indices }),
|
setSelectedWordIndices: (indices) => set({ selectedWordIndices: indices }),
|
||||||
setHoveredWordIndex: (index) => set({ hoveredWordIndex: index }),
|
setHoveredWordIndex: (index) => set({ hoveredWordIndex: index }),
|
||||||
@ -294,7 +309,7 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
|
|
||||||
updateWordText: (index, text) => {
|
updateWordText: (index, text) => {
|
||||||
const { words, segments } = get();
|
const { words, segments } = get();
|
||||||
if (index < 0 || index >= words.length) return;
|
if (index < 0 || index >= words.length || !text) return;
|
||||||
const newWords = words.map((w, i) =>
|
const newWords = words.map((w, i) =>
|
||||||
i === index ? { ...w, word: text } : w
|
i === index ? { ...w, word: text } : w
|
||||||
);
|
);
|
||||||
@ -320,7 +335,8 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
addCutRange: (start, end, trimGroupId) => {
|
addCutRange: (start, end, trimGroupId) => {
|
||||||
const { cutRanges } = get();
|
const { cutRanges, duration } = get();
|
||||||
|
if (!isFinite(start) || !isFinite(end) || start < 0 || end - start < 0.01 || end > duration) return;
|
||||||
const newRange: CutRange = {
|
const newRange: CutRange = {
|
||||||
id: `cut_${nextRangeId++}`,
|
id: `cut_${nextRangeId++}`,
|
||||||
start,
|
start,
|
||||||
@ -331,7 +347,8 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
addMuteRange: (start, end) => {
|
addMuteRange: (start, end) => {
|
||||||
const { muteRanges } = get();
|
const { muteRanges, duration } = get();
|
||||||
|
if (!isFinite(start) || !isFinite(end) || start < 0 || end - start < 0.01 || end > duration) return;
|
||||||
const newRange: MuteRange = {
|
const newRange: MuteRange = {
|
||||||
id: `mute_${nextRangeId++}`,
|
id: `mute_${nextRangeId++}`,
|
||||||
start,
|
start,
|
||||||
@ -341,7 +358,8 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
addGainRange: (start, end, gainDb) => {
|
addGainRange: (start, end, gainDb) => {
|
||||||
const { gainRanges } = get();
|
const { gainRanges, duration } = get();
|
||||||
|
if (!isFinite(start) || !isFinite(end) || start < 0 || end - start < 0.01 || end > duration) return;
|
||||||
const newRange: GainRange = {
|
const newRange: GainRange = {
|
||||||
id: `gain_${nextRangeId++}`,
|
id: `gain_${nextRangeId++}`,
|
||||||
start,
|
start,
|
||||||
@ -352,7 +370,8 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
addSpeedRange: (start, end, speed) => {
|
addSpeedRange: (start, end, speed) => {
|
||||||
const { speedRanges } = get();
|
const { speedRanges, duration } = get();
|
||||||
|
if (!isFinite(start) || !isFinite(end) || start < 0 || end - start < 0.01 || end > duration) return;
|
||||||
const newRange: SpeedRange = {
|
const newRange: SpeedRange = {
|
||||||
id: `speed_${nextRangeId++}`,
|
id: `speed_${nextRangeId++}`,
|
||||||
start,
|
start,
|
||||||
@ -437,6 +456,10 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
setGlobalGainDb: (gainDb) => {
|
setGlobalGainDb: (gainDb) => {
|
||||||
|
if (!isFinite(gainDb)) {
|
||||||
|
set({ globalGainDb: 0 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
set({ globalGainDb: Math.max(-24, Math.min(24, gainDb)) });
|
set({ globalGainDb: Math.max(-24, Math.min(24, gainDb)) });
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -482,11 +505,13 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
addTimelineMarker: (time, label, color) => {
|
addTimelineMarker: (time, label, color) => {
|
||||||
const { timelineMarkers } = get();
|
if (!isFinite(time) || time < 0) return;
|
||||||
|
const { timelineMarkers, duration } = get();
|
||||||
|
if (time > duration) return;
|
||||||
const newMarker: TimelineMarker = {
|
const newMarker: TimelineMarker = {
|
||||||
id: `marker_${nextRangeId++}`,
|
id: `marker_${nextRangeId++}`,
|
||||||
time,
|
time,
|
||||||
label: label || `Marker ${timelineMarkers.length + 1}`,
|
label: label || 'Marker',
|
||||||
color: color || '#6366f1',
|
color: color || '#6366f1',
|
||||||
};
|
};
|
||||||
set({ timelineMarkers: [...timelineMarkers, newMarker].sort((a, b) => a.time - b.time) });
|
set({ timelineMarkers: [...timelineMarkers, newMarker].sort((a, b) => a.time - b.time) });
|
||||||
@ -529,6 +554,7 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
}),
|
}),
|
||||||
|
|
||||||
setZonePreviewPaddingSeconds: (seconds) => {
|
setZonePreviewPaddingSeconds: (seconds) => {
|
||||||
|
if (!isFinite(seconds)) return;
|
||||||
const nextSeconds = Math.max(0, Math.min(10, seconds));
|
const nextSeconds = Math.max(0, Math.min(10, seconds));
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
window.localStorage.setItem(ZONE_PREVIEW_PADDING_KEY, String(nextSeconds));
|
window.localStorage.setItem(ZONE_PREVIEW_PADDING_KEY, String(nextSeconds));
|
||||||
@ -538,6 +564,9 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
|
|
||||||
replaceWordRange: (startIndex, endIndex, newWords) => {
|
replaceWordRange: (startIndex, endIndex, newWords) => {
|
||||||
const { words } = get();
|
const { words } = get();
|
||||||
|
assert(startIndex >= 0 && startIndex < words.length, 'replaceWordRange: startIndex out of bounds');
|
||||||
|
assert(endIndex >= 0 && endIndex < words.length, 'replaceWordRange: endIndex out of bounds');
|
||||||
|
assert(startIndex <= endIndex, 'replaceWordRange: startIndex > endIndex');
|
||||||
if (startIndex < 0 || endIndex >= words.length || startIndex > endIndex) return;
|
if (startIndex < 0 || endIndex >= words.length || startIndex > endIndex) return;
|
||||||
|
|
||||||
// Replace words in the range with new words
|
// Replace words in the range with new words
|
||||||
@ -647,6 +676,10 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
setBackgroundMusic: (config) => {
|
setBackgroundMusic: (config) => {
|
||||||
|
if (!config || !config.path) {
|
||||||
|
set({ backgroundMusic: null });
|
||||||
|
return;
|
||||||
|
}
|
||||||
set({ backgroundMusic: config });
|
set({ backgroundMusic: config });
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -657,9 +690,28 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
},
|
},
|
||||||
|
|
||||||
loadProject: (data) => {
|
loadProject: (data) => {
|
||||||
const { backendUrl, zonePreviewPaddingSeconds, projectFilePath } = get();
|
const { backendUrl, zonePreviewPaddingSeconds, projectFilePath, duration } = get();
|
||||||
const url = `${backendUrl}/file?path=${encodeURIComponent(data.videoPath)}`;
|
const url = `${backendUrl}/file?path=${encodeURIComponent(data.videoPath)}`;
|
||||||
|
|
||||||
|
const isValidZone = (r: { start: number; end: number }) =>
|
||||||
|
isFinite(r.start) && isFinite(r.end) && r.start >= 0 && r.end - r.start >= 0.01 && (duration <= 0 || r.end <= duration);
|
||||||
|
|
||||||
|
let removed = 0;
|
||||||
|
const filterZones = <T extends { start: number; end: number }>(ranges: T[]): T[] => {
|
||||||
|
const result: T[] = [];
|
||||||
|
for (const r of ranges) {
|
||||||
|
if (isValidZone(r)) { result.push(r); } else { removed++; }
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Backward compat: merge legacy deletedRanges into cutRanges as time-range cuts
|
||||||
|
const legacyCuts = (data.deletedRanges || []).map((r: any) => ({ id: r.id, start: r.start, end: r.end }));
|
||||||
|
const cleanedCutRanges = filterZones<CutRange>([...(data.cutRanges || []), ...legacyCuts]);
|
||||||
|
const cleanedMuteRanges = filterZones<MuteRange>(data.muteRanges || []);
|
||||||
|
const cleanedGainRanges = filterZones<GainRange>(data.gainRanges || []);
|
||||||
|
const cleanedSpeedRanges = filterZones<SpeedRange>(data.speedRanges || []);
|
||||||
|
|
||||||
let globalIdx = 0;
|
let globalIdx = 0;
|
||||||
const annotatedSegments = (data.segments || []).map((seg: Segment) => {
|
const annotatedSegments = (data.segments || []).map((seg: Segment) => {
|
||||||
const annotated = { ...seg, globalStartIndex: globalIdx };
|
const annotated = { ...seg, globalStartIndex: globalIdx };
|
||||||
@ -676,14 +728,10 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
videoUrl: url,
|
videoUrl: url,
|
||||||
words: data.words || [],
|
words: data.words || [],
|
||||||
segments: annotatedSegments,
|
segments: annotatedSegments,
|
||||||
// Backward compat: merge legacy deletedRanges into cutRanges as time-range cuts
|
cutRanges: cleanedCutRanges,
|
||||||
cutRanges: [
|
muteRanges: cleanedMuteRanges,
|
||||||
...(data.cutRanges || []),
|
gainRanges: cleanedGainRanges,
|
||||||
...(data.deletedRanges || []).map((r: any) => ({ id: r.id, start: r.start, end: r.end })),
|
speedRanges: cleanedSpeedRanges,
|
||||||
],
|
|
||||||
muteRanges: data.muteRanges || [],
|
|
||||||
gainRanges: data.gainRanges || [],
|
|
||||||
speedRanges: data.speedRanges || [],
|
|
||||||
globalGainDb: typeof data.globalGainDb === 'number' ? data.globalGainDb : 0,
|
globalGainDb: typeof data.globalGainDb === 'number' ? data.globalGainDb : 0,
|
||||||
silenceTrimGroups: data.silenceTrimGroups || [],
|
silenceTrimGroups: data.silenceTrimGroups || [],
|
||||||
timelineMarkers: data.timelineMarkers || [],
|
timelineMarkers: data.timelineMarkers || [],
|
||||||
@ -694,6 +742,8 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
additionalClips: data.additionalClips || [],
|
additionalClips: data.additionalClips || [],
|
||||||
backgroundMusic: data.backgroundMusic || null,
|
backgroundMusic: data.backgroundMusic || null,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return removed;
|
||||||
},
|
},
|
||||||
|
|
||||||
reset: () => {
|
reset: () => {
|
||||||
|
|||||||
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
@ -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 {};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
18
frontend/src/vite-env.d.ts
vendored
@ -8,6 +8,13 @@ interface ImportMeta {
|
|||||||
readonly env: ImportMetaEnv;
|
readonly env: ImportMetaEnv;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ModelInfo {
|
||||||
|
name: string;
|
||||||
|
path: string;
|
||||||
|
size_bytes: number;
|
||||||
|
kind: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface DesktopAPI {
|
interface DesktopAPI {
|
||||||
openFile: (options?: Record<string, unknown>) => Promise<string | null>;
|
openFile: (options?: Record<string, unknown>) => Promise<string | null>;
|
||||||
saveFile: (options?: Record<string, unknown>) => Promise<string | null>;
|
saveFile: (options?: Record<string, unknown>) => Promise<string | null>;
|
||||||
@ -20,6 +27,17 @@ interface DesktopAPI {
|
|||||||
transcribe: (filePath: string, modelName: string, language?: string) => Promise<any>;
|
transcribe: (filePath: string, modelName: string, language?: string) => Promise<any>;
|
||||||
readFile: (path: string) => Promise<string>;
|
readFile: (path: string) => Promise<string>;
|
||||||
writeFile: (path: string, content: string) => Promise<boolean>;
|
writeFile: (path: string, content: string) => Promise<boolean>;
|
||||||
|
activateLicense: (key: string) => Promise<any>;
|
||||||
|
getAppStatus: () => Promise<any>;
|
||||||
|
verifyLicense: (key: string) => Promise<any>;
|
||||||
|
deactivateLicense: () => Promise<void>;
|
||||||
|
hasLicenseFeature: (feature: string) => Promise<boolean>;
|
||||||
|
listModels: () => Promise<ModelInfo[]>;
|
||||||
|
deleteModel: (path: string) => Promise<void>;
|
||||||
|
logError: (message: string, stack: string, componentStack: string) => Promise<void>;
|
||||||
|
writeAutosave: (data: string) => Promise<void>;
|
||||||
|
readAutosave: () => Promise<string | null>;
|
||||||
|
deleteAutosave: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Window {
|
interface Window {
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/AIPanel.tsx","./src/components/AppendClipPanel.tsx","./src/components/BackgroundMusicPanel.tsx","./src/components/DevPanel.tsx","./src/components/ExportDialog.tsx","./src/components/MarkersPanel.tsx","./src/components/SettingsPanel.tsx","./src/components/SilenceTrimmerPanel.tsx","./src/components/TranscriptEditor.tsx","./src/components/VideoPlayer.tsx","./src/components/VolumePanel.tsx","./src/components/WaveformTimeline.tsx","./src/components/ZoneEditor.tsx","./src/hooks/useKeyboardShortcuts.ts","./src/hooks/useVideoSync.ts","./src/lib/dev-logger.ts","./src/lib/keybindings.ts","./src/lib/tauri-bridge.ts","./src/lib/thumbnails.ts","./src/store/aiStore.ts","./src/store/editorStore.test.ts","./src/store/editorStore.ts","./src/types/project.ts"],"version":"5.9.3"}
|
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/AIPanel.tsx","./src/components/AppendClipPanel.tsx","./src/components/BackgroundMusicPanel.tsx","./src/components/DevPanel.tsx","./src/components/ErrorBoundary.tsx","./src/components/ExportDialog.tsx","./src/components/HelpContent.tsx","./src/components/LicenseDialog.tsx","./src/components/MarkersPanel.tsx","./src/components/SettingsPanel.tsx","./src/components/SilenceTrimmerPanel.tsx","./src/components/TranscriptEditor.tsx","./src/components/VideoPlayer.tsx","./src/components/WaveformTimeline.tsx","./src/components/ZoneEditor.tsx","./src/hooks/useKeyboardShortcuts.ts","./src/hooks/useVideoSync.ts","./src/lib/assert.test.ts","./src/lib/assert.ts","./src/lib/dev-logger.ts","./src/lib/keybindings.ts","./src/lib/tauri-bridge.ts","./src/lib/thumbnails.ts","./src/store/aiStore.test.ts","./src/store/aiStore.ts","./src/store/editorStore.test.ts","./src/store/editorStore.ts","./src/store/licenseStore.test.ts","./src/store/licenseStore.ts","./src/types/project.ts"],"version":"5.9.3"}
|
||||||
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
@ -1,81 +1,146 @@
|
|||||||
# Plan for Building TalkEdit (Whisper.cpp + Tauri)
|
# TalkEdit — Launch Plan
|
||||||
|
|
||||||
Based on your original idea summary and our discussions, here's a detailed plan to build a standalone, local audio/video editor app. We'll modify CutScript as the base, migrate to **Tauri 2.0** (Rust backend + React frontend) for tiny, dependency-free installers, and use **Whisper.cpp** for fast, accurate transcription. This keeps the scope minimal, focuses on text-based editing for spoken content, and targets podcasters/YouTubers.
|
## Niche: "Descript for long-form content"
|
||||||
|
|
||||||
## 1. Overview
|
TalkEdit's defensible position: **works on hour+ files without degrading**, fully offline, one-time payment. No competitor owns this — Descript chokes on long content, CapCut limits mobile uploads, and both require accounts.
|
||||||
- **Goal**: Create an offline Descript alternative with word-level editing, transcription, and export. Users download one file (~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.
|
|
||||||
|
|
||||||
## 2. Tech Stack
|
**Current status (May 2026):** All core editing features are built and stable. Polish pass completed. 107 automated tests (95 frontend + 12 Rust). Ready for beta testing.
|
||||||
- **Frontend**: React + Vite + Tailwind CSS + shadcn/ui (from CutScript; minimal changes).
|
|
||||||
- **Backend**: Tauri 2.0 (Rust) – handles file I/O, FFmpeg calls, Whisper.cpp integration.
|
|
||||||
- **Transcription**: Whisper.cpp (via Rust bindings like `whisper-cpp-sys` or `whisper-rs`).
|
|
||||||
- **Audio/Video Processing**: FFmpeg (bundled or called via Rust wrappers like `ffmpeg-next`).
|
|
||||||
- **State Management**: Zustand (from CutScript).
|
|
||||||
- **Packaging**: Tauri's `tauri build` for cross-platform installers.
|
|
||||||
- **AI Features**: Local models only (no APIs); optional Ollama for fillers.
|
|
||||||
|
|
||||||
## 3. Step-by-Step Development Plan
|
---
|
||||||
1. **Set Up Tauri in CutScript** (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):
|
## Phase 1: Polish ✅ COMPLETED
|
||||||
- Add `whisper-cpp` as a dependency in `Cargo.toml`.
|
|
||||||
- Create a Rust module for transcription: Load models, process audio, return word-level timestamps.
|
|
||||||
- Replace Python backend calls with Tauri commands (e.g., `invoke` from frontend to Rust for transcription).
|
|
||||||
- Handle model downloads on first run (store in app data dir).
|
|
||||||
|
|
||||||
3. **Migrate Audio/Video Logic** (2 weeks):
|
### Reliability & error handling ✅
|
||||||
- Port FFmpeg calls to Rust (use `ffmpeg-next` for cutting/export).
|
- [x] Backend health check — polls `/health` every 30s, shows reconnecting banner
|
||||||
- Implement segment calculation: From edited transcript, build keep_segments with padding/fades.
|
- [x] Export failure reporting — surfaces FFmpeg stderr with copy-to-clipboard
|
||||||
- Add audio cleaning (noise reduction via bundled tools or Rust libs).
|
- [x] React ErrorBoundary catches render crashes, shows fallback with reload
|
||||||
|
- [x] Global JS error logging — `window.onerror` + `onunhandledrejection` logged to Rust backend
|
||||||
|
|
||||||
4. **Frontend Polish** (1–2 weeks):
|
### UX polish ✅
|
||||||
- Update UI for Tauri (file dialogs via `tauri-plugin-dialog`).
|
- [x] Tooltips on every button/control across all panels
|
||||||
- Refine transcript editor: Better timestamp syncing, manual adjustments.
|
- [x] Loading spinners for waveform, waveform retry button
|
||||||
- Add export options (MP4 with subs, audio-only).
|
- [x] Export progress bar (visual, not just text)
|
||||||
|
- [x] Help panel with full feature documentation
|
||||||
|
- [x] Keyboard cheatsheet overlay with close button and preset indicator
|
||||||
|
- [x] First-run welcome overlay with 3-step guide
|
||||||
|
- [x] `?` keyboard shortcut opens cheatsheet (accessible from Help panel)
|
||||||
|
- [x] Empty states: MarkersPanel, AIPanel, WaveformTimeline
|
||||||
|
- [x] Error states: AIPanel with retry, WaveformTimeline with retry
|
||||||
|
- [x] Auto-save crash recovery every 60s, restore prompt on next launch
|
||||||
|
- [x] Confirmation dialogs for zone/marker deletion
|
||||||
|
- [x] Disabled state for all buttons during export/transcription
|
||||||
|
- [x] Export button disabled when no video loaded
|
||||||
|
|
||||||
5. **Testing & Packaging** (1 week):
|
### Consistency ✅
|
||||||
- Test on Windows/macOS/Linux; ensure Whisper runs offline.
|
- [x] Mute zone color unified (blue everywhere)
|
||||||
- Bundle with `tauri build`; verify no external deps.
|
- [x] Disabled opacity unified (40% everywhere)
|
||||||
- Add auto-updater for Pro features.
|
- [x] Zone list items border radius unified (`rounded-lg`)
|
||||||
|
- [x] Toolbar button groups separated with visual dividers
|
||||||
|
- [x] Labels simplified: "Sound Gain", "Speed Adjust", "Trim Silence", "Chapter Marks", "Edit Zones", "Add Clips", "Bkg. Music", "AI Tools"
|
||||||
|
- [x] Model selector moved to AIPanel reprocess tab
|
||||||
|
- [x] Orphaned VolumePanel.tsx removed
|
||||||
|
|
||||||
6. **Launch & Iterate** (Ongoing):
|
### Trial & licensing ✅
|
||||||
- Open-source core on GitHub.
|
- [x] Trial duration: 7 days
|
||||||
- Market on Product Hunt, Reddit; gather feedback.
|
- [x] Trial bar on welcome screen with days remaining
|
||||||
|
- [x] Sentinel file prevents deleting trial.json to reset trial
|
||||||
|
- [x] XOR integrity check prevents editing trial.json timestamp
|
||||||
|
- [x] `canEdit` defaults to `false` (locked until status check confirms)
|
||||||
|
- [x] Email confirmation step before license activation (deters key sharing)
|
||||||
|
- [x] `verify_license` command (verify without caching)
|
||||||
|
- [x] Expired banner explains what still works (export, loading)
|
||||||
|
|
||||||
## 4. MVP Features (Minimal but Useful)
|
### Robustness ✅
|
||||||
Focus on what creators need for spoken content:
|
- [x] React ErrorBoundary
|
||||||
- **Drag-and-drop import**: Audio/video files; auto-extract audio.
|
- [x] Store-level input validation (reject NaN, clamp bounds, enforce min zone duration)
|
||||||
- **One-click transcription**: Whisper.cpp with model choice (Fast - less accurate: tiny/base; Slow - more accurate: small/medium/large).
|
- [x] Runtime assertions in critical paths (TranscriptEditor, WaveformTimeline, ExportDialog)
|
||||||
- **Text-based editing**: Scrollable transcript; click word → jump to video; select/delete words → auto-cut audio with 150ms fades.
|
- [x] Auto-save crash recovery
|
||||||
- **Smart cleanup**: Remove fillers ("um", pauses >0.8s) via local AI.
|
- [x] CI pipeline (GitHub Actions: Rust + Frontend + Python)
|
||||||
- **Preview & Export**: Synced preview; export MP4/audio with optional SRT subs.
|
- [x] Bad project state recovery (auto-prunes invalid zones on load, Dev Panel reset button)
|
||||||
- **Undo/Redo**: Full edit history.
|
- [x] 95 frontend tests (editorStore, licenseStore, aiStore, assert)
|
||||||
|
- [x] 12 Rust tests (licensing, models)
|
||||||
|
- [x] Canvas zone handles enlarged (r=6), hit area increased
|
||||||
|
- [x] Search match contrast improved
|
||||||
|
- [x] Split panes keyboard-accessible (arrow keys, tabIndex, ARIA)
|
||||||
|
|
||||||
No multi-track, voice cloning, or collaboration—keep it simple.
|
---
|
||||||
|
|
||||||
## 4. Notes
|
## Phase 2: Beta Launch (🚧 next — 2–4 weeks)
|
||||||
- Consider adding Parakeet TDT as a transcription option in the future for users who want alternatives to Whisper.
|
|
||||||
|
|
||||||
## 5. Monetization Model
|
**Goal:** Get working builds into real podcasters' hands. Validate the core promise (long-form, offline) before investing in edge-case features.
|
||||||
- **Free Forever**: Core editing/transcription (unlimited local use).
|
|
||||||
- **Pro License** ($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).
|
|
||||||
|
|
||||||
## 6. Timeline & Milestones
|
### Must-have for beta
|
||||||
- **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).
|
|
||||||
|
|
||||||
## 7. Risks & Tips
|
- [ ] **Smart chunking for transcription** — files >2hr. Without this the niche promise is unproven. Breaks transcription into overlapping chunks, reassembles with correct timestamps.
|
||||||
- **Risks**: Whisper.cpp compilation issues; Rust learning curve if new to it.
|
- [ ] **Hardware detection & model selection** — detect CUDA/ROCm/MPS at startup; expose model backend choice in Settings so beta users can configure their system.
|
||||||
- **Tips**: Start with small models (base ~70MB); test timestamp accuracy early. Use Tauri's docs for migration. If stuck, fall back to bundling Python for Whisper (but avoid for true standalone).
|
- [ ] **GitHub v1.0.0 release** — tag, binary builds (AppImage + .deb), release notes.
|
||||||
- **Resources**: Tauri docs, Whisper.cpp GitHub, Rust audio crates.</content>
|
|
||||||
<parameter name="filePath">/home/dillon/_code/audio_editor/plan.md
|
### Sales & distribution
|
||||||
|
- [ ] **Stripe integration** — payment processing for one-time purchases (Pro $39, Business $79). License key generation + email delivery on payment success.
|
||||||
|
- [ ] **Landing page + download site** — simple site with: feature overview, pricing tiers, download links (AppImage/.deb), license activation flow. No auth system needed — Stripe handles payments, license keys unlock the app.
|
||||||
|
|
||||||
|
### Beta program
|
||||||
|
|
||||||
|
- [ ] **Free licenses to 20 podcasters** — in exchange for feedback + permission to quote. Target: r/podcasting regulars, small-to-medium shows (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
@ -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
|
||||||
|
|
||||||
1
src-tauri/.gitignore
vendored
@ -2,3 +2,4 @@
|
|||||||
# will have compiled files and executables
|
# will have compiled files and executables
|
||||||
/target/
|
/target/
|
||||||
/gen/schemas
|
/gen/schemas
|
||||||
|
binaries/
|
||||||
|
|||||||
110
src-tauri/Cargo.lock
generated
@ -79,7 +79,9 @@ checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
|||||||
name = "app"
|
name = "app"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"base64 0.22.1",
|
||||||
"dirs 5.0.1",
|
"dirs 5.0.1",
|
||||||
|
"ed25519-dalek",
|
||||||
"hound",
|
"hound",
|
||||||
"log",
|
"log",
|
||||||
"serde",
|
"serde",
|
||||||
@ -146,6 +148,12 @@ version = "0.22.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64ct"
|
||||||
|
version = "1.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bit-set"
|
name = "bit-set"
|
||||||
version = "0.8.0"
|
version = "0.8.0"
|
||||||
@ -450,6 +458,12 @@ dependencies = [
|
|||||||
"memchr",
|
"memchr",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "const-oid"
|
||||||
|
version = "0.9.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "convert_case"
|
name = "convert_case"
|
||||||
version = "0.4.0"
|
version = "0.4.0"
|
||||||
@ -599,6 +613,33 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "curve25519-dalek"
|
||||||
|
version = "4.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures",
|
||||||
|
"curve25519-dalek-derive",
|
||||||
|
"digest",
|
||||||
|
"fiat-crypto",
|
||||||
|
"rustc_version",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "curve25519-dalek-derive"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.117",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "darling"
|
name = "darling"
|
||||||
version = "0.23.0"
|
version = "0.23.0"
|
||||||
@ -633,6 +674,16 @@ dependencies = [
|
|||||||
"syn 2.0.117",
|
"syn 2.0.117",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "der"
|
||||||
|
version = "0.7.10"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||||
|
dependencies = [
|
||||||
|
"const-oid",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deranged"
|
name = "deranged"
|
||||||
version = "0.5.8"
|
version = "0.5.8"
|
||||||
@ -826,6 +877,30 @@ version = "1.0.20"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ed25519"
|
||||||
|
version = "2.2.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
|
||||||
|
dependencies = [
|
||||||
|
"pkcs8",
|
||||||
|
"signature",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ed25519-dalek"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
|
||||||
|
dependencies = [
|
||||||
|
"curve25519-dalek",
|
||||||
|
"ed25519",
|
||||||
|
"serde",
|
||||||
|
"sha2",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "embed-resource"
|
name = "embed-resource"
|
||||||
version = "3.0.8"
|
version = "3.0.8"
|
||||||
@ -907,6 +982,12 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fiat-crypto"
|
||||||
|
version = "0.2.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "field-offset"
|
name = "field-offset"
|
||||||
version = "0.3.6"
|
version = "0.3.6"
|
||||||
@ -2536,6 +2617,16 @@ version = "0.1.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pkcs8"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
|
||||||
|
dependencies = [
|
||||||
|
"der",
|
||||||
|
"spki",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pkg-config"
|
name = "pkg-config"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
@ -3397,6 +3488,15 @@ version = "1.3.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "signature"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||||
|
dependencies = [
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "simd-adler32"
|
name = "simd-adler32"
|
||||||
version = "0.3.8"
|
version = "0.3.8"
|
||||||
@ -3491,6 +3591,16 @@ dependencies = [
|
|||||||
"system-deps",
|
"system-deps",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "spki"
|
||||||
|
version = "0.7.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"der",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stable_deref_trait"
|
name = "stable_deref_trait"
|
||||||
version = "1.2.1"
|
version = "1.2.1"
|
||||||
|
|||||||
@ -1,15 +1,13 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "app"
|
name = "app"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
description = "A Tauri App"
|
description = "TalkEdit - AI-powered video editor"
|
||||||
authors = ["you"]
|
authors = ["you"]
|
||||||
license = ""
|
license = ""
|
||||||
repository = ""
|
repository = ""
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
rust-version = "1.77.2"
|
rust-version = "1.77.2"
|
||||||
|
|
||||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
name = "app_lib"
|
name = "app_lib"
|
||||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||||
@ -29,3 +27,5 @@ dirs = "5.0"
|
|||||||
ureq = "2.9"
|
ureq = "2.9"
|
||||||
hound = "3.5"
|
hound = "3.5"
|
||||||
tempfile = "3.10"
|
tempfile = "3.10"
|
||||||
|
ed25519-dalek = "2"
|
||||||
|
base64 = "0.22"
|
||||||
|
|||||||
@ -11,8 +11,8 @@
|
|||||||
"dialog:allow-open",
|
"dialog:allow-open",
|
||||||
"dialog:allow-save",
|
"dialog:allow-save",
|
||||||
"fs:default",
|
"fs:default",
|
||||||
{ "identifier": "fs:allow-read-text-file", "allow": [{ "path": "$HOME/**" }] },
|
{ "identifier": "fs:allow-read-text-file", "allow": [{ "path": "$HOME/**" }, { "path": "**" }] },
|
||||||
{ "identifier": "fs:allow-write-text-file", "allow": [{ "path": "$HOME/**" }] },
|
{ "identifier": "fs:allow-write-text-file", "allow": [{ "path": "$HOME/**" }, { "path": "**" }] },
|
||||||
"fs:allow-app-read-recursive",
|
"fs:allow-app-read-recursive",
|
||||||
"fs:allow-app-write-recursive"
|
"fs:allow-app-write-recursive"
|
||||||
]
|
]
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 2.3 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 4.7 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 719 B |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 629 B |
|
Before Width: | Height: | Size: 28 KiB After Width: | Height: | Size: 5.8 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 922 B |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 7.4 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 968 B |
|
Before Width: | Height: | Size: 37 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 10 KiB |
1
src-tauri/icons/icon.svg
Normal file
@ -0,0 +1 @@
|
|||||||
|
<svg width="48" height="48" viewBox="0 0 36 36" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M6 10h12a6 6 0 0 1 0 12H8l-2 4V10Z" stroke="#818cf8" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" opacity="0.7"></path><path d="M6 10h12a6 6 0 0 1 0 12H8l-2 4V10Z" stroke="#6366f1" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"></path><path d="M10 14v4M13 13v6M16 14v4" stroke="#6366f1" stroke-width="1.5" stroke-linecap="round"></path><path d="M22 16h6M22 19h4M22 22h5" stroke="#818cf8" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.6"></path></svg>
|
||||||
|
After Width: | Height: | Size: 622 B |
@ -1,5 +1,7 @@
|
|||||||
// --- Commands ---
|
// --- Commands ---
|
||||||
|
|
||||||
|
use tauri::Manager;
|
||||||
|
|
||||||
mod paths;
|
mod paths;
|
||||||
mod transcription;
|
mod transcription;
|
||||||
mod video_editor;
|
mod video_editor;
|
||||||
@ -8,6 +10,8 @@ mod diarization;
|
|||||||
mod ai_provider;
|
mod ai_provider;
|
||||||
mod caption_generator;
|
mod caption_generator;
|
||||||
mod background_removal;
|
mod background_removal;
|
||||||
|
mod licensing;
|
||||||
|
mod models;
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn get_projects_directory() -> Result<String, String> {
|
fn get_projects_directory() -> Result<String, String> {
|
||||||
@ -204,6 +208,106 @@ async fn save_captions(content: String, output_path: String) -> Result<String, S
|
|||||||
.map_err(|e| format!("Task error: {:?}", e))?
|
.map_err(|e| format!("Task error: {:?}", e))?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// List downloaded models (Whisper + LLM) with sizes.
|
||||||
|
#[tauri::command]
|
||||||
|
fn log_error(message: String, stack: String, component_stack: String) {
|
||||||
|
log::error!(
|
||||||
|
"[Frontend Error] {} — Stack: {} — Component: {}",
|
||||||
|
message,
|
||||||
|
stack,
|
||||||
|
component_stack,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// List downloaded models (Whisper + LLM) with sizes.
|
||||||
|
#[tauri::command]
|
||||||
|
fn list_models(app_handle: tauri::AppHandle) -> Result<Vec<models::ModelInfo>, String> {
|
||||||
|
let data_dir = app_handle
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|e| format!("No app data directory: {e}"))?;
|
||||||
|
Ok(models::list_models(&data_dir))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete a downloaded model by path.
|
||||||
|
#[tauri::command]
|
||||||
|
fn delete_model(path: String) -> Result<(), String> {
|
||||||
|
models::delete_model(&path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the combined app status: licensed, trial, or expired.
|
||||||
|
#[tauri::command]
|
||||||
|
fn get_app_status(app_handle: tauri::AppHandle) -> Result<licensing::AppStatus, String> {
|
||||||
|
let data_dir = app_handle
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|e| format!("No app data directory: {e}"))?;
|
||||||
|
Ok(licensing::get_app_status(&data_dir))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify a license key signature without caching. Returns the payload.
|
||||||
|
#[tauri::command]
|
||||||
|
fn verify_license(license_key: String) -> Result<licensing::LicensePayload, String> {
|
||||||
|
licensing::verify_license_key(&license_key)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Verify and activate a license key.
|
||||||
|
#[tauri::command]
|
||||||
|
fn activate_license(app_handle: tauri::AppHandle, license_key: String) -> Result<licensing::LicensePayload, String> {
|
||||||
|
let data_dir = app_handle
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|e| format!("No app data directory: {e}"))?;
|
||||||
|
|
||||||
|
let payload = licensing::verify_license_key(&license_key)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
licensing::cache_license(&data_dir, &license_key)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
// Clear trial state since user has a valid license
|
||||||
|
licensing::clear_trial(&data_dir);
|
||||||
|
|
||||||
|
Ok(payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the cached license (deactivate). Trial will resume if still valid.
|
||||||
|
#[tauri::command]
|
||||||
|
fn deactivate_license(app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
let data_dir = app_handle
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|e| format!("No app data directory: {e}"))?;
|
||||||
|
licensing::remove_license(&data_dir);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Start the free trial if not already started. Returns trial info.
|
||||||
|
#[tauri::command]
|
||||||
|
fn start_trial(app_handle: tauri::AppHandle) -> Result<licensing::TrialState, String> {
|
||||||
|
let data_dir = app_handle
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|e| format!("No app data directory: {e}"))?;
|
||||||
|
let trial = licensing::get_or_start_trial(&data_dir);
|
||||||
|
Ok(trial)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Check if a specific feature is enabled (requires valid license).
|
||||||
|
#[tauri::command]
|
||||||
|
fn has_license_feature(app_handle: tauri::AppHandle, feature: String) -> Result<bool, String> {
|
||||||
|
let data_dir = app_handle
|
||||||
|
.path()
|
||||||
|
.app_data_dir()
|
||||||
|
.map_err(|e| format!("No app data directory: {e}"))?;
|
||||||
|
|
||||||
|
match licensing::load_cached_license(&data_dir) {
|
||||||
|
Ok(payload) => Ok(licensing::has_feature(&payload, &feature)),
|
||||||
|
Err(_) => Ok(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Check if background removal is available
|
/// Check if background removal is available
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
async fn is_background_removal_available() -> Result<bool, String> {
|
async fn is_background_removal_available() -> Result<bool, String> {
|
||||||
@ -224,6 +328,39 @@ async fn remove_background_on_export(input_path: String, output_path: String, re
|
|||||||
.map_err(|e| format!("Task error: {:?}", e))?
|
.map_err(|e| format!("Task error: {:?}", e))?
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Write autosave data to the app data directory
|
||||||
|
#[tauri::command]
|
||||||
|
fn write_autosave(app_handle: tauri::AppHandle, data: String) -> Result<(), String> {
|
||||||
|
let data_dir = app_handle.path().app_data_dir().map_err(|e| format!("No app data directory: {e}"))?;
|
||||||
|
let path = data_dir.join("autosave.json");
|
||||||
|
std::fs::write(&path, data).map_err(|e| format!("Failed to write autosave: {e}"))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read autosave data if it exists
|
||||||
|
#[tauri::command]
|
||||||
|
fn read_autosave(app_handle: tauri::AppHandle) -> Result<Option<String>, String> {
|
||||||
|
let data_dir = app_handle.path().app_data_dir().map_err(|e| format!("No app data directory: {e}"))?;
|
||||||
|
let path = data_dir.join("autosave.json");
|
||||||
|
match std::fs::read_to_string(&path) {
|
||||||
|
Ok(data) => Ok(Some(data)),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||||
|
Err(e) => Err(format!("Failed to read autosave: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete the autosave file
|
||||||
|
#[tauri::command]
|
||||||
|
fn delete_autosave(app_handle: tauri::AppHandle) -> Result<(), String> {
|
||||||
|
let data_dir = app_handle.path().app_data_dir().map_err(|e| format!("No app data directory: {e}"))?;
|
||||||
|
let path = data_dir.join("autosave.json");
|
||||||
|
match std::fs::remove_file(&path) {
|
||||||
|
Ok(()) => Ok(()),
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
|
||||||
|
Err(e) => Err(format!("Failed to delete autosave: {e}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- App entry point ---
|
// --- App entry point ---
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
@ -239,6 +376,36 @@ pub fn run() {
|
|||||||
.build(),
|
.build(),
|
||||||
)?;
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for cached license or trial at startup
|
||||||
|
if let Ok(data_dir) = app.path().app_data_dir() {
|
||||||
|
match licensing::get_app_status(&data_dir) {
|
||||||
|
licensing::AppStatus::Licensed { license: payload } => {
|
||||||
|
log::info!(
|
||||||
|
"License: {} ({}), expires {}",
|
||||||
|
payload.customer_email,
|
||||||
|
payload.tier,
|
||||||
|
payload.expires_at,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
licensing::AppStatus::Trial { days_remaining, .. } => {
|
||||||
|
log::info!("Trial active: {days_remaining} days remaining");
|
||||||
|
}
|
||||||
|
licensing::AppStatus::Expired => {
|
||||||
|
log::info!("Trial expired — license activation required");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add bundled ffmpeg/ffprobe to PATH so Python subprocesses find them
|
||||||
|
if let Some(ffmpeg_dir) = paths::bundled_ffmpeg().and_then(|p| p.parent().map(|p| p.to_path_buf())) {
|
||||||
|
if let Ok(current_path) = std::env::var("PATH") {
|
||||||
|
let new_path = format!("{}:{}", ffmpeg_dir.display(), current_path);
|
||||||
|
std::env::set_var("PATH", &new_path);
|
||||||
|
}
|
||||||
|
log::info!("Added bundled FFmpeg directory to PATH: {}", ffmpeg_dir.display());
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
})
|
})
|
||||||
.invoke_handler(tauri::generate_handler![
|
.invoke_handler(tauri::generate_handler![
|
||||||
@ -263,6 +430,18 @@ pub fn run() {
|
|||||||
save_captions,
|
save_captions,
|
||||||
is_background_removal_available,
|
is_background_removal_available,
|
||||||
remove_background_on_export,
|
remove_background_on_export,
|
||||||
|
get_app_status,
|
||||||
|
activate_license,
|
||||||
|
deactivate_license,
|
||||||
|
verify_license,
|
||||||
|
start_trial,
|
||||||
|
has_license_feature,
|
||||||
|
list_models,
|
||||||
|
delete_model,
|
||||||
|
log_error,
|
||||||
|
write_autosave,
|
||||||
|
read_autosave,
|
||||||
|
delete_autosave,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
293
src-tauri/src/licensing.rs
Normal file
@ -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
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -62,7 +62,38 @@ pub fn backend_script(name: &str) -> PathBuf {
|
|||||||
project_root().join("backend").join(name)
|
project_root().join("backend").join(name)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Absolute path to a script at the project root.
|
/// Locate bundled ffmpeg binary (sidecar).
|
||||||
|
pub fn bundled_ffmpeg() -> Option<PathBuf> {
|
||||||
|
let exe = std::env::current_exe().ok()?;
|
||||||
|
let dir = exe.parent()?;
|
||||||
|
// Tauri places externalBin next to the executable
|
||||||
|
let candidates = [
|
||||||
|
dir.join("ffmpeg"),
|
||||||
|
dir.join("binaries").join("ffmpeg"),
|
||||||
|
];
|
||||||
|
for c in &candidates {
|
||||||
|
if c.exists() {
|
||||||
|
return Some(c.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Locate bundled ffprobe binary (sidecar).
|
||||||
|
pub fn bundled_ffprobe() -> Option<PathBuf> {
|
||||||
|
let exe = std::env::current_exe().ok()?;
|
||||||
|
let dir = exe.parent()?;
|
||||||
|
let candidates = [
|
||||||
|
dir.join("ffprobe"),
|
||||||
|
dir.join("binaries").join("ffprobe"),
|
||||||
|
];
|
||||||
|
for c in &candidates {
|
||||||
|
if c.exists() {
|
||||||
|
return Some(c.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
pub fn root_script(name: &str) -> PathBuf {
|
pub fn root_script(name: &str) -> PathBuf {
|
||||||
project_root().join(name)
|
project_root().join(name)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,7 @@
|
|||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../frontend/dist",
|
"frontendDist": "../frontend/dist",
|
||||||
"devUrl": "http://localhost:5173",
|
"devUrl": "http://localhost:5173",
|
||||||
"beforeDevCommand": "cd frontend && npm run dev",
|
"beforeDevCommand": "lsof -ti:5173 | xargs kill -9 2>/dev/null; cd frontend && npm run dev",
|
||||||
"beforeBuildCommand": "cd frontend && npm run build"
|
"beforeBuildCommand": "cd frontend && npm run build"
|
||||||
},
|
},
|
||||||
"app": {
|
"app": {
|
||||||
@ -35,6 +35,10 @@
|
|||||||
"icons/128x128@2x.png",
|
"icons/128x128@2x.png",
|
||||||
"icons/icon.icns",
|
"icons/icon.icns",
|
||||||
"icons/icon.ico"
|
"icons/icon.ico"
|
||||||
|
],
|
||||||
|
"externalBin": [
|
||||||
|
"binaries/ffmpeg",
|
||||||
|
"binaries/ffprobe"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -45,7 +45,16 @@ def main():
|
|||||||
device = "cpu"
|
device = "cpu"
|
||||||
compute_type = "int8"
|
compute_type = "int8"
|
||||||
|
|
||||||
model = WhisperModel(model_name, device=device, compute_type=compute_type)
|
try:
|
||||||
|
model = WhisperModel(model_name, device=device, compute_type=compute_type)
|
||||||
|
except RuntimeError as e:
|
||||||
|
if "out of memory" in str(e).lower() and device == "cuda":
|
||||||
|
print(f"CUDA OOM, falling back to CPU (int8)", file=sys.stderr)
|
||||||
|
device = "cpu"
|
||||||
|
compute_type = "int8"
|
||||||
|
model = WhisperModel(model_name, device=device, compute_type=compute_type)
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
# Transcribe with progress reporting
|
# Transcribe with progress reporting
|
||||||
print(f"Starting transcription of {wav_path} with model {model_name}", file=sys.stderr)
|
print(f"Starting transcription of {wav_path} with model {model_name}", file=sys.stderr)
|
||||||
|
|||||||