From fd6697b48e53d90f16137ad4791149dc97a48a65 Mon Sep 17 00:00:00 2001 From: dillonj Date: Wed, 6 May 2026 10:53:27 -0600 Subject: [PATCH] polishing --- frontend/src/App.tsx | 243 +++++++------ frontend/src/components/AIPanel.tsx | 11 + frontend/src/components/AppendClipPanel.tsx | 7 +- .../src/components/BackgroundMusicPanel.tsx | 8 +- frontend/src/components/ExportDialog.tsx | 21 ++ frontend/src/components/LicenseDialog.tsx | 1 + frontend/src/components/MarkersPanel.tsx | 18 +- frontend/src/components/SettingsPanel.tsx | 120 ++++++- .../src/components/SilenceTrimmerPanel.tsx | 11 +- frontend/src/components/TranscriptEditor.tsx | 14 +- frontend/src/components/WaveformTimeline.tsx | 12 +- frontend/src/components/ZoneEditor.tsx | 20 +- frontend/src/index.css | 43 +++ frontend/src/lib/tauri-bridge.ts | 8 + frontend/src/vite-env.d.ts | 9 + polish_plan.md | 328 ++++++++++++++++++ src-tauri/src/lib.rs | 19 + src-tauri/src/models.rs | 141 ++++++++ 18 files changed, 889 insertions(+), 145 deletions(-) create mode 100644 polish_plan.md create mode 100644 src-tauri/src/models.rs diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d2a402d..4772e19 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -617,105 +617,114 @@ export default function App() { <>
setShowFileMenu(false)} />
- } label="New Project" onClick={() => { setShowFileMenu(false); handleNewProject(); }} /> - } label="Open File" onClick={() => { setShowFileMenu(false); handleOpenFile(); }} /> - } label="Load Project" onClick={() => { setShowFileMenu(false); handleLoadProject(); }} /> + } label="New Project" title="Start a new empty project" onClick={() => { setShowFileMenu(false); handleNewProject(); }} /> + } label="Open File" title="Open a video or audio file for transcription" onClick={() => { setShowFileMenu(false); handleOpenFile(); }} /> + } label="Load Project" title="Open a saved .aive project file" onClick={() => { setShowFileMenu(false); handleLoadProject(); }} />
- } label="Save" onClick={() => { setShowFileMenu(false); handleSaveProject(); }} disabled={words.length === 0} /> - } label="Save As" onClick={() => { setShowFileMenu(false); handleSaveProjectAs(); }} disabled={words.length === 0} /> + } label="Save" title="Save current project" onClick={() => { setShowFileMenu(false); handleSaveProject(); }} disabled={words.length === 0} /> + } label="Save As" title="Save a copy of the current project" onClick={() => { setShowFileMenu(false); handleSaveProjectAs(); }} disabled={words.length === 0} />
)}
- } - label="Cut" - onClick={handleCut} - active={cutMode} - disabled={!canEdit} - /> - } - label="Mute" - onClick={handleMute} - active={muteMode} - disabled={!canEdit} - /> -
} - label="Gain Zone" - onClick={handleGain} - active={gainMode} + icon={} + label="Cut" + onClick={handleCut} + active={cutMode} disabled={!canEdit} + title="Cut selected word range or mark in/out area — removes the segment from output" /> - 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" - disabled={!canEdit} - /> -
-
} - label="Speed Zone" - onClick={handleSpeed} - active={speedMode} + icon={} + label="Mute" + onClick={handleMute} + active={muteMode} disabled={!canEdit} + title="Mute selected word range or mark in/out area — silences audio, keeps video" /> - setSpeedModeValue(Math.max(0.25, Math.min(4, Number(e.target.value) || 1)))} - className="w-16 px-1.5 py-1 text-xs bg-editor-surface border border-editor-border rounded text-editor-text focus:outline-none focus:border-editor-accent" - title="Playback rate for new speed zones" - disabled={!canEdit} +
+ } + label="Gain Zone" + onClick={handleGain} + active={gainMode} + disabled={!canEdit} + title="Add gain zone from selection or mark in/out — adjust volume up or down" + /> + 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" + data-tooltip="Volume adjustment in decibels for new gain zones — positive boosts, negative reduces" + disabled={!canEdit} + /> +
+
+ } + label="Speed Zone" + onClick={handleSpeed} + active={speedMode} + disabled={!canEdit} + title="Add speed zone from selection or mark in/out — change playback speed" + /> + setSpeedModeValue(Math.max(0.25, Math.min(4, Number(e.target.value) || 1)))} + className="w-16 px-1.5 py-1 text-xs bg-editor-surface border border-editor-border rounded text-editor-text focus:outline-none focus:border-editor-accent" + data-tooltip="Playback speed multiplier for new speed zones — 1x is normal, 2x is double speed" + disabled={!canEdit} + /> +
+ } + label="Zones" + active={activePanel === 'zones'} + onClick={() => togglePanel('zones')} + disabled={!videoPath || !canEdit} + title="Open zone editor panel — view and manage all cut, mute, gain, and speed zones" + /> + PA} + label="Pause Trim" + active={activePanel === 'silence'} + onClick={() => togglePanel('silence')} + disabled={!videoPath || !canEdit} + title="Detect and remove silent pauses — batch-removes silence above a configurable threshold" + /> + } + label="Markers" + active={activePanel === 'markers'} + onClick={() => togglePanel('markers')} + disabled={!videoPath || !canEdit} + title="Add and manage timeline markers — chapter points, key moments, YouTube timestamps" + /> + } + label="Music" + active={activePanel === 'music'} + onClick={() => togglePanel('music')} + disabled={!videoPath || !canEdit} + title="Add background music track with auto-ducking — music lowers when someone speaks" + /> + } + label="Append" + active={activePanel === 'append'} + onClick={() => togglePanel('append')} + disabled={!videoPath || !canEdit} + title="Append additional video clips — concatenate multiple files during export" /> -
- } - label="Zones" - active={activePanel === 'zones'} - onClick={() => togglePanel('zones')} - disabled={!videoPath || !canEdit} - /> - PA} - label="Pause Trim" - active={activePanel === 'silence'} - onClick={() => togglePanel('silence')} - disabled={!videoPath || !canEdit} - /> - } - label="Markers" - active={activePanel === 'markers'} - onClick={() => togglePanel('markers')} - disabled={!videoPath || !canEdit} - /> - } - label="Music" - active={activePanel === 'music'} - onClick={() => togglePanel('music')} - disabled={!videoPath || !canEdit} - /> - } - label="Append" - active={activePanel === 'append'} - onClick={() => togglePanel('append')} - disabled={!videoPath || !canEdit} - />
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]" diff --git a/frontend/src/components/LicenseDialog.tsx b/frontend/src/components/LicenseDialog.tsx index 3c37116..5d8ba79 100644 --- a/frontend/src/components/LicenseDialog.tsx +++ b/frontend/src/components/LicenseDialog.tsx @@ -144,6 +144,7 @@ function LicenseActivateDialog({ diff --git a/frontend/src/components/MarkersPanel.tsx b/frontend/src/components/MarkersPanel.tsx index 4dfad58..ea5760a 100644 --- a/frontend/src/components/MarkersPanel.tsx +++ b/frontend/src/components/MarkersPanel.tsx @@ -2,6 +2,17 @@ import { useState } from 'react'; import { useEditorStore } from '../store/editorStore'; import { MapPin, Trash2, PencilLine, Check, X, Copy } from 'lucide-react'; +const COLOR_NAMES: Record = { + '#6366f1': 'Indigo', + '#ef4444': 'Red', + '#22c55e': 'Green', + '#f59e0b': 'Amber', + '#3b82f6': 'Blue', + '#ec4899': 'Pink', + '#8b5cf6': 'Purple', + '#14b8a6': 'Teal', +}; + const COLORS = ['#6366f1', '#ef4444', '#22c55e', '#f59e0b', '#3b82f6', '#ec4899', '#8b5cf6', '#14b8a6']; export default function MarkersPanel() { @@ -73,6 +84,7 @@ export default function MarkersPanel() { onClick={() => setNewColor(c)} className={`w-4 h-4 rounded-full border ${newColor === c ? 'border-white ring-1 ring-white' : 'border-transparent'}`} style={{ backgroundColor: c }} + title={COLOR_NAMES[c]} /> ))}
@@ -80,6 +92,7 @@ export default function MarkersPanel() { - + + )}
@@ -138,6 +151,7 @@ export default function MarkersPanel() { @@ -197,10 +243,12 @@ export default function SettingsPanel() { onKeyDown={(e) => handleKeyCapture(e, i)} className="w-28 px-2 py-1 text-[10px] font-mono bg-editor-bg border border-editor-border rounded text-center focus:outline-none focus:border-editor-accent" placeholder="Type shortcut" + data-tooltip="Click then press the desired key combination" /> @@ -220,6 +268,11 @@ export default function SettingsPanel() { + + ))} + + )} + + +

