help menu
This commit is contained in:
15
FEATURES.md
15
FEATURES.md
@ -138,6 +138,21 @@ These aren't features to build — they're things to make more visible in the UI
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 🚫 Explicitly deferred — multi-track timeline
|
||||||
|
|
||||||
|
TalkEdit deliberately avoids becoming a general-purpose NLE. Multi-track compositing (z-order, per-track opacity, keyframe animation, nested sequences) would take months and you'd still lose to DaVinci Resolve (free), CapCut (free), and OpenShot (free).
|
||||||
|
|
||||||
|
**TalkEdit's advantage is that it isn't a timeline editor** — the text-is-the-timeline model makes spoken-word editing drastically faster than dragging razor cuts around.
|
||||||
|
|
||||||
|
What exists and what's planned:
|
||||||
|
- One audio track (main) + optional background music track (done)
|
||||||
|
- Clip concatenation (append clips end-to-end) (done)
|
||||||
|
- Potential future: **B-roll overlay** — a single up/down overlay track for inserting images or cutaway clips at specific timestamps. Useful for talking-head content (screenshots, charts, reaction clips) without a full multi-track system. The zone model already supports this — you add an overlay zone type with a file reference.
|
||||||
|
|
||||||
|
Everything beyond that (picture-in-picture, multi-layer compositing, per-layer keyframing) should stay deferred. If the market demands it later, it's easier to add than to remove complexity.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## ✅ Already Implemented
|
## ✅ Already Implemented
|
||||||
|
|
||||||
- [#025] Word-level transcript editing (select, drag, shift-click, delete)
|
- [#025] Word-level transcript editing (select, drag, shift-click, delete)
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import ZoneEditor from './components/ZoneEditor';
|
|||||||
import BackgroundMusicPanel from './components/BackgroundMusicPanel';
|
import BackgroundMusicPanel from './components/BackgroundMusicPanel';
|
||||||
import AppendClipPanel from './components/AppendClipPanel';
|
import AppendClipPanel from './components/AppendClipPanel';
|
||||||
import LicenseDialog from './components/LicenseDialog';
|
import LicenseDialog from './components/LicenseDialog';
|
||||||
|
import HelpContent from './components/HelpContent';
|
||||||
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
|
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
|
||||||
import { useLicenseStore } from './store/licenseStore';
|
import { useLicenseStore } from './store/licenseStore';
|
||||||
import {
|
import {
|
||||||
@ -35,11 +36,12 @@ import {
|
|||||||
ListVideo,
|
ListVideo,
|
||||||
Clock,
|
Clock,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
HelpCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const LAST_MEDIA_PATH_KEY = 'talkedit:lastMediaPath';
|
const LAST_MEDIA_PATH_KEY = 'talkedit:lastMediaPath';
|
||||||
|
|
||||||
type Panel = 'ai' | 'settings' | 'export' | 'silence' | 'zones' | 'markers' | 'music' | 'append' | null;
|
type Panel = 'ai' | 'settings' | 'export' | 'silence' | 'zones' | 'markers' | 'music' | 'append' | 'help' | null;
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const {
|
const {
|
||||||
@ -133,6 +135,9 @@ export default function App() {
|
|||||||
const [speedMode, setSpeedMode] = useState(false);
|
const [speedMode, setSpeedMode] = useState(false);
|
||||||
const [speedModeValue, setSpeedModeValue] = useState(1.25);
|
const [speedModeValue, setSpeedModeValue] = useState(1.25);
|
||||||
const [showReprocessConfirm, setShowReprocessConfirm] = useState(false);
|
const [showReprocessConfirm, setShowReprocessConfirm] = useState(false);
|
||||||
|
const [showWelcomeOverlay, setShowWelcomeOverlay] = useState(() => {
|
||||||
|
return localStorage.getItem('talkedit:welcomeDismissed') !== 'true';
|
||||||
|
});
|
||||||
const [showUnsavedPrompt, setShowUnsavedPrompt] = useState(false);
|
const [showUnsavedPrompt, setShowUnsavedPrompt] = useState(false);
|
||||||
const [pendingProceedAction, setPendingProceedAction] = useState<(() => Promise<void>) | null>(null);
|
const [pendingProceedAction, setPendingProceedAction] = useState<(() => Promise<void>) | null>(null);
|
||||||
const [lastSavedSignature, setLastSavedSignature] = useState<string | null>(null);
|
const [lastSavedSignature, setLastSavedSignature] = useState<string | null>(null);
|
||||||
@ -727,34 +732,6 @@ export default function App() {
|
|||||||
title="Append additional video clips — concatenate multiple files during export"
|
title="Append additional video clips — concatenate multiple files during export"
|
||||||
/>
|
/>
|
||||||
<div className="w-px h-5 bg-editor-border mx-1" />
|
<div className="w-px h-5 bg-editor-border mx-1" />
|
||||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-editor-surface border border-editor-border">
|
|
||||||
<select
|
|
||||||
value={whisperModel}
|
|
||||||
onChange={(e) => setWhisperModel(e.target.value)}
|
|
||||||
className="bg-editor-surface text-xs text-editor-text focus:outline-none [color-scheme:dark]"
|
|
||||||
title="Transcription model"
|
|
||||||
>
|
|
||||||
<optgroup label="Multilingual">
|
|
||||||
<option value="tiny">tiny</option>
|
|
||||||
<option value="base">base</option>
|
|
||||||
<option value="small">small</option>
|
|
||||||
<option value="medium">medium</option>
|
|
||||||
<option value="large-v2">large-v2</option>
|
|
||||||
<option value="large-v3">large-v3</option>
|
|
||||||
<option value="large-v3-turbo">large-v3-turbo</option>
|
|
||||||
<option value="distil-large-v3">distil-large-v3</option>
|
|
||||||
</optgroup>
|
|
||||||
<optgroup label="English">
|
|
||||||
<option value="tiny.en">tiny.en</option>
|
|
||||||
<option value="base.en">base.en</option>
|
|
||||||
<option value="small.en">small.en</option>
|
|
||||||
<option value="medium.en">medium.en</option>
|
|
||||||
<option value="distil-small.en">distil-small.en</option>
|
|
||||||
<option value="distil-medium.en">distil-medium.en</option>
|
|
||||||
</optgroup>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="w-px h-5 bg-editor-border mx-1" />
|
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
icon={<Sparkles className="w-4 h-4" />}
|
icon={<Sparkles className="w-4 h-4" />}
|
||||||
label="AI Tools"
|
label="AI Tools"
|
||||||
@ -776,6 +753,13 @@ export default function App() {
|
|||||||
active={activePanel === 'settings'}
|
active={activePanel === 'settings'}
|
||||||
onClick={() => togglePanel('settings')}
|
onClick={() => togglePanel('settings')}
|
||||||
/>
|
/>
|
||||||
|
<ToolbarButton
|
||||||
|
icon={<HelpCircle className="w-4 h-4" />}
|
||||||
|
label="Help"
|
||||||
|
active={activePanel === 'help'}
|
||||||
|
onClick={() => togglePanel('help')}
|
||||||
|
title="View help and feature documentation"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@ -833,6 +817,9 @@ export default function App() {
|
|||||||
<div className="text-center space-y-1">
|
<div className="text-center space-y-1">
|
||||||
<p className="text-sm font-medium text-editor-text">Processing audio</p>
|
<p className="text-sm font-medium text-editor-text">Processing audio</p>
|
||||||
<p className="text-xs text-editor-text-muted">{transcriptionStatus || 'Please wait...'}</p>
|
<p className="text-xs text-editor-text-muted">{transcriptionStatus || 'Please wait...'}</p>
|
||||||
|
<div className="w-48 h-1.5 bg-editor-border rounded-full overflow-hidden mt-3">
|
||||||
|
<div className="h-full bg-editor-accent rounded-full animate-pulse" style={{ width: '60%' }} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : words.length > 0 ? (
|
) : words.length > 0 ? (
|
||||||
@ -885,6 +872,7 @@ export default function App() {
|
|||||||
{activePanel === 'ai' && <AIPanel onReprocess={handleReprocessProject} whisperModel={whisperModel} setWhisperModel={setWhisperModel} />}
|
{activePanel === 'ai' && <AIPanel onReprocess={handleReprocessProject} whisperModel={whisperModel} setWhisperModel={setWhisperModel} />}
|
||||||
{activePanel === 'export' && <ExportDialog />}
|
{activePanel === 'export' && <ExportDialog />}
|
||||||
{activePanel === 'settings' && <SettingsPanel />}
|
{activePanel === 'settings' && <SettingsPanel />}
|
||||||
|
{activePanel === 'help' && <HelpContent />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -893,6 +881,53 @@ export default function App() {
|
|||||||
|
|
||||||
<LicenseDialog />
|
<LicenseDialog />
|
||||||
|
|
||||||
|
{showWelcomeOverlay && (
|
||||||
|
<div className="fixed inset-0 z-[85] flex items-center justify-center bg-black/70 px-4">
|
||||||
|
<div className="w-full max-w-sm rounded-xl border border-editor-border bg-editor-bg p-6 space-y-5 shadow-2xl">
|
||||||
|
<div className="flex flex-col items-center gap-3 text-center">
|
||||||
|
<Film className="w-10 h-10 text-editor-accent opacity-80" />
|
||||||
|
<h2 className="text-lg font-semibold">Welcome to TalkEdit</h2>
|
||||||
|
<p className="text-xs text-editor-text-muted leading-relaxed">
|
||||||
|
The offline video editor for long-form content. No account. No subscription. Your data never leaves your machine.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-start gap-3 p-2 rounded bg-editor-surface">
|
||||||
|
<span className="w-5 h-5 rounded-full bg-editor-accent/20 text-editor-accent text-[10px] font-semibold flex items-center justify-center shrink-0 mt-0.5">1</span>
|
||||||
|
<span className="text-xs text-editor-text-muted leading-relaxed">
|
||||||
|
<strong className="text-editor-text">Open a video</strong> — TalkEdit transcribes it into a word-level transcript.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-3 p-2 rounded bg-editor-surface">
|
||||||
|
<span className="w-5 h-5 rounded-full bg-editor-accent/20 text-editor-accent text-[10px] font-semibold flex items-center justify-center shrink-0 mt-0.5">2</span>
|
||||||
|
<span className="text-xs text-editor-text-muted leading-relaxed">
|
||||||
|
<strong className="text-editor-text">Edit by selecting words</strong> — deleting words cuts the matching video. Like editing a doc.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-3 p-2 rounded bg-editor-surface">
|
||||||
|
<span className="w-5 h-5 rounded-full bg-editor-accent/20 text-editor-accent text-[10px] font-semibold flex items-center justify-center shrink-0 mt-0.5">3</span>
|
||||||
|
<span className="text-xs text-editor-text-muted leading-relaxed">
|
||||||
|
<strong className="text-editor-text">Export your final cut</strong> — with captions, background music, AI cleanup, and more.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => { localStorage.setItem('talkedit:welcomeDismissed', 'true'); setShowWelcomeOverlay(false); }}
|
||||||
|
className="w-full px-6 py-2.5 bg-editor-accent hover:bg-editor-accent-hover rounded-lg text-sm font-medium transition-colors"
|
||||||
|
>
|
||||||
|
Got it
|
||||||
|
</button>
|
||||||
|
<p className="text-[10px] text-editor-text-muted">
|
||||||
|
Press <kbd className="px-1 py-0.5 text-[10px] font-mono bg-editor-surface border border-editor-border rounded">?</kbd> anytime to see shortcuts, or click <strong className="text-editor-text">Help</strong> in the toolbar.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{showReprocessConfirm && (
|
{showReprocessConfirm && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 px-4"
|
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 px-4"
|
||||||
|
|||||||
@ -510,19 +510,28 @@ export default function ExportDialog() {
|
|||||||
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-40 rounded-lg text-sm font-semibold transition-colors"
|
className="w-full flex items-center justify-center gap-2 px-4 py-3 bg-editor-accent hover:bg-editor-accent-hover disabled:opacity-40 rounded-lg text-sm font-semibold transition-colors"
|
||||||
title="Start export with current settings"
|
title="Start export with current settings"
|
||||||
>
|
>
|
||||||
{isExporting ? (
|
<Download className="w-4 h-4" />
|
||||||
<>
|
Export Video
|
||||||
<Loader2 className="w-4 h-4 animate-spin" />
|
|
||||||
Exporting... {Math.round(exportProgress)}%
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Download className="w-4 h-4" />
|
|
||||||
Export Video
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Export progress */}
|
||||||
|
{isExporting && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader2 className="w-4 h-4 animate-spin text-editor-accent" />
|
||||||
|
<span className="text-xs font-medium">Exporting...</span>
|
||||||
|
<span className="text-xs text-editor-text-muted">{Math.round(exportProgress)}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-2 bg-editor-border rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-editor-accent rounded-full transition-all duration-300"
|
||||||
|
style={{ width: `${exportProgress}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-editor-text-muted">Export in progress...</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{exportError && (
|
{exportError && (
|
||||||
<div className="rounded border border-red-500/40 bg-red-500/10 px-3 py-2 text-xs text-red-300">
|
<div className="rounded border border-red-500/40 bg-red-500/10 px-3 py-2 text-xs text-red-300">
|
||||||
{exportError}
|
{exportError}
|
||||||
|
|||||||
150
frontend/src/components/HelpContent.tsx
Normal file
150
frontend/src/components/HelpContent.tsx
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
import { HelpCircle, Scissors, VolumeX, SlidersHorizontal, Gauge, Film, Search, FileText, Download, Music, MapPin, ListVideo, Sparkles, Keyboard } from 'lucide-react';
|
||||||
|
|
||||||
|
export default function HelpContent() {
|
||||||
|
return (
|
||||||
|
<div className="p-4 space-y-5 overflow-y-auto">
|
||||||
|
<h3 className="text-sm font-semibold flex items-center gap-1.5">
|
||||||
|
<HelpCircle className="w-4 h-4" />
|
||||||
|
Help & Reference
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<Section title="Getting Started" icon={<Film className="w-3.5 h-3.5" />}>
|
||||||
|
<Step num={1}>Open a video file — click <strong>File > Open File</strong> or press <kbd>Ctrl+O</kbd></Step>
|
||||||
|
<Step num={2}>Wait for transcription — Whisper processes your audio and creates a word-level transcript</Step>
|
||||||
|
<Step num={3}>Edit by selecting words — choose <strong>Cut</strong>, <strong>Mute</strong>, <strong>Sound Gain</strong>, or <strong>Speed Adjust</strong> from the toolbar</Step>
|
||||||
|
<Step num={4}>Use AI tools — detect filler words, find clips, re-transcribe with a different model</Step>
|
||||||
|
<Step num={5}>Export — apply all edits and save your final video</Step>
|
||||||
|
<Step>Press <kbd>?</kbd> anytime to see all keyboard shortcuts</Step>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Cut / Mute / Sound Gain / Speed Adjust" icon={<Scissors className="w-3.5 h-3.5" />}>
|
||||||
|
<P>These are time-range edits applied during export. You create them in three ways:</P>
|
||||||
|
<Bullet>Select words in the transcript — the toolbar buttons create a zone from the selected word range</Bullet>
|
||||||
|
<Bullet>Use <strong>Mark In</strong> (<kbd>I</kbd>) and <strong>Mark Out</strong> (<kbd>O</kbd>) on the timeline, then clicking the toolbar button</Bullet>
|
||||||
|
<Bullet>Click a toolbar button to enter <strong>zone mode</strong>, then drag on the waveform timeline to draw a zone</Bullet>
|
||||||
|
<P className="mt-2">
|
||||||
|
<strong>Cut</strong> — removes the segment from the output entirely<br />
|
||||||
|
<strong>Mute</strong> — silences the audio but keeps the video<br />
|
||||||
|
<strong>Sound Gain</strong> — adjusts volume (positive = louder, negative = quieter)<br />
|
||||||
|
<strong>Speed Adjust</strong> — changes playback speed (1.0x = normal, 2.0x = double)
|
||||||
|
</P>
|
||||||
|
<P>View and manage all zones in the <strong>Edit Zones</strong> panel. Click a zone on the waveform to select it — drag edges to resize, drag the body to move.</P>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Waveform Timeline" icon={<Film className="w-3.5 h-3.5" />}>
|
||||||
|
<Bullet>Click to seek, drag to scrub through the video</Bullet>
|
||||||
|
<Bullet>Enter zone mode from the toolbar, then drag on the waveform to create a zone</Bullet>
|
||||||
|
<Bullet>Click an existing zone to select it — drag edges to resize, drag body to move</Bullet>
|
||||||
|
<Bullet><kbd>Delete</kbd> or <kbd>Backspace</kbd> removes the selected zone (with confirmation)</Bullet>
|
||||||
|
<Bullet><kbd>Ctrl+Scroll</kbd> to zoom in/out, scroll to pan horizontally</Bullet>
|
||||||
|
<Bullet>Toggle individual zone types on/off with the colored buttons above the waveform</Bullet>
|
||||||
|
<Bullet>"Show adjusted timeline" compresses cut regions to preview the output</Bullet>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Transcript Editing" icon={<FileText className="w-3.5 h-3.5" />}>
|
||||||
|
<Bullet>Click a word to select it, <kbd>Shift+Click</kbd> to extend the selection</Bullet>
|
||||||
|
<Bullet><kbd>Ctrl+Click</kbd> any word to seek the video to that exact timestamp</Bullet>
|
||||||
|
<Bullet>Double-click any word to edit its text directly</Bullet>
|
||||||
|
<Bullet>Words with low confidence get an orange dotted underline — adjust the threshold in Settings</Bullet>
|
||||||
|
<Bullet><kbd>Ctrl+F</kbd> to search the transcript — navigate matches with <kbd>Enter</kbd> / <kbd>Shift+Enter</kbd></Bullet>
|
||||||
|
<Bullet>Select a word range and click <strong>Re-transcribe</strong> to re-run Whisper on just that segment</Bullet>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Chapter Marks" icon={<MapPin className="w-3.5 h-3.5" />}>
|
||||||
|
<Bullet>Add markers at the current playhead position with a label and color</Bullet>
|
||||||
|
<Bullet>Use <kbd>I</kbd> / <kbd>O</kbd> keys to set mark in/out points on the timeline</Bullet>
|
||||||
|
<Bullet>Markers auto-sort as chapters — click <strong>Copy as YouTube timestamps</strong> to get chapter text</Bullet>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="AI Tools" icon={<Sparkles className="w-3.5 h-3.5" />}>
|
||||||
|
<P><strong>Filler Words</strong> — detects "um", "uh", "like", "you know" and similar words. Add custom fillers (e.g. "okay", "alright"). <strong>Apply All</strong> creates cut ranges for every detection at once.</P>
|
||||||
|
<P><strong>Create Clips</strong> — analyzes your transcript to find the best 20-60 second segments for TikTok, YouTube Shorts, or Instagram Reels.</P>
|
||||||
|
<P><strong>Reprocess</strong> — re-run transcription with a different Whisper model. Larger models are more accurate but slower. English-only models are faster for English content.</P>
|
||||||
|
<P>AI features work with the bundled local model (no setup needed), or via Ollama/OpenAI/Claude — configure in Settings.</P>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Export" icon={<Download className="w-3.5 h-3.5" />}>
|
||||||
|
<Bullet><strong>Fast mode</strong> (stream copy): instant, no quality loss — but doesn't apply cuts or effects</Bullet>
|
||||||
|
<Bullet><strong>Re-encode mode</strong>: applies all edits — cuts, gain, speed, zoom, captions, background music</Bullet>
|
||||||
|
<Bullet>Captions can be burned into the video or exported as separate SRT/VTT files</Bullet>
|
||||||
|
<Bullet>Loudness normalization targets: YouTube (-14 LUFS), Spotify (-16), Broadcast (-23)</Bullet>
|
||||||
|
<Bullet>Audio enhancement: noise reduction and speech clarity</Bullet>
|
||||||
|
<Bullet>Export Transcript Only — get SRT or plain text without the video</Bullet>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Background Music + Add Clips" icon={<Music className="w-3.5 h-3.5" />}>
|
||||||
|
<Bullet><strong>Bkg. Music</strong> — add a music track with auto-ducking: the music automatically lowers when someone speaks. Adjust volume, duck amount, attack, and release times.</Bullet>
|
||||||
|
<Bullet><strong>Add Clips</strong> — load additional video files to concatenate during export. Drag to reorder.</Bullet>
|
||||||
|
<Bullet>Both are applied during re-encode export only</Bullet>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<Section title="Keyboard Shortcuts" icon={<Keyboard className="w-3.5 h-3.5" />}>
|
||||||
|
<P>Press <kbd>?</kbd> anytime to see the full cheatsheet overlay. Remap any shortcut in Settings.</P>
|
||||||
|
<div className="grid grid-cols-2 gap-1 mt-2">
|
||||||
|
<Shortcut keys="Space" desc="Play / Pause" />
|
||||||
|
<Shortcut keys="J K L" desc="Slow / Pause / Speed" />
|
||||||
|
<Shortcut keys="← →" desc="Skip 5s back / forward" />
|
||||||
|
<Shortcut keys="I / O" desc="Mark In / Out points" />
|
||||||
|
<Shortcut keys="Delete" desc="Cut selected / marked range" />
|
||||||
|
<Shortcut keys="Ctrl+Z" desc="Undo" />
|
||||||
|
<Shortcut keys="Ctrl+Shift+Z" desc="Redo" />
|
||||||
|
<Shortcut keys="Ctrl+S" desc="Save project" />
|
||||||
|
<Shortcut keys="Ctrl+E" desc="Export" />
|
||||||
|
<Shortcut keys="Ctrl+F" desc="Find in transcript" />
|
||||||
|
<Shortcut keys="?" desc="Toggle cheatsheet" />
|
||||||
|
</div>
|
||||||
|
</Section>
|
||||||
|
|
||||||
|
<div className="text-[10px] text-editor-text-muted leading-relaxed border-t border-editor-border pt-4">
|
||||||
|
TalkEdit is 100% offline. No account required. No data leaves your machine. No subscription — buy once, own forever.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Section({ title, icon, children }: { title: string; icon: React.ReactNode; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 p-3 bg-editor-surface rounded-lg">
|
||||||
|
<h4 className="text-xs font-semibold flex items-center gap-1.5 text-editor-text">
|
||||||
|
{icon}
|
||||||
|
{title}
|
||||||
|
</h4>
|
||||||
|
<div className="space-y-1.5">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function P({ children, className = '' }: { children: React.ReactNode; className?: string }) {
|
||||||
|
return <p className={`text-xs text-editor-text-muted leading-relaxed ${className}`}>{children}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Bullet({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-1.5">
|
||||||
|
<span className="text-editor-accent mt-1.5 w-1 h-1 rounded-full bg-editor-accent shrink-0" />
|
||||||
|
<span className="text-xs text-editor-text-muted leading-relaxed">{children}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Step({ num, children }: { num?: number; children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-start gap-2">
|
||||||
|
<span className="w-5 h-5 rounded-full bg-editor-accent/20 text-editor-accent text-[10px] font-semibold flex items-center justify-center shrink-0 mt-0.5">
|
||||||
|
{num}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-editor-text-muted leading-relaxed">{children}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Shortcut({ keys, desc }: { keys: string; desc: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2 text-xs">
|
||||||
|
<kbd className="px-1.5 py-0.5 text-[10px] font-mono bg-editor-bg border border-editor-border rounded text-editor-text min-w-[72px] text-center">{keys}</kbd>
|
||||||
|
<span className="text-editor-text-muted">{desc}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -124,7 +124,7 @@ 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" title="Edit marker label and color"><PencilLine className="w-3 h-3" /></button>
|
<button onClick={() => startEdit(m.id, m.label)} className="p-0.5 hover:text-editor-accent" title="Edit marker label and color"><PencilLine className="w-3 h-3" /></button>
|
||||||
<button onClick={() => removeTimelineMarker(m.id)} className="p-0.5 hover:text-editor-danger" title="Delete this marker"><Trash2 className="w-3 h-3" /></button>
|
<button onClick={() => { if (window.confirm("Delete this marker?")) removeTimelineMarker(m.id); }} className="p-0.5 hover:text-editor-danger" title="Delete this marker"><Trash2 className="w-3 h-3" /></button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -457,8 +457,8 @@ export default function TranscriptEditor({
|
|||||||
${isZoneDragSelected && muteMode ? 'bg-blue-500/30 ring-1 ring-blue-400/60' : ''}
|
${isZoneDragSelected && muteMode ? 'bg-blue-500/30 ring-1 ring-blue-400/60' : ''}
|
||||||
${isZoneDragSelected && gainMode ? 'bg-amber-500/30 ring-1 ring-amber-400/60' : ''}
|
${isZoneDragSelected && gainMode ? 'bg-amber-500/30 ring-1 ring-amber-400/60' : ''}
|
||||||
${isZoneDragSelected && speedMode ? 'bg-emerald-500/30 ring-1 ring-emerald-400/60' : ''}
|
${isZoneDragSelected && speedMode ? 'bg-emerald-500/30 ring-1 ring-emerald-400/60' : ''}
|
||||||
${isSearchMatch && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-accent/15 ring-1 ring-editor-accent/35' : ''}
|
${isSearchMatch && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-accent/15 ring-2 ring-editor-accent/50' : ''}
|
||||||
${isActiveSearchMatch && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-accent/35 ring-1 ring-editor-accent text-white' : ''}
|
${isActiveSearchMatch && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-accent/35 ring-2 ring-editor-accent text-white font-medium' : ''}
|
||||||
${isSelected && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-word-selected text-white' : ''}
|
${isSelected && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-word-selected text-white' : ''}
|
||||||
${isActive && !isSelected && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-accent/20 text-editor-accent' : ''}
|
${isActive && !isSelected && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-accent/20 text-editor-accent' : ''}
|
||||||
${isHovered && !isSelected && !isActive && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-word-hover' : ''}
|
${isHovered && !isSelected && !isActive && !cutRange && !muteRange && !gainRange && !speedRange ? 'bg-editor-word-hover' : ''}
|
||||||
|
|||||||
@ -473,10 +473,10 @@ export default function WaveformTimeline({
|
|||||||
// Draw resize handles
|
// Draw resize handles
|
||||||
ctx.fillStyle = '#ef4444';
|
ctx.fillStyle = '#ef4444';
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(x1, waveTop + waveH / 2, 4, 0, 2 * Math.PI);
|
ctx.arc(x1, waveTop + waveH / 2, 6, 0, 2 * Math.PI);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(x2, waveTop + waveH / 2, 4, 0, 2 * Math.PI);
|
ctx.arc(x2, waveTop + waveH / 2, 6, 0, 2 * Math.PI);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -498,10 +498,10 @@ export default function WaveformTimeline({
|
|||||||
// Draw resize handles
|
// Draw resize handles
|
||||||
ctx.fillStyle = '#3b82f6';
|
ctx.fillStyle = '#3b82f6';
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(x1, waveTop + waveH / 2, 4, 0, 2 * Math.PI);
|
ctx.arc(x1, waveTop + waveH / 2, 6, 0, 2 * Math.PI);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(x2, waveTop + waveH / 2, 4, 0, 2 * Math.PI);
|
ctx.arc(x2, waveTop + waveH / 2, 6, 0, 2 * Math.PI);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -522,10 +522,10 @@ export default function WaveformTimeline({
|
|||||||
|
|
||||||
ctx.fillStyle = '#f59e0b';
|
ctx.fillStyle = '#f59e0b';
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(x1, waveTop + waveH / 2, 4, 0, 2 * Math.PI);
|
ctx.arc(x1, waveTop + waveH / 2, 6, 0, 2 * Math.PI);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(x2, waveTop + waveH / 2, 4, 0, 2 * Math.PI);
|
ctx.arc(x2, waveTop + waveH / 2, 6, 0, 2 * Math.PI);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -546,10 +546,10 @@ export default function WaveformTimeline({
|
|||||||
|
|
||||||
ctx.fillStyle = '#10b981';
|
ctx.fillStyle = '#10b981';
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(x1, waveTop + waveH / 2, 4, 0, 2 * Math.PI);
|
ctx.arc(x1, waveTop + waveH / 2, 6, 0, 2 * Math.PI);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.arc(x2, waveTop + waveH / 2, 4, 0, 2 * Math.PI);
|
ctx.arc(x2, waveTop + waveH / 2, 6, 0, 2 * Math.PI);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -849,7 +849,7 @@ export default function WaveformTimeline({
|
|||||||
// Check if click is in waveform area
|
// Check if click is in waveform area
|
||||||
if (y < waveTop || y > waveTop + waveH) return null;
|
if (y < waveTop || y > waveTop + waveH) return null;
|
||||||
|
|
||||||
const handleSize = forHover ? 6 : 8; // Smaller hit area for hover, larger for click
|
const handleSize = forHover ? 8 : 10;
|
||||||
|
|
||||||
// Check cut ranges
|
// Check cut ranges
|
||||||
for (const range of showCutZones ? cutRanges : []) {
|
for (const range of showCutZones ? cutRanges : []) {
|
||||||
@ -1188,6 +1188,7 @@ export default function WaveformTimeline({
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
e.stopImmediatePropagation();
|
e.stopImmediatePropagation();
|
||||||
|
if (!window.confirm("Delete this zone?")) return;
|
||||||
if (selectedZone.type === 'cut') {
|
if (selectedZone.type === 'cut') {
|
||||||
removeCutRange(selectedZone.id);
|
removeCutRange(selectedZone.id);
|
||||||
} else if (selectedZone.type === 'mute') {
|
} else if (selectedZone.type === 'mute') {
|
||||||
@ -1313,6 +1314,15 @@ export default function WaveformTimeline({
|
|||||||
>
|
>
|
||||||
{audioError}
|
{audioError}
|
||||||
</pre>
|
</pre>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setAudioError(null);
|
||||||
|
waveformDataRef.current = null;
|
||||||
|
}}
|
||||||
|
className="px-3 py-1 text-xs rounded bg-editor-surface border border-editor-border text-editor-text hover:bg-editor-bg transition-colors"
|
||||||
|
>
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : !waveformDataRef.current ? (
|
) : !waveformDataRef.current ? (
|
||||||
<div className="flex-1 flex items-center justify-center">
|
<div className="flex-1 flex items-center justify-center">
|
||||||
|
|||||||
@ -120,6 +120,7 @@ export default function ZoneEditor() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const removeZone = useCallback((type: 'cut' | 'mute' | 'gain' | 'speed', id: string) => {
|
const removeZone = useCallback((type: 'cut' | 'mute' | 'gain' | 'speed', id: string) => {
|
||||||
|
if (!window.confirm("Delete this zone?")) return;
|
||||||
if (type === 'cut') removeCutRange(id);
|
if (type === 'cut') removeCutRange(id);
|
||||||
else if (type === 'mute') removeMuteRange(id);
|
else if (type === 'mute') removeMuteRange(id);
|
||||||
else if (type === 'gain') removeGainRange(id);
|
else if (type === 'gain') removeGainRange(id);
|
||||||
|
|||||||
Reference in New Issue
Block a user