polishing
This commit is contained in:
@ -617,12 +617,12 @@ export default function App() {
|
|||||||
<>
|
<>
|
||||||
<div className="fixed inset-0 z-40" onClick={() => setShowFileMenu(false)} />
|
<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">
|
<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" onClick={() => { setShowFileMenu(false); handleNewProject(); }} />
|
<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" onClick={() => { setShowFileMenu(false); handleOpenFile(); }} />
|
<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" onClick={() => { setShowFileMenu(false); handleLoadProject(); }} />
|
<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" />
|
<div className="border-t border-editor-border my-1" />
|
||||||
<DropdownItem icon={<Save className="w-4 h-4" />} label="Save" onClick={() => { setShowFileMenu(false); handleSaveProject(); }} disabled={words.length === 0} />
|
<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" onClick={() => { setShowFileMenu(false); handleSaveProjectAs(); }} 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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
@ -633,6 +633,7 @@ export default function App() {
|
|||||||
onClick={handleCut}
|
onClick={handleCut}
|
||||||
active={cutMode}
|
active={cutMode}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
|
title="Cut selected word range or mark in/out area — removes the segment from output"
|
||||||
/>
|
/>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
icon={<VolumeX className="w-4 h-4" />}
|
icon={<VolumeX className="w-4 h-4" />}
|
||||||
@ -640,6 +641,7 @@ export default function App() {
|
|||||||
onClick={handleMute}
|
onClick={handleMute}
|
||||||
active={muteMode}
|
active={muteMode}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
|
title="Mute selected word range or mark in/out area — silences audio, keeps video"
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
@ -648,6 +650,7 @@ export default function App() {
|
|||||||
onClick={handleGain}
|
onClick={handleGain}
|
||||||
active={gainMode}
|
active={gainMode}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
|
title="Add gain zone from selection or mark in/out — adjust volume up or down"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@ -657,7 +660,7 @@ export default function App() {
|
|||||||
value={gainModeDb}
|
value={gainModeDb}
|
||||||
onChange={(e) => setGainModeDb(Math.max(-24, Math.min(24, Number(e.target.value) || 0)))}
|
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"
|
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"
|
data-tooltip="Volume adjustment in decibels for new gain zones — positive boosts, negative reduces"
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -668,6 +671,7 @@ export default function App() {
|
|||||||
onClick={handleSpeed}
|
onClick={handleSpeed}
|
||||||
active={speedMode}
|
active={speedMode}
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
|
title="Add speed zone from selection or mark in/out — change playback speed"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
@ -677,7 +681,7 @@ export default function App() {
|
|||||||
value={speedModeValue}
|
value={speedModeValue}
|
||||||
onChange={(e) => setSpeedModeValue(Math.max(0.25, Math.min(4, Number(e.target.value) || 1)))}
|
onChange={(e) => setSpeedModeValue(Math.max(0.25, Math.min(4, Number(e.target.value) || 1)))}
|
||||||
className="w-16 px-1.5 py-1 text-xs bg-editor-surface border border-editor-border rounded text-editor-text focus:outline-none focus:border-editor-accent"
|
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"
|
data-tooltip="Playback speed multiplier for new speed zones — 1x is normal, 2x is double speed"
|
||||||
disabled={!canEdit}
|
disabled={!canEdit}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@ -687,6 +691,7 @@ export default function App() {
|
|||||||
active={activePanel === 'zones'}
|
active={activePanel === 'zones'}
|
||||||
onClick={() => togglePanel('zones')}
|
onClick={() => togglePanel('zones')}
|
||||||
disabled={!videoPath || !canEdit}
|
disabled={!videoPath || !canEdit}
|
||||||
|
title="Open zone editor panel — view and manage all cut, mute, gain, and speed zones"
|
||||||
/>
|
/>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
icon={<span className="text-[10px] font-semibold">PA</span>}
|
icon={<span className="text-[10px] font-semibold">PA</span>}
|
||||||
@ -694,6 +699,7 @@ export default function App() {
|
|||||||
active={activePanel === 'silence'}
|
active={activePanel === 'silence'}
|
||||||
onClick={() => togglePanel('silence')}
|
onClick={() => togglePanel('silence')}
|
||||||
disabled={!videoPath || !canEdit}
|
disabled={!videoPath || !canEdit}
|
||||||
|
title="Detect and remove silent pauses — batch-removes silence above a configurable threshold"
|
||||||
/>
|
/>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
icon={<MapPin className="w-4 h-4" />}
|
icon={<MapPin className="w-4 h-4" />}
|
||||||
@ -701,6 +707,7 @@ export default function App() {
|
|||||||
active={activePanel === 'markers'}
|
active={activePanel === 'markers'}
|
||||||
onClick={() => togglePanel('markers')}
|
onClick={() => togglePanel('markers')}
|
||||||
disabled={!videoPath || !canEdit}
|
disabled={!videoPath || !canEdit}
|
||||||
|
title="Add and manage timeline markers — chapter points, key moments, YouTube timestamps"
|
||||||
/>
|
/>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
icon={<Music className="w-4 h-4" />}
|
icon={<Music className="w-4 h-4" />}
|
||||||
@ -708,6 +715,7 @@ export default function App() {
|
|||||||
active={activePanel === 'music'}
|
active={activePanel === 'music'}
|
||||||
onClick={() => togglePanel('music')}
|
onClick={() => togglePanel('music')}
|
||||||
disabled={!videoPath || !canEdit}
|
disabled={!videoPath || !canEdit}
|
||||||
|
title="Add background music track with auto-ducking — music lowers when someone speaks"
|
||||||
/>
|
/>
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
icon={<ListVideo className="w-4 h-4" />}
|
icon={<ListVideo className="w-4 h-4" />}
|
||||||
@ -715,6 +723,7 @@ export default function App() {
|
|||||||
active={activePanel === 'append'}
|
active={activePanel === 'append'}
|
||||||
onClick={() => togglePanel('append')}
|
onClick={() => togglePanel('append')}
|
||||||
disabled={!videoPath || !canEdit}
|
disabled={!videoPath || !canEdit}
|
||||||
|
title="Append additional video clips — concatenate multiple files during export"
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-editor-surface border border-editor-border">
|
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-editor-surface border border-editor-border">
|
||||||
<select
|
<select
|
||||||
@ -745,7 +754,7 @@ export default function App() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleReprocessProject}
|
onClick={handleReprocessProject}
|
||||||
disabled={isTranscribing || !videoPath || !canEdit}
|
disabled={isTranscribing || !videoPath || !canEdit}
|
||||||
title="Reprocess transcript with selected model"
|
data-tooltip="Re-run transcription with the selected Whisper model — replaces current transcript"
|
||||||
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"
|
className="flex items-center gap-1 px-2 py-1 rounded text-xs text-editor-text hover:bg-editor-bg disabled:opacity-40 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<RefreshCw className={`w-3 h-3 ${isTranscribing ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`w-3 h-3 ${isTranscribing ? 'animate-spin' : ''}`} />
|
||||||
@ -758,6 +767,7 @@ export default function App() {
|
|||||||
active={activePanel === 'ai'}
|
active={activePanel === 'ai'}
|
||||||
onClick={() => togglePanel('ai')}
|
onClick={() => togglePanel('ai')}
|
||||||
disabled={words.length === 0 || !canEdit}
|
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" />}
|
||||||
@ -964,18 +974,20 @@ 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 (
|
||||||
|
<span data-tooltip={title || label}>
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
title={label}
|
|
||||||
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'
|
||||||
@ -985,6 +997,7 @@ function ToolbarButton({
|
|||||||
{icon}
|
{icon}
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -993,13 +1006,16 @@ function DropdownItem({
|
|||||||
label,
|
label,
|
||||||
onClick,
|
onClick,
|
||||||
disabled,
|
disabled,
|
||||||
|
title,
|
||||||
}: {
|
}: {
|
||||||
icon: React.ReactNode;
|
icon: React.ReactNode;
|
||||||
label: string;
|
label: string;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
|
title?: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
|
<span data-tooltip={title || label}>
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
@ -1012,5 +1028,6 @@ function DropdownItem({
|
|||||||
{icon}
|
{icon}
|
||||||
{label}
|
{label}
|
||||||
</button>
|
</button>
|
||||||
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -150,12 +150,14 @@ 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"
|
||||||
|
data-tooltip="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"
|
||||||
|
data-tooltip="Find the best segments for social media clips"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -181,6 +183,7 @@ export default function AIPanel() {
|
|||||||
<button
|
<button
|
||||||
onClick={detectFillers}
|
onClick={detectFillers}
|
||||||
disabled={isProcessing || words.length === 0}
|
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"
|
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 ? (
|
{isProcessing ? (
|
||||||
@ -205,12 +208,14 @@ export default function AIPanel() {
|
|||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<button
|
<button
|
||||||
onClick={applyFillerDeletions}
|
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"
|
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
|
<Check className="w-3 h-3" /> Apply All
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setFillerResult(null)}
|
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"
|
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
|
<X className="w-3 h-3" /> Dismiss
|
||||||
@ -248,6 +253,7 @@ export default function AIPanel() {
|
|||||||
<button
|
<button
|
||||||
onClick={createClips}
|
onClick={createClips}
|
||||||
disabled={isProcessing || words.length === 0}
|
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"
|
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 ? (
|
{isProcessing ? (
|
||||||
@ -277,6 +283,7 @@ export default function AIPanel() {
|
|||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => handlePreviewClip(clip)}
|
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"
|
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
|
<Play className="w-3 h-3" /> Preview
|
||||||
@ -284,6 +291,7 @@ export default function AIPanel() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => handleExportClip(clip, i)}
|
onClick={() => handleExportClip(clip, i)}
|
||||||
disabled={exportingClipIndex === 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"
|
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 ? (
|
{exportingClipIndex === i ? (
|
||||||
@ -310,15 +318,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'
|
||||||
|
|||||||
@ -45,7 +45,7 @@ export default function AppendClipPanel() {
|
|||||||
onClick={() => reorderAdditionalClip(clip.id, -1)}
|
onClick={() => reorderAdditionalClip(clip.id, -1)}
|
||||||
disabled={idx === 0}
|
disabled={idx === 0}
|
||||||
className="p-0.5 rounded hover:bg-editor-bg disabled:opacity-30 text-editor-text-muted hover:text-editor-text"
|
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" />
|
<ChevronUp className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
@ -53,7 +53,7 @@ export default function AppendClipPanel() {
|
|||||||
onClick={() => reorderAdditionalClip(clip.id, 1)}
|
onClick={() => reorderAdditionalClip(clip.id, 1)}
|
||||||
disabled={idx === additionalClips.length - 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"
|
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" />
|
<ChevronDown className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
@ -61,7 +61,7 @@ export default function AppendClipPanel() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => removeAdditionalClip(clip.id)}
|
onClick={() => removeAdditionalClip(clip.id)}
|
||||||
className="p-0.5 rounded hover:bg-red-500/20 text-red-400"
|
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" />
|
<Trash2 className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
@ -74,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"
|
||||||
|
data-tooltip="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
|
||||||
|
|||||||
@ -38,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"
|
||||||
|
data-tooltip="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
|
||||||
@ -52,7 +53,7 @@ export default function BackgroundMusicPanel() {
|
|||||||
<button
|
<button
|
||||||
onClick={handleRemoveMusic}
|
onClick={handleRemoveMusic}
|
||||||
className="p-1 rounded hover:bg-red-500/20 text-red-400 transition-colors"
|
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" />
|
<Trash2 className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
@ -70,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"
|
||||||
|
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>
|
<span className="text-xs text-editor-text w-10 text-right">{backgroundMusic.volumeDb} dB</span>
|
||||||
</div>
|
</div>
|
||||||
@ -81,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"
|
||||||
|
data-tooltip="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>
|
||||||
@ -102,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"
|
||||||
|
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>
|
<span className="text-xs text-editor-text w-10 text-right">{backgroundMusic.duckingDb} dB</span>
|
||||||
</div>
|
</div>
|
||||||
@ -115,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"
|
||||||
|
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>
|
<span className="text-xs text-editor-text w-10 text-right">{backgroundMusic.duckingAttackMs}ms</span>
|
||||||
</div>
|
</div>
|
||||||
@ -128,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"
|
||||||
|
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>
|
<span className="text-xs text-editor-text w-10 text-right">{backgroundMusic.duckingReleaseMs}ms</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -228,6 +228,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'}
|
||||||
@ -235,6 +236,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>
|
||||||
@ -250,6 +252,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)' },
|
||||||
]}
|
]}
|
||||||
|
data-tooltip="Output video resolution — higher resolution = larger file"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@ -264,6 +267,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)' }] : []),
|
||||||
]}
|
]}
|
||||||
|
data-tooltip="Output container format — MP4 is most compatible"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Video zoom / punch-in */}
|
{/* Video zoom / punch-in */}
|
||||||
@ -274,6 +278,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"
|
||||||
|
data-tooltip="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">
|
||||||
@ -297,6 +302,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"
|
||||||
|
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>
|
<span className="text-xs text-editor-text w-10 text-right">{options.zoom?.zoomFactor?.toFixed(2)}x</span>
|
||||||
</div>
|
</div>
|
||||||
@ -310,6 +316,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"
|
||||||
|
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>
|
<span className="text-xs text-editor-text w-10 text-right">{((options.zoom?.panX || 0) * 100).toFixed(0)}%</span>
|
||||||
</div>
|
</div>
|
||||||
@ -323,6 +330,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"
|
||||||
|
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>
|
<span className="text-xs text-editor-text w-10 text-right">{((options.zoom?.panY || 0) * 100).toFixed(0)}%</span>
|
||||||
</div>
|
</div>
|
||||||
@ -339,6 +347,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"
|
||||||
|
data-tooltip="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">
|
||||||
@ -407,6 +416,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"
|
||||||
|
data-tooltip="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>
|
||||||
@ -422,6 +432,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]"
|
||||||
|
data-tooltip="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>
|
||||||
@ -440,6 +451,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"
|
||||||
|
data-tooltip="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>
|
||||||
@ -454,6 +466,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' },
|
||||||
]}
|
]}
|
||||||
|
data-tooltip="Burn captions into video, export as separate SRT/VTT file, or none"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Transcript-only export */}
|
{/* Transcript-only export */}
|
||||||
@ -478,6 +491,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"
|
||||||
|
data-tooltip="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,6 +508,7 @@ export default function ExportDialog() {
|
|||||||
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-50 rounded-lg text-sm font-semibold transition-colors"
|
||||||
|
data-tooltip="Start export with current settings"
|
||||||
>
|
>
|
||||||
{isExporting ? (
|
{isExporting ? (
|
||||||
<>
|
<>
|
||||||
@ -538,16 +553,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'
|
||||||
@ -566,16 +584,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]"
|
||||||
|
|||||||
@ -144,6 +144,7 @@ function LicenseActivateDialog({
|
|||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-1 rounded hover:bg-editor-surface text-editor-text-muted"
|
className="p-1 rounded hover:bg-editor-surface text-editor-text-muted"
|
||||||
|
data-tooltip="Close dialog"
|
||||||
>
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -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"
|
||||||
|
data-tooltip="Add a marker at the current playhead position"
|
||||||
>
|
>
|
||||||
<MapPin className="w-3 h-3" />
|
<MapPin className="w-3 h-3" />
|
||||||
Add
|
Add
|
||||||
@ -110,8 +123,8 @@ 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" 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"><Trash2 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>
|
</div>
|
||||||
@ -138,6 +151,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"
|
||||||
|
data-tooltip="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"
|
||||||
|
data-tooltip="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"
|
||||||
|
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>
|
<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"
|
||||||
|
data-tooltip="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"
|
||||||
|
data-tooltip="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"
|
||||||
|
data-tooltip="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"
|
||||||
|
data-tooltip="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"
|
||||||
|
data-tooltip="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"
|
||||||
|
data-tooltip="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"
|
||||||
|
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>
|
<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"
|
||||||
|
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
|
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"
|
||||||
|
data-tooltip="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}
|
||||||
|
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"
|
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"
|
||||||
|
data-tooltip="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"
|
||||||
|
data-tooltip="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"
|
||||||
|
data-tooltip="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"
|
||||||
|
data-tooltip="Extra time to add after each detected silence"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -187,6 +191,7 @@ export default function SilenceTrimmerPanel() {
|
|||||||
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-50 rounded-lg text-sm font-medium transition-colors"
|
||||||
|
data-tooltip="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"
|
||||||
|
data-tooltip="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"
|
||||||
|
data-tooltip="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
|
||||||
@ -264,14 +271,14 @@ export default function SilenceTrimmerPanel() {
|
|||||||
<button
|
<button
|
||||||
onClick={() => loadGroupForEditing(group.id)}
|
onClick={() => loadGroupForEditing(group.id)}
|
||||||
className="px-1.5 py-1 rounded hover:bg-editor-accent/20 text-editor-accent"
|
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" />
|
<PencilLine className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => removeGroup(group.id)}
|
onClick={() => removeGroup(group.id)}
|
||||||
className="px-1.5 py-1 rounded hover:bg-editor-danger/20 text-editor-danger"
|
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" />
|
<Trash2 className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -511,7 +511,7 @@ export default function TranscriptEditor({
|
|||||||
requestAnimationFrame(() => searchInputRef.current?.focus());
|
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"
|
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" />
|
<Search className="w-3 h-3" />
|
||||||
Find
|
Find
|
||||||
@ -534,21 +534,21 @@ export default function TranscriptEditor({
|
|||||||
<button
|
<button
|
||||||
onClick={() => jumpToMatch(safeActiveMatchIdx - 1)}
|
onClick={() => jumpToMatch(safeActiveMatchIdx - 1)}
|
||||||
className="p-0.5 rounded hover:bg-editor-bg text-editor-text-muted hover:text-editor-text"
|
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" />
|
<ChevronUp className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => jumpToMatch(safeActiveMatchIdx + 1)}
|
onClick={() => jumpToMatch(safeActiveMatchIdx + 1)}
|
||||||
className="p-0.5 rounded hover:bg-editor-bg text-editor-text-muted hover:text-editor-text"
|
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" />
|
<ChevronDown className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setSearchOpen(false)}
|
onClick={() => setSearchOpen(false)}
|
||||||
className="p-0.5 rounded hover:bg-editor-bg text-editor-text-muted hover:text-editor-text"
|
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" />
|
<X className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
@ -561,6 +561,7 @@ export default function TranscriptEditor({
|
|||||||
onClick={cutSelectedWords}
|
onClick={cutSelectedWords}
|
||||||
disabled={!canEdit}
|
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"
|
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" />
|
<Scissors className="w-3 h-3" />
|
||||||
Cut
|
Cut
|
||||||
@ -569,6 +570,7 @@ export default function TranscriptEditor({
|
|||||||
onClick={muteSelectedWords}
|
onClick={muteSelectedWords}
|
||||||
disabled={!canEdit}
|
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"
|
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" />
|
<VolumeX className="w-3 h-3" />
|
||||||
Mute
|
Mute
|
||||||
@ -577,6 +579,7 @@ export default function TranscriptEditor({
|
|||||||
onClick={gainSelectedWords}
|
onClick={gainSelectedWords}
|
||||||
disabled={!canEdit}
|
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"
|
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" />
|
<SlidersHorizontal className="w-3 h-3" />
|
||||||
Gain ({gainModeDb > 0 ? '+' : ''}{gainModeDb.toFixed(1)} dB)
|
Gain ({gainModeDb > 0 ? '+' : ''}{gainModeDb.toFixed(1)} dB)
|
||||||
@ -585,6 +588,7 @@ export default function TranscriptEditor({
|
|||||||
onClick={speedSelectedWords}
|
onClick={speedSelectedWords}
|
||||||
disabled={!canEdit}
|
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"
|
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" />
|
<Gauge className="w-3 h-3" />
|
||||||
Speed {speedModeValue.toFixed(2)}x
|
Speed {speedModeValue.toFixed(2)}x
|
||||||
@ -593,7 +597,7 @@ export default function TranscriptEditor({
|
|||||||
onClick={handleReTranscribe}
|
onClick={handleReTranscribe}
|
||||||
disabled={isReTranscribing || !canEdit}
|
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"
|
data-tooltip="Re-run Whisper transcription on this segment"
|
||||||
>
|
>
|
||||||
<RefreshCw className={`w-3 h-3 ${isReTranscribing ? 'animate-spin' : ''}`} />
|
<RefreshCw className={`w-3 h-3 ${isReTranscribing ? 'animate-spin' : ''}`} />
|
||||||
{isReTranscribing ? 'Re-transcribing...' : 'Re-transcribe'}
|
{isReTranscribing ? 'Re-transcribing...' : 'Re-transcribe'}
|
||||||
|
|||||||
@ -1259,7 +1259,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" data-tooltip="Compress cut regions to preview the output timeline without gaps">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={showAdjustedTimeline}
|
checked={showAdjustedTimeline}
|
||||||
@ -1273,28 +1273,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"
|
data-tooltip="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"
|
data-tooltip="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"
|
data-tooltip="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"
|
data-tooltip="Toggle speed zones on the timeline — emerald overlays show speed changes"
|
||||||
>
|
>
|
||||||
Speed
|
Speed
|
||||||
</button>
|
</button>
|
||||||
@ -1309,7 +1309,7 @@ export default function WaveformTimeline({
|
|||||||
<AlertTriangle className="w-4 h-4 text-yellow-500 mt-0.5 shrink-0" />
|
<AlertTriangle className="w-4 h-4 text-yellow-500 mt-0.5 shrink-0" />
|
||||||
<pre
|
<pre
|
||||||
className="select-text cursor-text whitespace-pre-wrap break-all leading-relaxed"
|
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}
|
{audioError}
|
||||||
</pre>
|
</pre>
|
||||||
|
|||||||
@ -176,7 +176,7 @@ export default function ZoneEditor() {
|
|||||||
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 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 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>
|
<span className="text-xs text-editor-text-muted">sec</span>
|
||||||
</div>
|
</div>
|
||||||
@ -193,6 +193,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'
|
||||||
}`}
|
}`}
|
||||||
|
data-tooltip="Show all zones"
|
||||||
>
|
>
|
||||||
All
|
All
|
||||||
</button>
|
</button>
|
||||||
@ -203,6 +204,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'
|
||||||
}`}
|
}`}
|
||||||
|
data-tooltip="Show only Cut zones"
|
||||||
>
|
>
|
||||||
Cut
|
Cut
|
||||||
</button>
|
</button>
|
||||||
@ -213,6 +215,7 @@ export default function ZoneEditor() {
|
|||||||
? 'bg-orange-500/30 text-orange-500'
|
? 'bg-orange-500/30 text-orange-500'
|
||||||
: 'text-editor-text-muted hover:text-editor-text'
|
: 'text-editor-text-muted hover:text-editor-text'
|
||||||
}`}
|
}`}
|
||||||
|
data-tooltip="Show only Mute zones"
|
||||||
>
|
>
|
||||||
Mute
|
Mute
|
||||||
</button>
|
</button>
|
||||||
@ -223,6 +226,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'
|
||||||
}`}
|
}`}
|
||||||
|
data-tooltip="Show only Gain zones"
|
||||||
>
|
>
|
||||||
Gain
|
Gain
|
||||||
</button>
|
</button>
|
||||||
@ -233,6 +237,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'
|
||||||
}`}
|
}`}
|
||||||
|
data-tooltip="Show only Speed zones"
|
||||||
>
|
>
|
||||||
Speed
|
Speed
|
||||||
</button>
|
</button>
|
||||||
@ -274,7 +279,7 @@ export default function ZoneEditor() {
|
|||||||
removeZone('cut', range.id);
|
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"
|
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" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@ -311,7 +316,7 @@ export default function ZoneEditor() {
|
|||||||
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-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" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@ -350,6 +355,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"
|
||||||
|
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>
|
<span className="text-xs text-amber-500/80 font-medium w-6 text-right">dB</span>
|
||||||
</div>
|
</div>
|
||||||
@ -379,7 +385,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"
|
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')}
|
{renderPreviewButton(range.start, range.end, 'hover:bg-amber-500/20 text-amber-500/70 hover:text-amber-500')}
|
||||||
<button
|
<button
|
||||||
@ -388,7 +394,7 @@ export default function ZoneEditor() {
|
|||||||
removeZone('gain', range.id);
|
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"
|
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" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
@ -429,7 +435,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"
|
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')}
|
{renderPreviewButton(range.start, range.end, 'hover:bg-emerald-500/20 text-emerald-500/70 hover:text-emerald-500')}
|
||||||
<button
|
<button
|
||||||
@ -438,7 +444,7 @@ export default function ZoneEditor() {
|
|||||||
removeZone('speed', range.id);
|
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"
|
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" />
|
<Trash2 className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -46,3 +46,46 @@ body {
|
|||||||
video::-webkit-media-controls {
|
video::-webkit-media-controls {
|
||||||
display: none !important;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@ -112,4 +112,12 @@ window.electronAPI = {
|
|||||||
hasLicenseFeature: (feature: string): Promise<boolean> => {
|
hasLicenseFeature: (feature: string): Promise<boolean> => {
|
||||||
return invoke('has_license_feature', { feature });
|
return invoke('has_license_feature', { feature });
|
||||||
},
|
},
|
||||||
|
|
||||||
|
listModels: (): Promise<ModelInfo[]> => {
|
||||||
|
return invoke('list_models');
|
||||||
|
},
|
||||||
|
|
||||||
|
deleteModel: (path: string): Promise<void> => {
|
||||||
|
return invoke('delete_model', { path });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
9
frontend/src/vite-env.d.ts
vendored
9
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>;
|
||||||
@ -24,6 +31,8 @@ interface DesktopAPI {
|
|||||||
getAppStatus: () => Promise<any>;
|
getAppStatus: () => Promise<any>;
|
||||||
deactivateLicense: () => Promise<void>;
|
deactivateLicense: () => Promise<void>;
|
||||||
hasLicenseFeature: (feature: string) => Promise<boolean>;
|
hasLicenseFeature: (feature: string) => Promise<boolean>;
|
||||||
|
listModels: () => Promise<ModelInfo[]>;
|
||||||
|
deleteModel: (path: string) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Window {
|
interface Window {
|
||||||
|
|||||||
328
polish_plan.md
Normal file
328
polish_plan.md
Normal file
@ -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 `<div>` 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 `<pre>` 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
|
||||||
@ -11,6 +11,7 @@ mod ai_provider;
|
|||||||
mod caption_generator;
|
mod caption_generator;
|
||||||
mod background_removal;
|
mod background_removal;
|
||||||
mod licensing;
|
mod licensing;
|
||||||
|
mod models;
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn get_projects_directory() -> Result<String, String> {
|
fn get_projects_directory() -> Result<String, String> {
|
||||||
@ -207,6 +208,22 @@ 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 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.
|
/// Get the combined app status: licensed, trial, or expired.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
fn get_app_status(app_handle: tauri::AppHandle) -> Result<licensing::AppStatus, String> {
|
fn get_app_status(app_handle: tauri::AppHandle) -> Result<licensing::AppStatus, String> {
|
||||||
@ -358,6 +375,8 @@ pub fn run() {
|
|||||||
deactivate_license,
|
deactivate_license,
|
||||||
start_trial,
|
start_trial,
|
||||||
has_license_feature,
|
has_license_feature,
|
||||||
|
list_models,
|
||||||
|
delete_model,
|
||||||
])
|
])
|
||||||
.run(tauri::generate_context!())
|
.run(tauri::generate_context!())
|
||||||
.expect("error while running tauri application");
|
.expect("error while running tauri application");
|
||||||
|
|||||||
141
src-tauri/src/models.rs
Normal file
141
src-tauri/src/models.rs
Normal file
@ -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<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(())
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user