AI Settings

{/* Ollama settings */} @@ -242,16 +339,18 @@ export default function SettingsPanel() { value={providers.ollama.baseUrl || ''} onChange={(v) => setProviderConfig('ollama', { baseUrl: v })} placeholder="http://localhost:11434" + title="URL of your Ollama instance — http://localhost:11434 by default" />
@@ -260,6 +359,7 @@ export default function SettingsPanel() { value={providers.ollama.model} onChange={(e) => setProviderConfig('ollama', { model: e.target.value })} className="w-full px-3 py-2 bg-editor-surface border border-editor-border rounded-lg text-xs text-white focus:outline-none focus:border-editor-accent" + data-tooltip="Which Ollama model to use for AI features" > {ollamaModels.map((m) => ( @@ -271,6 +371,7 @@ export default function SettingsPanel() { value={providers.ollama.model} onChange={(v) => setProviderConfig('ollama', { model: v })} placeholder="llama3" + title="Which Ollama model to use for AI features" /> )}
@@ -284,12 +385,14 @@ export default function SettingsPanel() { onChange={(v) => setProviderConfig('openai', { apiKey: v })} placeholder="sk-..." type="password" + title="Your OpenAI API key — stored encrypted on your machine" /> setProviderConfig('openai', { model: v })} placeholder="gpt-4o" + title="OpenAI model to use (e.g. gpt-4o, gpt-4o-mini)" /> @@ -301,12 +404,14 @@ export default function SettingsPanel() { onChange={(v) => setProviderConfig('claude', { apiKey: v })} placeholder="sk-ant-..." type="password" + title="Your Anthropic Claude API key — stored encrypted on your machine" /> setProviderConfig('claude', { model: v })} placeholder="claude-sonnet-4-20250514" + title="Claude model to use (e.g. claude-sonnet-4-20250514)" /> @@ -339,12 +444,14 @@ function InputField({ onChange, placeholder, type = 'text', + title, }: { label: string; value: string; onChange: (value: string) => void; placeholder: string; type?: string; + title?: string; }) { return (
@@ -354,6 +461,7 @@ function InputField({ value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} + data-tooltip={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" />
diff --git a/frontend/src/components/SilenceTrimmerPanel.tsx b/frontend/src/components/SilenceTrimmerPanel.tsx index d97f8e1..e8a2090 100644 --- a/frontend/src/components/SilenceTrimmerPanel.tsx +++ b/frontend/src/components/SilenceTrimmerPanel.tsx @@ -134,6 +134,7 @@ export default function SilenceTrimmerPanel() { value={minSilenceMs} onChange={(e) => setMinSilenceMs(Number(e.target.value) || 500)} className="w-full px-2.5 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none" + data-tooltip="Minimum duration of silence to detect in milliseconds" /> @@ -149,6 +150,7 @@ export default function SilenceTrimmerPanel() { value={silenceDb} onChange={(e) => setSilenceDb(Number(e.target.value) || -35)} className="w-full px-2.5 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none" + data-tooltip="Volume threshold in dB — lower values detect quieter sounds as silence" /> @@ -165,6 +167,7 @@ export default function SilenceTrimmerPanel() { value={preBufferMs} onChange={(e) => setPreBufferMs(Number(e.target.value) || 0)} className="w-full px-2.5 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none" + data-tooltip="Extra time to add before each detected silence" />
@@ -179,6 +182,7 @@ export default function SilenceTrimmerPanel() { value={postBufferMs} onChange={(e) => setPostBufferMs(Number(e.target.value) || 0)} className="w-full px-2.5 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none" + data-tooltip="Extra time to add after each detected silence" />
@@ -187,6 +191,7 @@ export default function SilenceTrimmerPanel() { onClick={detectSilence} disabled={isDetecting || !videoPath} className="w-full flex items-center justify-center gap-2 px-4 py-2.5 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-50 rounded-lg text-sm font-medium transition-colors" + data-tooltip="Scan the entire audio track for silent pauses" > {isDetecting ? ( <> @@ -214,6 +219,7 @@ export default function SilenceTrimmerPanel() { diff --git a/frontend/src/components/TranscriptEditor.tsx b/frontend/src/components/TranscriptEditor.tsx index 68acd38..3d1ec32 100644 --- a/frontend/src/components/TranscriptEditor.tsx +++ b/frontend/src/components/TranscriptEditor.tsx @@ -511,7 +511,7 @@ export default function TranscriptEditor({ requestAnimationFrame(() => searchInputRef.current?.focus()); }} className="flex items-center gap-1 px-2 py-1 text-xs text-editor-text-muted hover:text-editor-text hover:bg-editor-surface rounded" - title="Find (Ctrl+F)" + data-tooltip="Find (Ctrl+F)" > Find @@ -534,21 +534,21 @@ export default function TranscriptEditor({ @@ -561,6 +561,7 @@ export default function TranscriptEditor({ onClick={cutSelectedWords} 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" + data-tooltip="Remove this word range from the output" > Cut @@ -569,6 +570,7 @@ export default function TranscriptEditor({ onClick={muteSelectedWords} 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" + data-tooltip="Silence audio for this word range" > Mute @@ -577,6 +579,7 @@ export default function TranscriptEditor({ onClick={gainSelectedWords} 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" + data-tooltip="Adjust volume for this word range — positive boosts, negative reduces" > Gain ({gainModeDb > 0 ? '+' : ''}{gainModeDb.toFixed(1)} dB) @@ -585,6 +588,7 @@ export default function TranscriptEditor({ onClick={speedSelectedWords} 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" + data-tooltip="Change playback speed for this word range — lower is slower, higher is faster" > Speed {speedModeValue.toFixed(2)}x @@ -593,7 +597,7 @@ export default function TranscriptEditor({ onClick={handleReTranscribe} disabled={isReTranscribing || !canEdit} className="flex items-center gap-1 px-2 py-1 text-xs bg-purple-500/20 text-purple-300 rounded hover:bg-purple-500/30 disabled:opacity-40 transition-colors" - title="Re-run Whisper transcription on this segment" + data-tooltip="Re-run Whisper transcription on this segment" > {isReTranscribing ? 'Re-transcribing...' : 'Re-transcribe'} diff --git a/frontend/src/components/WaveformTimeline.tsx b/frontend/src/components/WaveformTimeline.tsx index 5e82c24..1296317 100644 --- a/frontend/src/components/WaveformTimeline.tsx +++ b/frontend/src/components/WaveformTimeline.tsx @@ -1259,7 +1259,7 @@ export default function WaveformTimeline({ {markOutTime !== null && O {markOutTime.toFixed(2)}s}
-
@@ -193,6 +193,7 @@ export default function ZoneEditor() { ? 'bg-editor-accent text-white' : 'text-editor-text-muted hover:text-editor-text' }`} + data-tooltip="Show all zones" > All @@ -203,6 +204,7 @@ export default function ZoneEditor() { ? 'bg-red-500/30 text-red-500' : 'text-editor-text-muted hover:text-editor-text' }`} + data-tooltip="Show only Cut zones" > Cut @@ -213,6 +215,7 @@ export default function ZoneEditor() { ? 'bg-orange-500/30 text-orange-500' : 'text-editor-text-muted hover:text-editor-text' }`} + data-tooltip="Show only Mute zones" > Mute @@ -223,6 +226,7 @@ export default function ZoneEditor() { ? 'bg-amber-500/30 text-amber-500' : 'text-editor-text-muted hover:text-editor-text' }`} + data-tooltip="Show only Gain zones" > Gain @@ -233,6 +237,7 @@ export default function ZoneEditor() { ? 'bg-emerald-500/30 text-emerald-500' : 'text-editor-text-muted hover:text-editor-text' }`} + data-tooltip="Show only Speed zones" > Speed @@ -274,7 +279,7 @@ export default function ZoneEditor() { removeZone('cut', range.id); }} className="p-1 rounded hover:bg-red-500/20 text-red-500/70 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity" - title="Delete cut zone" + data-tooltip="Delete cut zone" > @@ -311,7 +316,7 @@ export default function ZoneEditor() { 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" - title="Delete mute zone" + data-tooltip="Delete mute zone" > @@ -350,6 +355,7 @@ export default function ZoneEditor() { value={globalGainDb} onChange={(e) => setGlobalGainDb(Math.max(-24, Math.min(24, Number(e.target.value) || 0)))} className="w-14 px-1.5 py-0.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none" + data-tooltip="Volume adjustment in decibels — +6 dB doubles volume, -6 dB halves it" /> dB @@ -379,7 +385,7 @@ export default function ZoneEditor() { onClick={(e) => e.stopPropagation()} onChange={(e) => updateGainRange(range.id, Number(e.target.value) || 0)} className="w-16 px-1.5 py-0.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none" - title="Gain dB" + data-tooltip="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')} @@ -429,7 +435,7 @@ export default function ZoneEditor() { onClick={(e) => e.stopPropagation()} onChange={(e) => updateSpeedRange(range.id, Number(e.target.value) || 1)} className="w-16 px-1.5 py-0.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none" - title="Speed multiplier" + data-tooltip="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')} diff --git a/frontend/src/index.css b/frontend/src/index.css index e8b1087..5c4d0fe 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -46,3 +46,46 @@ body { video::-webkit-media-controls { display: none !important; } + +[data-tooltip] { + position: relative; +} + +[data-tooltip]::after { + content: attr(data-tooltip); + position: absolute; + bottom: calc(100% + 6px); + left: 50%; + transform: translateX(-50%); + padding: 4px 8px; + border-radius: 4px; + background: #1f2133; + color: #e2e8f0; + font-size: 11px; + line-height: 1.3; + white-space: nowrap; + pointer-events: none; + opacity: 0; + transition: opacity 0.1s ease; + z-index: 100; + border: 1px solid #2a2d3a; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); + font-family: 'Inter', system-ui, sans-serif; +} + +[data-tooltip]:hover::after { + opacity: 1; +} + +/* Tooltip on the left side for elements near the right edge */ +[data-tooltip-side="left"]::after { + left: auto; + right: 0; + transform: none; +} + +/* Tooltip on the right side for elements near the left edge */ +[data-tooltip-side="right"]::after { + left: 0; + transform: none; +} diff --git a/frontend/src/lib/tauri-bridge.ts b/frontend/src/lib/tauri-bridge.ts index d7e35c4..6deb994 100644 --- a/frontend/src/lib/tauri-bridge.ts +++ b/frontend/src/lib/tauri-bridge.ts @@ -112,4 +112,12 @@ window.electronAPI = { hasLicenseFeature: (feature: string): Promise => { return invoke('has_license_feature', { feature }); }, + + listModels: (): Promise => { + return invoke('list_models'); + }, + + deleteModel: (path: string): Promise => { + return invoke('delete_model', { path }); + }, }; diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts index 1bc1776..7890d71 100644 --- a/frontend/src/vite-env.d.ts +++ b/frontend/src/vite-env.d.ts @@ -8,6 +8,13 @@ interface ImportMeta { readonly env: ImportMetaEnv; } +interface ModelInfo { + name: string; + path: string; + size_bytes: number; + kind: string; +} + interface DesktopAPI { openFile: (options?: Record) => Promise; saveFile: (options?: Record) => Promise; @@ -24,6 +31,8 @@ interface DesktopAPI { getAppStatus: () => Promise; deactivateLicense: () => Promise; hasLicenseFeature: (feature: string) => Promise; + listModels: () => Promise; + deleteModel: (path: string) => Promise; } interface Window { diff --git a/polish_plan.md b/polish_plan.md new file mode 100644 index 0000000..a6559ad --- /dev/null +++ b/polish_plan.md @@ -0,0 +1,328 @@ +# TalkEdit — UI Polish Plan + +## 1. Tooltips: show what it does + keyboard shortcut + +Every toolbar button and action button should have a `title` that explains the action and shows the keyboard shortcut if one exists. + +### Toolbar buttons (App.tsx) + +Current: `title={label}` → shows just the name. +New format: `title="Cut the selected or marked range [Ctrl+X]"` + +| Button | Current tooltip | New tooltip | +|--------|----------------|-------------| +| Cut | "Cut" | "Cut selected word range or mark in/out area [Ctrl+X]" | +| Mute | "Mute" | "Mute selected word range or mark in/out area [Ctrl+M]" | +| Gain Zone | "Gain Zone" | "Add gain zone from selection or mark in/out [Ctrl+G]" | +| Speed Zone | "Speed Zone" | "Add speed zone from selection or mark in/out [Ctrl+Shift+S]" | +| Zones | "Zones" | "Open zone editor panel [Ctrl+Shift+Z]" | +| Pause Trim | "Pause Trim" | "Detect and remove silent pauses [Ctrl+T]" | +| Markers | "Markers" | "Add and manage timeline markers [Ctrl+Shift+M]" | +| Music | "Music" | "Add background music track [Ctrl+Shift+B]" | +| Append | "Append" | "Append additional video clips [Ctrl+Shift+A]" | +| Reprocess | "Reprocess transcript with selected model" | "Re-transcribe entire video with selected model | +| AI | "AI" | "AI filler detection and clip suggestions [Ctrl+I]" | +| Export | "Export" | "Export video with current edits [Ctrl+E]" | +| Settings | "Settings" | "Configure AI providers, shortcuts, models [Ctrl+,]" | + +### File menu dropdown items + +| Item | Current | New | +|------|---------|-----| +| New Project | none | "Start a new empty project" | +| Open File | none | "Open a video or audio file for transcription" | +| Load Project | none | "Open a saved .aive project file" | +| Save | none | "Save current project [Ctrl+S]" | +| Save As | none | "Save a copy of the current project" | + +### Waveform timeline controls + +| Element | New tooltip | +|---------|-------------| +| Show adjusted timeline checkbox | "Compress cut regions to see the output timeline without gaps" | +| Cut zones toggle | "Show/hide cut ranges on the timeline" | +| Mute zones toggle | "Show/hide mute ranges on the timeline" | +| Gain zones toggle | "Show/hide gain ranges on the timeline" | +| Speed zones toggle | "Show/hide speed ranges on the timeline" | +| Zoom instruction text | "Scroll to pan · Ctrl+Scroll to zoom [Ctrl+= to reset zoom]" | +| Thumbnail toggle | "Show waveform thumbnail previews from the video" | + +### Transcript selection toolbar + +| Button | New tooltip | +|--------|-------------| +| Cut | "Remove this word range from the output" | +| Mute | "Silence audio for this word range" | +| Gain | "Adjust volume for this word range — positive boosts, negative reduces" | +| Speed | "Change playback speed for this word range — lower is slower, higher is faster" | +| Re-transcribe | "Re-run Whisper transcription on just this segment to improve accuracy" | + +### AIPanel buttons + +| Button | New tooltip | +|--------|-------------| +| Detect Filler Words | "Scan the entire transcript for filler words (um, uh, like, you know…) and mark for removal" | +| Apply All | "Create cut ranges for all detected filler words at once" | +| Dismiss | "Clear detected filler word results without applying" | +| Find Best Clips | "Analyze transcript to find the most engaging 20-60 second segments for social media" | +| Preview clip | "Seek to this clip's position and play a preview" | +| Export clip | "Export just this segment as a standalone video file" | + +### ExportDialog controls + +Every control needs a tooltip — this is the most complex panel with zero tooltips. + +| Control | Tooltip | +|---------|---------| +| Fast export card | "Stream copy — no re-encoding, fast but no effects or cuts applied" | +| Re-encode card | "Full re-encode — applies cuts, gain, speed, zoom, captions, and effects" | +| Resolution select | "Output video resolution — higher = larger file" | +| Format select | "Output container format — MP4 is most compatible" | +| Enable zoom checkbox | "Crop and reposition the video frame — useful for removing black bars or reframing" | +| Zoom slider | "Magnification level — 1.0x is original, higher values zoom in" | +| Pan X slider | "Horizontal position of the crop window — negative moves left, positive moves right" | +| Pan Y slider | "Vertical position of the crop window — negative moves up, positive moves down" | +| Background removal checkbox | "Remove or replace the background behind the speaker" | +| Background blur slider | "Amount of Gaussian blur applied to the background" | +| Loudness normalization checkbox | "Normalize audio to a consistent loudness target — recommended for YouTube" | +| LUFS target select | "Loudness target: YouTube (-14), Spotify (-16), Broadcast (-23)" | +| Audio enhancement checkbox | "Apply noise reduction and speech enhancement (DeepFilterNet)" | +| Captions select | "Burn captions into video, export as separate file (SRT/VTT), or none" | +| Export Transcript section | "Export just the transcript text or subtitles without the video" | + +### SettingsPanel controls + +| Control | Tooltip | +|---------|---------| +| Zone preview padding | "Extra context time shown before and after each zone when previewing" | +| Confidence threshold | "Words below this confidence get an orange underline — lower = show fewer warnings" | +| AI provider selector | "Choose which AI backend powers filler detection, chapters, and suggestions" | +| Ollama base URL | "URL of your Ollama instance — default is localhost:11434" | +| Ollama model | "Model name to use for AI features — requires Ollama running with this model pulled" | +| OpenAI API key | "Your OpenAI API key — stored encrypted on your machine" | +| Claude API key | "Your Anthropic Claude API key — stored encrypted on your machine" | +| Keyboard shortcut inputs | "Click then press the key combination you want to assign" | + +### Zone detail tooltips + +| Element | Tooltip | +|---------|---------| +| Zone preview button | "Preview this zone with {N}s of context before and after" | +| Gain dB input | "Volume adjustment in decibels — +6 dB doubles volume, -6 dB halves it" | +| Speed multiplier | "Playback speed multiplier — 1.0x is normal, 2.0x is twice as fast" | +| Delete zone button | "Remove this zone permanently" | + +--- + +## 2. Help menu / feature documentation + +### 2.1 Help button in toolbar + +Add a `?` help button to the right side of the toolbar (next to Settings): + +``` +[? Help] +``` + +Clicking it opens a **Help panel** (not a dialog — uses the existing sidebar panel system, or slides in as an overlay). + +### 2.2 Help panel sections + +#### Getting Started (for first-time users) + +``` +Welcome to TalkEdit + +1. Open a video file → click "Open Video File" or press Ctrl+O +2. Wait for transcription — Whisper processes your audio and creates a word-level transcript +3. Edit by selecting words → choose Cut, Mute, Gain, or Speed from the toolbar +4. Use AI tools → detect filler words, find clips, auto-chapter +5. Export → apply all edits and save your final video + +Pro tip: press ? anytime to see all keyboard shortcuts +``` + +#### Feature reference + +**Transcription** +- Select a Whisper model from the toolbar dropdown (larger = more accurate but slower) +- Click a word to select it, Shift+click to extend the selection +- Ctrl+click any word to seek the video to that timestamp +- Double-click any word to edit its text +- Right-click or use the selection toolbar to apply Cut/Mute/Gain/Speed +- Select a word range and click Re-transcribe to improve accuracy on that segment + +**Zones (Cut / Mute / Gain / Speed)** +- Zones are time-range edits applied during export +- Create zones by: selecting words in the transcript, using mark-in/mark-out on the timeline, or dragging on the waveform while in zone mode +- Cut = removes the segment from output entirely +- Mute = silences audio but keeps the video +- Gain = adjust volume (positive = louder, negative = quieter) +- Speed = change playback speed +- All zones can be resized and moved on the waveform timeline +- View and manage all zones in the Zone Editor panel + +**Waveform Timeline** +- The waveform shows your audio with all zone overlays +- Click to seek, drag to scrub +- Enter Cut/Mute/Gain/Speed mode from the toolbar, then drag on the waveform to create a zone +- Click an existing zone to select it — drag edges to resize, drag body to move +- Press Delete or Backspace to remove the selected zone +- Ctrl+Scroll to zoom in/out, Scroll to pan horizontally +- Toggle individual zone types on/off with the colored buttons +- "Show adjusted timeline" compresses cut regions to preview the output + +**AI Features** +- Filler word detection: finds "um", "uh", "like", "you know" and similar words. Add custom fillers in the AI panel. Apply All to create cut ranges for all detected fillers at once. +- Clip suggestions: analyzes your transcript to find the best 20-60 second segments for TikTok, YouTube Shorts, or Instagram Reels. +- AI features work locally with the bundled Qwen3 model (no internet needed) or via Ollama/OpenAI/Claude — configure in Settings. + +**Markers** +- Markers are named timestamps pinned to the waveform +- Add markers at the current playhead position with a label and color +- Markers auto-sort as chapters — copy as YouTube timestamps format +- Useful for chapter breaks, key moments, or section headings + +**Music & Append** +- Background Music: add a music track with auto-ducking (music lowers when someone speaks) +- Append Clips: load additional video files to concatenate during export +- Both are applied during re-encode export only + +**Export** +- Fast mode (stream copy): no quality loss, but doesn't apply cuts, effects, or music — only works if you haven't made any edits +- Re-encode mode: applies all edits, cuts, effects, captions, and music +- Captions: burn directly into video or export as separate SRT/VTT file +- Loudness normalization: match YouTube (-14 LUFS), Spotify (-16), or Broadcast (-23) standards +- Audio enhancement: noise reduction and speech clarity via DeepFilterNet +- Video zoom: crop and reposition the frame (useful for removing letterboxing or reframing) + +**Keyboard Shortcuts** +[Full table of all shortcuts — same as the ? cheatsheet but always visible in this section] + +**Settings** +- AI Providers: configure Ollama (local), OpenAI (cloud), or Claude (cloud). The bundled Qwen3 model works with zero setup. +- Model Management: view and delete downloaded Whisper and LLM models to free disk space +- Keyboard Shortcuts: remap any shortcut — click a binding then press your desired combination +- Confidence threshold: adjust the low-confidence word highlighting sensitivity +- Zone preview padding: how much context to show before/after zones during preview + +### 2.3 First-run onboarding + +When a user opens the app for the first time (no license activated, no project loaded): + +Show a **welcome overlay** with: +1. "Welcome to TalkEdit" heading +2. Brief description: "The offline video editor for long-form content" +3. Three quick-start steps with icons: + - Open a video → starts transcription + - Edit by deleting words → cuts out the matching video + - Export your final cut +4. "Got it" button that dismisses permanently (store in localStorage) +5. A "Show this again" checkbox in the Help panel + +--- + +## 3. Keyboard shortcut cheatsheet improvements + +Current: `?` key appends a `
` to `document.body` with a table of shortcuts. + +### Fixes: +- [ ] Render the cheatsheet as a React portal (inside a modal overlay) instead of manual DOM +- [ ] Add a close button (×) in the top-right corner +- [ ] Group shortcuts by category with visual headers (Transport, Editing, File, View) +- [ ] Show the current active preset name at the top +- [ ] Add the `?` tooltip "Show/hide keyboard shortcuts" to itself +- [ ] Show the cheatsheet from the Help panel too (not just `?` key) + +### Categories and grouping: + +| Transport | Edit | File | View | +|-----------|------|------|------| +| Space — Play/Pause | Delete — Cut selection | Ctrl+S — Save | ? — Toggle cheatsheet | +| ← → — Skip 5s | I — Mark in | Ctrl+O — Open | Ctrl+F — Find | +| J — Slow down | O — Mark out | Ctrl+E — Export | | +| K — Pause | Ctrl+Z — Undo | | | +| L — Speed up | Ctrl+Shift+Z — Redo | | | + +--- + +## 4. Missing states (empty/loading/error) + +### Empty states + +| Component | Current | Fix | +|-----------|---------|-----| +| MarkersPanel | Shows nothing when no markers | Add: "No markers yet. Press M or click Add Marker to create one." | +| AIPanel (clips) | Shows nothing before first detection | Add: "Click 'Find Best Clips' to discover the most shareable moments in your video." | +| AppendClipPanel | "No additional clips loaded" | Keep but add hint: "Add video files to concatenate during export." | +| WaveformTimeline (zones) | Canvas is empty | No change needed — zones are overlays, not content | + +### Error states + +| Component | Current | Fix | +|-----------|---------|-----| +| AIPanel | Errors logged to console only | Show error message in the panel with a retry button | +| ExportDialog | Shows export error in a red box | Keep, but add a "Copy error" button | +| VideoPlayer | No error for broken video | Add an error state with "Could not load video" + re-select button | +| WaveformTimeline | Shows error text in a `
` tag | Keep, but add a "Retry" button |
+| Silence detection | Errors use `alert()` | Show error inline in the panel |
+
+### Loading states
+
+| Component | Current | Fix |
+|-----------|---------|-----|
+| WaveformTimeline | Blank canvas while audio loads | Add a centered "Loading waveform…" spinner |
+| Export | Percentage text only | Add a determinate progress bar |
+| Transcription | Spinning waveform bars + text | Add a determinate progress bar for model download phase |
+| AI features | Spinner + "Processing…" | Add descriptive step text ("Analyzing transcript…") |
+
+---
+
+## 5. Consistency fixes
+
+### 5.1 Fix mute zone color in ZoneEditor
+`ZoneEditor.tsx` uses `border-orange-500/40` for mute zones — should be `border-blue-500/40` to match the waveform timeline's blue mute color.
+
+### 5.2 Unify disabled opacity
+- All disabled buttons: `opacity-40` (currently some use 50%)
+
+### 5.3 Unify border radius
+- All toolbar buttons: `rounded-md` (keep)
+- All sidebar panel inputs: `rounded-lg` (keep)
+- All zone/detection list items: `rounded-lg` (currently `rounded`)
+
+### 5.4 Remove orphaned VolumePanel
+`VolumePanel.tsx` is not imported anywhere. Either wire it into the sidebar or remove it.
+
+---
+
+## 6. Quick wins (implement first)
+
+- [ ] Add `title` tooltips to ALL toolbar buttons with shortcut hints
+- [ ] Add `title` tooltips to ALL ExportDialog controls
+- [ ] Fix mute zone color in ZoneEditor (orange → blue)
+- [ ] Add empty state to MarkersPanel
+- [ ] Add error display to AIPanel
+- [ ] Add close button to keyboard cheatsheet
+- [ ] Unify disabled opacity to 40% everywhere
+- [ ] Remove orphaned VolumePanel.tsx
+- [ ] Add loading spinner to WaveformTimeline
+
+## 7. Help system (implement second)
+
+- [ ] Create `HelpContent.tsx` with all feature documentation content
+- [ ] Add Help button to toolbar (`?` icon, opens sidebar)
+- [ ] Wire Help as a sidebar panel (like AI, Export, Settings)
+- [ ] Build first-run welcome overlay component
+- [ ] Add "Show help on startup" checkbox to Settings
+- [ ] Render keyboard cheatsheet as React portal with close button
+
+## 8. Polish (implement third)
+
+- [ ] Progress bar for export (determinate bar, not just text)
+- [ ] Progress bar for model downloads
+- [ ] Retry button on waveform load error
+- [ ] Confirmation dialog for zone/marker deletion
+- [ ] Keyboard-accessible split pane resizing
+- [ ] Larger hit targets for canvas zone handles (r=4 → r=6)
+- [ ] Search bar match indicator contrast improvement
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 0fd225c..3586492 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -11,6 +11,7 @@ mod ai_provider;
 mod caption_generator;
 mod background_removal;
 mod licensing;
+mod models;
 
 #[tauri::command]
 fn get_projects_directory() -> Result {
@@ -207,6 +208,22 @@ async fn save_captions(content: String, output_path: String) -> Result Result, 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 {
@@ -358,6 +375,8 @@ pub fn run() {
             deactivate_license,
             start_trial,
             has_license_feature,
+            list_models,
+            delete_model,
         ])
         .run(tauri::generate_context!())
         .expect("error while running tauri application");
diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs
new file mode 100644
index 0000000..6135b84
--- /dev/null
+++ b/src-tauri/src/models.rs
@@ -0,0 +1,141 @@
+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 {
+    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 {
+    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 {
+    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(())
+}