polishing

This commit is contained in:
2026-05-06 10:53:27 -06:00
parent 09ebcbc9ec
commit fd6697b48e
18 changed files with 889 additions and 145 deletions

View File

@ -150,12 +150,14 @@ export default function AIPanel() {
onClick={() => setActiveTab('filler')}
icon={<Scissors className="w-3.5 h-3.5" />}
label="Filler Words"
data-tooltip="Detect and remove filler words from transcript"
/>
<TabButton
active={activeTab === 'clips'}
onClick={() => setActiveTab('clips')}
icon={<Film className="w-3.5 h-3.5" />}
label="Create Clips"
data-tooltip="Find the best segments for social media clips"
/>
</div>
@ -181,6 +183,7 @@ export default function AIPanel() {
<button
onClick={detectFillers}
disabled={isProcessing || words.length === 0}
data-tooltip="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-50 rounded-lg text-sm font-medium transition-colors"
>
{isProcessing ? (
@ -205,12 +208,14 @@ export default function AIPanel() {
<div className="flex gap-1">
<button
onClick={applyFillerDeletions}
data-tooltip="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)}
data-tooltip="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
@ -248,6 +253,7 @@ export default function AIPanel() {
<button
onClick={createClips}
disabled={isProcessing || words.length === 0}
data-tooltip="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-50 rounded-lg text-sm font-medium transition-colors"
>
{isProcessing ? (
@ -277,6 +283,7 @@ export default function AIPanel() {
<div className="flex gap-2">
<button
onClick={() => handlePreviewClip(clip)}
data-tooltip="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
@ -284,6 +291,7 @@ export default function AIPanel() {
<button
onClick={() => handleExportClip(clip, i)}
disabled={exportingClipIndex === i}
data-tooltip="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-50 transition-colors"
>
{exportingClipIndex === i ? (
@ -310,15 +318,18 @@ function TabButton({
onClick,
icon,
label,
title,
}: {
active: boolean;
onClick: () => void;
icon: React.ReactNode;
label: string;
title?: string;
}) {
return (
<button
onClick={onClick}
title={title}
className={`flex-1 flex items-center justify-center gap-1.5 px-3 py-2.5 text-xs font-medium transition-colors border-b-2 ${
active
? 'border-editor-accent text-editor-accent'

View File

@ -45,7 +45,7 @@ export default function AppendClipPanel() {
onClick={() => reorderAdditionalClip(clip.id, -1)}
disabled={idx === 0}
className="p-0.5 rounded hover:bg-editor-bg disabled:opacity-30 text-editor-text-muted hover:text-editor-text"
title="Move up"
data-tooltip="Move up"
>
<ChevronUp className="w-3 h-3" />
</button>
@ -53,7 +53,7 @@ export default function AppendClipPanel() {
onClick={() => reorderAdditionalClip(clip.id, 1)}
disabled={idx === additionalClips.length - 1}
className="p-0.5 rounded hover:bg-editor-bg disabled:opacity-30 text-editor-text-muted hover:text-editor-text"
title="Move down"
data-tooltip="Move down"
>
<ChevronDown className="w-3 h-3" />
</button>
@ -61,7 +61,7 @@ export default function AppendClipPanel() {
<button
onClick={() => removeAdditionalClip(clip.id)}
className="p-0.5 rounded hover:bg-red-500/20 text-red-400"
title="Remove clip"
data-tooltip="Remove clip"
>
<Trash2 className="w-3 h-3" />
</button>
@ -74,6 +74,7 @@ export default function AppendClipPanel() {
onClick={handleAddClip}
disabled={!videoPath}
className="w-full flex items-center justify-center gap-2 px-3 py-2 rounded-lg border-2 border-dashed border-editor-border text-xs text-editor-text-muted hover:text-editor-text hover:border-editor-text-muted disabled:opacity-40 transition-colors"
data-tooltip="Select a video or audio file to append during export"
>
<Plus className="w-3.5 h-3.5" />
Add Clip

View File

@ -38,6 +38,7 @@ export default function BackgroundMusicPanel() {
<button
onClick={handleLoadMusic}
className="w-full flex items-center justify-center gap-2 px-4 py-3 rounded-lg border-2 border-dashed border-editor-border text-xs text-editor-text-muted hover:text-editor-text hover:border-editor-text-muted transition-colors"
data-tooltip="Select an audio file to use as background music"
>
<Disc3 className="w-4 h-4" />
Load Music File
@ -52,7 +53,7 @@ export default function BackgroundMusicPanel() {
<button
onClick={handleRemoveMusic}
className="p-1 rounded hover:bg-red-500/20 text-red-400 transition-colors"
title="Remove music"
data-tooltip="Remove music"
>
<Trash2 className="w-3 h-3" />
</button>
@ -70,6 +71,7 @@ export default function BackgroundMusicPanel() {
value={backgroundMusic.volumeDb}
onChange={(e) => updateBackgroundMusic({ volumeDb: Number(e.target.value) })}
className="flex-1 h-1.5"
data-tooltip="Background music volume relative to main audio — positive boosts, negative reduces"
/>
<span className="text-xs text-editor-text w-10 text-right">{backgroundMusic.volumeDb} dB</span>
</div>
@ -81,6 +83,7 @@ export default function BackgroundMusicPanel() {
checked={backgroundMusic.duckingEnabled}
onChange={(e) => updateBackgroundMusic({ duckingEnabled: e.target.checked })}
className="w-4 h-4 rounded bg-editor-surface border-editor-border accent-editor-accent"
data-tooltip="Automatically lower music volume when speech is detected"
/>
<div>
<span className="text-xs font-medium">Auto-ducking</span>
@ -102,6 +105,7 @@ export default function BackgroundMusicPanel() {
value={backgroundMusic.duckingDb}
onChange={(e) => updateBackgroundMusic({ duckingDb: Number(e.target.value) })}
className="flex-1 h-1.5"
data-tooltip="How much to reduce music volume during speech (1-20 dB)"
/>
<span className="text-xs text-editor-text w-10 text-right">{backgroundMusic.duckingDb} dB</span>
</div>
@ -115,6 +119,7 @@ export default function BackgroundMusicPanel() {
value={backgroundMusic.duckingAttackMs}
onChange={(e) => updateBackgroundMusic({ duckingAttackMs: Number(e.target.value) })}
className="flex-1 h-1.5"
data-tooltip="How quickly the ducking effect engages when speech starts"
/>
<span className="text-xs text-editor-text w-10 text-right">{backgroundMusic.duckingAttackMs}ms</span>
</div>
@ -128,6 +133,7 @@ export default function BackgroundMusicPanel() {
value={backgroundMusic.duckingReleaseMs}
onChange={(e) => updateBackgroundMusic({ duckingReleaseMs: Number(e.target.value) })}
className="flex-1 h-1.5"
data-tooltip="How quickly the ducking effect fades when speech ends"
/>
<span className="text-xs text-editor-text w-10 text-right">{backgroundMusic.duckingReleaseMs}ms</span>
</div>

View File

@ -228,6 +228,7 @@ export default function ExportDialog() {
icon={<Zap className="w-4 h-4" />}
title="Fast"
desc="Stream copy, seconds"
tooltip="Stream copy — fast, no quality loss, but does not apply cuts or effects"
/>
<ModeCard
active={options.mode === 'reencode'}
@ -235,6 +236,7 @@ export default function ExportDialog() {
icon={<Cog className="w-4 h-4" />}
title="Re-encode"
desc="Custom quality, slower"
tooltip="Full re-encode — applies cuts, gain, speed, zoom, captions, and effects"
/>
</div>
</fieldset>
@ -250,6 +252,7 @@ export default function ExportDialog() {
{ value: '1080p', label: '1080p (Full HD)' },
{ value: '4k', label: '4K (Ultra HD)' },
]}
data-tooltip="Output video resolution — higher resolution = larger file"
/>
)}
@ -264,6 +267,7 @@ export default function ExportDialog() {
{ value: 'webm', label: 'WebM (VP9)' },
...(isAudioOnly ? [{ value: 'wav' as const, label: 'WAV (Uncompressed)' }] : []),
]}
data-tooltip="Output container format — MP4 is most compatible"
/>
{/* Video zoom / punch-in */}
@ -274,6 +278,7 @@ export default function ExportDialog() {
checked={options.zoom?.enabled || false}
onChange={(e) => setOptions((o) => ({ ...o, zoom: { ...o.zoom!, enabled: e.target.checked } }))}
className="w-4 h-4 rounded bg-editor-surface border-editor-border accent-editor-accent"
data-tooltip="Crop and reposition the video frame — useful for removing black bars or reframing"
/>
<div>
<span className="text-xs font-medium flex items-center gap-1">
@ -297,6 +302,7 @@ export default function ExportDialog() {
value={options.zoom?.zoomFactor || 1}
onChange={(e) => setOptions((o) => ({ ...o, zoom: { ...o.zoom!, zoomFactor: Number(e.target.value) } }))}
className="flex-1 h-1.5"
data-tooltip="Magnification level — 1.0x is original, higher values zoom in"
/>
<span className="text-xs text-editor-text w-10 text-right">{options.zoom?.zoomFactor?.toFixed(2)}x</span>
</div>
@ -310,6 +316,7 @@ export default function ExportDialog() {
value={options.zoom?.panX || 0}
onChange={(e) => setOptions((o) => ({ ...o, zoom: { ...o.zoom!, panX: Number(e.target.value) } }))}
className="flex-1 h-1.5"
data-tooltip="Horizontal position of the crop window — negative moves left, positive moves right"
/>
<span className="text-xs text-editor-text w-10 text-right">{((options.zoom?.panX || 0) * 100).toFixed(0)}%</span>
</div>
@ -323,6 +330,7 @@ export default function ExportDialog() {
value={options.zoom?.panY || 0}
onChange={(e) => setOptions((o) => ({ ...o, zoom: { ...o.zoom!, panY: Number(e.target.value) } }))}
className="flex-1 h-1.5"
data-tooltip="Vertical position of the crop window — negative moves up, positive moves down"
/>
<span className="text-xs text-editor-text w-10 text-right">{((options.zoom?.panY || 0) * 100).toFixed(0)}%</span>
</div>
@ -339,6 +347,7 @@ export default function ExportDialog() {
checked={options.removeBackground || false}
onChange={(e) => setOptions((o) => ({ ...o, removeBackground: e.target.checked }))}
className="w-4 h-4 rounded bg-editor-surface border-editor-border accent-editor-accent"
data-tooltip="Remove or replace the background behind the speaker"
/>
<div>
<span className="text-xs font-medium flex items-center gap-1">
@ -407,6 +416,7 @@ export default function ExportDialog() {
checked={options.normalizeAudio}
onChange={(e) => setOptions((o) => ({ ...o, normalizeAudio: e.target.checked }))}
className="w-4 h-4 rounded bg-editor-surface border-editor-border accent-editor-accent"
data-tooltip="Normalize audio to a consistent loudness target"
/>
<div>
<span className="text-xs font-medium">Normalize loudness</span>
@ -422,6 +432,7 @@ export default function ExportDialog() {
value={options.normalizeTarget}
onChange={(e) => setOptions((o) => ({ ...o, normalizeTarget: Number(e.target.value) }))}
className="flex-1 px-2 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:outline-none focus:border-editor-accent [color-scheme:dark]"
data-tooltip="Loudness target — YouTube (-14), Spotify (-16), Broadcast (-23)"
>
<option value={-14}>YouTube (-14 LUFS)</option>
<option value={-16}>Spotify (-16 LUFS)</option>
@ -440,6 +451,7 @@ export default function ExportDialog() {
checked={options.enhanceAudio}
onChange={(e) => setOptions((o) => ({ ...o, enhanceAudio: e.target.checked }))}
className="w-4 h-4 rounded bg-editor-surface border-editor-border accent-editor-accent"
data-tooltip="Apply noise reduction and speech enhancement"
/>
<span className="text-xs">Enhance audio (Studio Sound)</span>
</label>
@ -454,6 +466,7 @@ export default function ExportDialog() {
{ value: 'burn-in', label: 'Burn-in (permanent)' },
{ value: 'sidecar', label: 'Sidecar SRT file' },
]}
data-tooltip="Burn captions into video, export as separate SRT/VTT file, or none"
/>
{/* Transcript-only export */}
@ -478,6 +491,7 @@ export default function ExportDialog() {
onClick={handleTranscriptExport}
disabled={isTranscribingTranscript || words.length === 0}
className="flex items-center gap-1.5 px-3 py-1.5 text-xs rounded bg-editor-accent/20 text-editor-accent hover:bg-editor-accent/30 disabled:opacity-40 transition-colors"
data-tooltip="Export just the transcript text or subtitles without the video"
>
{isTranscribingTranscript ? (
<Loader2 className="w-3 h-3 animate-spin" />
@ -494,6 +508,7 @@ export default function ExportDialog() {
onClick={handleExport}
disabled={isExporting || !videoPath}
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-50 rounded-lg text-sm font-semibold transition-colors"
data-tooltip="Start export with current settings"
>
{isExporting ? (
<>
@ -538,16 +553,19 @@ function ModeCard({
icon,
title,
desc,
tooltip,
}: {
active: boolean;
onClick: () => void;
icon: React.ReactNode;
title: string;
desc: string;
tooltip?: string;
}) {
return (
<button
onClick={onClick}
title={tooltip}
className={`flex flex-col items-center gap-1 p-3 rounded-lg border-2 transition-colors ${
active
? 'border-editor-accent bg-editor-accent/10'
@ -566,16 +584,19 @@ function SelectField({
value,
onChange,
options,
title,
}: {
label: string;
value: string;
onChange: (value: string) => void;
options: Array<{ value: string; label: string }>;
title?: string;
}) {
return (
<div className="space-y-1">
<label className="text-xs text-editor-text-muted font-medium">{label}</label>
<select
title={title}
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full px-3 py-2 bg-editor-surface border border-editor-border rounded-lg text-xs text-editor-text focus:outline-none focus:border-editor-accent [color-scheme:dark]"

View File

@ -144,6 +144,7 @@ function LicenseActivateDialog({
<button
onClick={onClose}
className="p-1 rounded hover:bg-editor-surface text-editor-text-muted"
data-tooltip="Close dialog"
>
<X className="w-4 h-4" />
</button>

View File

@ -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<string, string> = {
'#6366f1': 'Indigo',
'#ef4444': 'Red',
'#22c55e': 'Green',
'#f59e0b': 'Amber',
'#3b82f6': 'Blue',
'#ec4899': 'Pink',
'#8b5cf6': 'Purple',
'#14b8a6': 'Teal',
};
const COLORS = ['#6366f1', '#ef4444', '#22c55e', '#f59e0b', '#3b82f6', '#ec4899', '#8b5cf6', '#14b8a6'];
export default function MarkersPanel() {
@ -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]}
/>
))}
</div>
@ -80,6 +92,7 @@ export default function MarkersPanel() {
<button
onClick={addAtCurrentTime}
className="w-full flex items-center justify-center gap-1 px-2 py-1.5 text-xs bg-editor-accent/20 text-editor-accent hover:bg-editor-accent/30 rounded"
data-tooltip="Add a marker at the current playhead position"
>
<MapPin className="w-3 h-3" />
Add
@ -110,8 +123,8 @@ export default function MarkersPanel() {
) : (
<>
<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={() => removeTimelineMarker(m.id)} className="p-0.5 hover:text-editor-danger"><Trash2 className="w-3 h-3" /></button>
<button onClick={() => startEdit(m.id, m.label)} className="p-0.5 hover:text-editor-accent" data-tooltip="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" data-tooltip="Delete this marker"><Trash2 className="w-3 h-3" /></button>
</>
)}
</div>
@ -138,6 +151,7 @@ export default function MarkersPanel() {
<button
onClick={exportChapters}
className="flex items-center gap-1 text-[10px] text-editor-accent hover:underline"
data-tooltip="Copy chapter timestamps to clipboard in YouTube format"
>
<Copy className="w-2.5 h-2.5" />
Copy as YouTube timestamps

View File

@ -2,7 +2,7 @@ import { useAIStore } from '../store/aiStore';
import { useState, useEffect, useCallback } from 'react';
import type { AIProvider, KeyBinding, HotkeyPreset } from '../types/project';
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';
export default function SettingsPanel() {
@ -65,11 +65,51 @@ export default function SettingsPanel() {
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 [deleting, setDeleting] = useState<string | null>(null);
const fetchModels = useCallback(async () => {
setLoadingModels(true);
try {
const list = await window.electronAPI.listModels();
setModels(list);
} catch {
setModels([]);
} finally {
setLoadingModels(false);
}
}, []);
useEffect(() => {
fetchModels();
}, [fetchModels]);
const handleDeleteModel = useCallback(async (model: ModelInfo) => {
if (deleting) return;
setDeleting(model.path);
try {
await window.electronAPI.deleteModel(model.path);
setModels((prev) => prev.filter((m) => m.path !== model.path));
} catch {
// Model deletion failed silently
} finally {
setDeleting(null);
}
}, [deleting]);
const formatBytes = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
};
const [ollamaModels, setOllamaModels] = useState<string[]>([]);
const [loadingOllamaModels, setLoadingOllamaModels] = useState(false);
const fetchOllamaModels = useCallback(async () => {
setLoadingModels(true);
setLoadingOllamaModels(true);
try {
const res = await fetch(`${backendUrl}/ai/ollama-models`);
if (res.ok) {
@ -79,7 +119,7 @@ export default function SettingsPanel() {
} catch {
setOllamaModels([]);
} finally {
setLoadingModels(false);
setLoadingOllamaModels(false);
}
}, [backendUrl]);
@ -109,6 +149,7 @@ export default function SettingsPanel() {
value={zonePreviewPaddingSeconds}
onChange={(e) => setZonePreviewPaddingSeconds(Number(e.target.value) || 0)}
className="flex-1 h-1.5"
data-tooltip="Extra time in seconds to show before and after each zone during preview"
/>
<input
type="number"
@ -118,6 +159,7 @@ export default function SettingsPanel() {
value={zonePreviewPaddingSeconds}
onChange={(e) => setZonePreviewPaddingSeconds(Number(e.target.value) || 0)}
className="w-16 px-2 py-1 bg-editor-bg border border-editor-border rounded-lg text-xs text-editor-text focus:outline-none focus:border-editor-accent"
data-tooltip="Extra time in seconds to show before and after each zone during preview"
/>
<span className="text-xs text-editor-text-muted w-6">s</span>
</div>
@ -140,6 +182,7 @@ export default function SettingsPanel() {
value={confidenceThreshold}
onChange={(e) => setConfidenceThreshold(Number(e.target.value))}
className="flex-1 h-1.5"
data-tooltip="Words below this confidence get an orange underline — lower values show fewer warnings"
/>
<input
type="number"
@ -149,6 +192,7 @@ export default function SettingsPanel() {
value={confidenceThreshold}
onChange={(e) => setConfidenceThreshold(Math.max(0, Math.min(1, Number(e.target.value) || 0)))}
className="w-16 px-2 py-1 bg-editor-bg border border-editor-border rounded-lg text-xs text-editor-text focus:outline-none focus:border-editor-accent"
data-tooltip="Words below this confidence get an orange underline — lower values show fewer warnings"
/>
</div>
<div className="flex items-center justify-between text-[10px]">
@ -168,12 +212,14 @@ export default function SettingsPanel() {
<button
onClick={() => applyPresetAction('standard')}
className="flex-1 px-2 py-1.5 text-xs rounded bg-editor-accent/20 text-editor-accent hover:bg-editor-accent/30"
data-tooltip="Reset all shortcuts to the Standard preset"
>
Standard Preset
</button>
<button
onClick={() => applyPresetAction('left-hand')}
className="flex-1 px-2 py-1.5 text-xs rounded bg-editor-accent/20 text-editor-accent hover:bg-editor-accent/30"
data-tooltip="Reset all shortcuts to the Left-Hand preset"
>
Left-Hand Preset
</button>
@ -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"
/>
<button
onClick={() => handleReset(i)}
className="text-[10px] text-editor-text-muted hover:text-editor-text px-1"
data-tooltip="Reset this shortcut to default"
>
</button>
@ -220,6 +268,11 @@ export default function SettingsPanel() {
<button
key={p}
onClick={() => setDefaultProvider(p)}
title={`Use ${p.charAt(0).toUpperCase() + p.slice(1)} for AI features — ${
p === 'ollama' ? 'Use a local Ollama instance' :
p === 'openai' ? "Use OpenAI's API (requires API key)" :
"Use Anthropic's Claude API (requires API key)"
}`}
className={`flex flex-col items-center gap-1 p-2 rounded-lg border transition-colors text-[10px] ${
defaultProvider === p
? 'border-editor-accent bg-editor-accent/10 text-editor-accent'
@ -233,6 +286,50 @@ export default function SettingsPanel() {
</div>
</div>
{/* Manage downloaded models */}
<div className="space-y-2 pt-1 border-t border-editor-border">
<h4 className="text-xs font-semibold flex items-center gap-1.5">
<HardDrive className="w-3.5 h-3.5" />
Manage Models
</h4>
<p className="text-[10px] text-editor-text-muted leading-relaxed">
Downloaded Whisper transcription models and bundled LLM files.
</p>
{models.length === 0 ? (
<p className="text-xs text-editor-text-muted">No downloaded models found.</p>
) : (
<div className="space-y-1.5">
{models.map((m) => (
<div key={m.path} className="flex items-center gap-2 p-2 rounded bg-editor-bg border border-editor-border">
<div className="flex-1 min-w-0">
<p className="text-xs text-editor-text truncate">{m.name}</p>
<p className="text-[10px] text-editor-text-muted">
{formatBytes(m.size_bytes)} &middot; {m.kind === 'whisper' ? 'Whisper' : 'LLM'}
</p>
</div>
<button
onClick={() => handleDeleteModel(m)}
disabled={deleting === m.path}
className="p-1.5 rounded text-editor-text-muted hover:text-red-400 hover:bg-red-500/10 transition-colors disabled:opacity-40"
data-tooltip="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"
data-tooltip="Refresh list of downloaded models"
>
<RefreshCw className={`w-2.5 h-2.5 ${loadingModels ? 'animate-spin' : ''}`} />
Refresh
</button>
</div>
<h4 className="text-xs font-semibold uppercase tracking-wide text-editor-text-muted">AI Settings</h4>
{/* Ollama settings */}
@ -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"
/>
<div className="space-y-1">
<div className="flex items-center justify-between">
<label className="text-xs text-editor-text-muted">Model</label>
<button
onClick={fetchOllamaModels}
disabled={loadingModels}
disabled={loadingOllamaModels}
className="text-[10px] text-editor-accent hover:underline flex items-center gap-0.5"
data-tooltip="Refresh available Ollama models"
>
<RefreshCw className={`w-2.5 h-2.5 ${loadingModels ? 'animate-spin' : ''}`} />
<RefreshCw className={`w-2.5 h-2.5 ${loadingOllamaModels ? 'animate-spin' : ''}`} />
Refresh
</button>
</div>
@ -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) => (
<option key={m} value={m}>{m}</option>
@ -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"
/>
)}
</div>
@ -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"
/>
<InputField
label="Model"
value={providers.openai.model}
onChange={(v) => setProviderConfig('openai', { model: v })}
placeholder="gpt-4o"
title="OpenAI model to use (e.g. gpt-4o, gpt-4o-mini)"
/>
</ProviderSection>
@ -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"
/>
<InputField
label="Model"
value={providers.claude.model}
onChange={(v) => setProviderConfig('claude', { model: v })}
placeholder="claude-sonnet-4-20250514"
title="Claude model to use (e.g. claude-sonnet-4-20250514)"
/>
</ProviderSection>
</div>
@ -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 (
<div className="space-y-1">
@ -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"
/>
</div>

View File

@ -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"
/>
</div>
@ -149,6 +150,7 @@ export default function SilenceTrimmerPanel() {
value={silenceDb}
onChange={(e) => setSilenceDb(Number(e.target.value) || -35)}
className="w-full px-2.5 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
data-tooltip="Volume threshold in dB — lower values detect quieter sounds as silence"
/>
</div>
@ -165,6 +167,7 @@ export default function SilenceTrimmerPanel() {
value={preBufferMs}
onChange={(e) => setPreBufferMs(Number(e.target.value) || 0)}
className="w-full px-2.5 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
data-tooltip="Extra time to add before each detected silence"
/>
</div>
<div className="space-y-1.5">
@ -179,6 +182,7 @@ export default function SilenceTrimmerPanel() {
value={postBufferMs}
onChange={(e) => setPostBufferMs(Number(e.target.value) || 0)}
className="w-full px-2.5 py-1.5 text-xs bg-editor-surface border border-editor-border rounded focus:border-editor-accent focus:outline-none"
data-tooltip="Extra time to add after each detected silence"
/>
</div>
</div>
@ -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() {
<button
onClick={reapplySelectedGroup}
className="flex items-center gap-1 px-2 py-1 text-xs bg-editor-warning/20 text-editor-warning rounded hover:bg-editor-warning/30"
data-tooltip="Re-apply this silence trim group with current settings"
>
<RotateCcw className="w-3 h-3" />
Reapply Group
@ -222,6 +228,7 @@ export default function SilenceTrimmerPanel() {
<button
onClick={applyAsNewGroup}
className="flex items-center gap-1 px-2 py-1 text-xs bg-editor-accent/20 text-editor-accent rounded hover:bg-editor-accent/30"
data-tooltip="Create a new silence trim group from detected pauses"
>
<Scissors className="w-3 h-3" />
Apply As New Group
@ -264,14 +271,14 @@ export default function SilenceTrimmerPanel() {
<button
onClick={() => loadGroupForEditing(group.id)}
className="px-1.5 py-1 rounded hover:bg-editor-accent/20 text-editor-accent"
title="Edit and reapply this group"
data-tooltip="Edit and reapply this group"
>
<PencilLine className="w-3 h-3" />
</button>
<button
onClick={() => removeGroup(group.id)}
className="px-1.5 py-1 rounded hover:bg-editor-danger/20 text-editor-danger"
title="Delete all cuts from this group"
data-tooltip="Delete all cuts from this group"
>
<Trash2 className="w-3 h-3" />
</button>

View File

@ -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)"
>
<Search className="w-3 h-3" />
Find
@ -534,21 +534,21 @@ export default function TranscriptEditor({
<button
onClick={() => jumpToMatch(safeActiveMatchIdx - 1)}
className="p-0.5 rounded hover:bg-editor-bg text-editor-text-muted hover:text-editor-text"
title="Previous match (Shift+Enter)"
data-tooltip="Previous match (Shift+Enter)"
>
<ChevronUp className="w-3 h-3" />
</button>
<button
onClick={() => jumpToMatch(safeActiveMatchIdx + 1)}
className="p-0.5 rounded hover:bg-editor-bg text-editor-text-muted hover:text-editor-text"
title="Next match (Enter)"
data-tooltip="Next match (Enter)"
>
<ChevronDown className="w-3 h-3" />
</button>
<button
onClick={() => setSearchOpen(false)}
className="p-0.5 rounded hover:bg-editor-bg text-editor-text-muted hover:text-editor-text"
title="Close search (Esc)"
data-tooltip="Close search (Esc)"
>
<X className="w-3 h-3" />
</button>
@ -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"
>
<Scissors className="w-3 h-3" />
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"
>
<VolumeX className="w-3 h-3" />
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"
>
<SlidersHorizontal className="w-3 h-3" />
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"
>
<Gauge className="w-3 h-3" />
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"
>
<RefreshCw className={`w-3 h-3 ${isReTranscribing ? 'animate-spin' : ''}`} />
{isReTranscribing ? 'Re-transcribing...' : 'Re-transcribe'}

View File

@ -1259,7 +1259,7 @@ export default function WaveformTimeline({
{markOutTime !== null && <span className="text-[10px] text-yellow-300">O {markOutTime.toFixed(2)}s</span>}
</div>
<div className="flex items-center gap-2">
<label className="flex items-center gap-1 text-[10px] text-editor-text-muted select-none">
<label className="flex items-center gap-1 text-[10px] text-editor-text-muted select-none" data-tooltip="Compress cut regions to preview the output timeline without gaps">
<input
type="checkbox"
checked={showAdjustedTimeline}
@ -1273,28 +1273,28 @@ export default function WaveformTimeline({
<button
onClick={() => setShowCutZones((v) => !v)}
className={`px-1.5 py-0.5 rounded text-[10px] border ${showCutZones ? 'border-red-500/60 text-red-300 bg-red-500/10' : 'border-editor-border text-editor-text-muted'}`}
title="Toggle cut zones"
data-tooltip="Toggle cut zones on the timeline — red overlays show removed segments"
>
Cut
</button>
<button
onClick={() => setShowMuteZones((v) => !v)}
className={`px-1.5 py-0.5 rounded text-[10px] border ${showMuteZones ? 'border-blue-500/60 text-blue-300 bg-blue-500/10' : 'border-editor-border text-editor-text-muted'}`}
title="Toggle mute zones"
data-tooltip="Toggle mute zones on the timeline — blue overlays show silenced segments"
>
Mute
</button>
<button
onClick={() => setShowGainZones((v) => !v)}
className={`px-1.5 py-0.5 rounded text-[10px] border ${showGainZones ? 'border-amber-500/60 text-amber-300 bg-amber-500/10' : 'border-editor-border text-editor-text-muted'}`}
title="Toggle gain zones"
data-tooltip="Toggle gain zones on the timeline — amber overlays show volume adjustments"
>
Gain
</button>
<button
onClick={() => setShowSpeedZones((v) => !v)}
className={`px-1.5 py-0.5 rounded text-[10px] border ${showSpeedZones ? 'border-emerald-500/60 text-emerald-300 bg-emerald-500/10' : 'border-editor-border text-editor-text-muted'}`}
title="Toggle speed zones"
data-tooltip="Toggle speed zones on the timeline — emerald overlays show speed changes"
>
Speed
</button>
@ -1309,7 +1309,7 @@ export default function WaveformTimeline({
<AlertTriangle className="w-4 h-4 text-yellow-500 mt-0.5 shrink-0" />
<pre
className="select-text cursor-text whitespace-pre-wrap break-all leading-relaxed"
title="Highlight this text to copy"
data-tooltip="Highlight this text to copy"
>
{audioError}
</pre>

View File

@ -176,7 +176,7 @@ export default function ZoneEditor() {
value={zonePreviewPaddingSeconds}
onChange={(e) => setZonePreviewPaddingSeconds(Number(e.target.value) || 0)}
className="w-16 px-2 py-1 bg-editor-bg border border-editor-border rounded text-xs text-editor-text focus:outline-none focus:border-editor-accent"
title="Preview time before and after each zone"
data-tooltip="Preview time before and after each zone"
/>
<span className="text-xs text-editor-text-muted">sec</span>
</div>
@ -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
</button>
@ -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
</button>
@ -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
</button>
@ -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
</button>
@ -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
</button>
@ -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"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
@ -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"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
@ -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"
/>
<span className="text-xs text-amber-500/80 font-medium w-6 text-right">dB</span>
</div>
@ -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')}
<button
@ -388,7 +394,7 @@ export default function ZoneEditor() {
removeZone('gain', range.id);
}}
className="p-1 rounded hover:bg-amber-500/20 text-amber-500/70 hover:text-amber-500 opacity-0 group-hover:opacity-100 transition-opacity"
title="Delete gain zone"
data-tooltip="Delete gain zone"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
@ -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')}
<button
@ -438,7 +444,7 @@ export default function ZoneEditor() {
removeZone('speed', range.id);
}}
className="p-1 rounded hover:bg-emerald-500/20 text-emerald-500/70 hover:text-emerald-500 opacity-0 group-hover:opacity-100 transition-opacity"
title="Delete speed zone"
data-tooltip="Delete speed zone"
>
<Trash2 className="w-3.5 h-3.5" />
</button>