From cde635a660af22b04f4051f62b223fd1c43db962 Mon Sep 17 00:00:00 2001 From: dillonj Date: Tue, 5 May 2026 12:29:25 -0600 Subject: [PATCH] improved chapters/markers --- frontend/src/App.tsx | 76 +++++++++- frontend/src/components/MarkersPanel.tsx | 151 +++++++++++++++++++ frontend/src/components/WaveformTimeline.tsx | 39 +++-- frontend/src/lib/keybindings.ts | 83 ++++++++++ frontend/src/lib/thumbnails.ts | 81 ++++++++++ 5 files changed, 410 insertions(+), 20 deletions(-) create mode 100644 frontend/src/components/MarkersPanel.tsx create mode 100644 frontend/src/lib/keybindings.ts create mode 100644 frontend/src/lib/thumbnails.ts diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9c641c0..1c59e03 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useMemo } from 'react'; +import { useEffect, useState, useMemo, useCallback, useRef } from 'react'; import { useEditorStore } from './store/editorStore'; import VideoPlayer from './components/VideoPlayer'; import TranscriptEditor from './components/TranscriptEditor'; @@ -68,6 +68,54 @@ export default function App() { const [activePanel, setActivePanel] = useState(null); const [projectName, setProjectName] = useState(null); + const [splitRatio, setSplitRatio] = useState(() => { + try { return Number(localStorage.getItem('talkedit:splitRatio')) || 0.5; } catch { return 0.5; } + }); + const splitRef = useRef(null); + const isDraggingSplit = useRef(false); + + const startSplitDrag = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + isDraggingSplit.current = true; + const container = splitRef.current?.parentElement; + if (!container) return; + const rect = container.getBoundingClientRect(); + const onMove = (me: MouseEvent) => { + if (!isDraggingSplit.current) return; + const pct = (me.clientX - rect.left) / rect.width; + const clamped = Math.max(0.15, Math.min(0.85, pct)); + setSplitRatio(clamped); + localStorage.setItem('talkedit:splitRatio', String(clamped)); + }; + const onUp = () => { isDraggingSplit.current = false; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + }, []); + + // Draggable right sidebar + const [sidebarWidth, setSidebarWidth] = useState(() => { + try { return Number(localStorage.getItem('talkedit:sidebarWidth')) || 320; } catch { return 320; } + }); + const isDraggingSidebar = useRef(false); + + const startSidebarDrag = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + isDraggingSidebar.current = true; + const container = document.querySelector('.main-content') as HTMLElement; + if (!container) return; + const rect = container.getBoundingClientRect(); + const onMove = (me: MouseEvent) => { + if (!isDraggingSidebar.current) return; + const w = rect.right - me.clientX; + const clamped = Math.max(180, Math.min(600, w)); + setSidebarWidth(clamped); + localStorage.setItem('talkedit:sidebarWidth', String(clamped)); + }; + const onUp = () => { isDraggingSidebar.current = false; window.removeEventListener('mousemove', onMove); window.removeEventListener('mouseup', onUp); }; + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + }, []); + const [whisperModel, setWhisperModel] = useState('base'); useEffect(() => { if (transcriptionModel) setWhisperModel(transcriptionModel); }, [transcriptionModel]); const [cutMode, setCutMode] = useState(false); @@ -666,17 +714,24 @@ export default function App() { {/* Main content */} -
+
{/* Left: video + transcript */}
-
+
{/* Video player */} -
+
+ {/* Draggable divider */} +
+ {/* Transcript */} -
+
{videoPath && (
{projectName && ( @@ -745,7 +800,13 @@ export default function App() { {/* Right panel (AI / Export / Settings) */} {activePanel && ( -
+
+ {/* Draggable sidebar divider */} +
+
{activePanel === 'zones' && ( )} @@ -755,7 +816,8 @@ export default function App() { {activePanel === 'export' && } {activePanel === 'settings' && }
- )} +
+ )}
{import.meta.env.DEV && } diff --git a/frontend/src/components/MarkersPanel.tsx b/frontend/src/components/MarkersPanel.tsx new file mode 100644 index 0000000..4dfad58 --- /dev/null +++ b/frontend/src/components/MarkersPanel.tsx @@ -0,0 +1,151 @@ +import { useState } from 'react'; +import { useEditorStore } from '../store/editorStore'; +import { MapPin, Trash2, PencilLine, Check, X, Copy } from 'lucide-react'; + +const COLORS = ['#6366f1', '#ef4444', '#22c55e', '#f59e0b', '#3b82f6', '#ec4899', '#8b5cf6', '#14b8a6']; + +export default function MarkersPanel() { + const { timelineMarkers, addTimelineMarker, updateTimelineMarker, removeTimelineMarker, getChapters } = + useEditorStore(); + const currentTime = useEditorStore((s) => s.currentTime); + const [editingId, setEditingId] = useState(null); + const [editLabel, setEditLabel] = useState(''); + const [newLabel, setNewLabel] = useState(''); + const [newColor, setNewColor] = useState(COLORS[0]); + const [showChapters, setShowChapters] = useState(false); + + const chapters = getChapters(); + + const addAtCurrentTime = () => { + addTimelineMarker(currentTime, newLabel || undefined, newColor); + setNewLabel(''); + }; + + const startEdit = (id: string, label: string) => { + setEditingId(id); + setEditLabel(label); + }; + + const commitEdit = (id: string) => { + if (editLabel.trim()) { + updateTimelineMarker(id, { label: editLabel.trim() }); + } + setEditingId(null); + }; + + const exportChapters = () => { + const lines = chapters.map((ch) => { + const h = Math.floor(ch.startTime / 3600); + const m = Math.floor((ch.startTime % 3600) / 60); + const s = Math.floor(ch.startTime % 60); + const timeStr = `${h > 0 ? `${h}:` : ''}${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}`; + return `${timeStr} ${ch.label}`; + }); + const text = lines.join('\n'); + navigator.clipboard.writeText(text).catch(() => {}); + }; + + return ( +
+
+

+ + Timeline Markers +

+

+ Drop markers at key points. Markers become YouTube-compatible chapters. +

+
+ + {/* Add marker at current time */} +
+
+ setNewLabel(e.target.value)} + placeholder={`${currentTime.toFixed(2)}s`} + 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" + /> +
+ {COLORS.map((c) => ( +
+
+ +
+ + {/* Marker list */} + {timelineMarkers.length > 0 && ( +
+ {timelineMarkers.map((m) => ( +
+
+ {m.time.toFixed(2)}s + {editingId === m.id ? ( + <> + setEditLabel(e.target.value)} + autoFocus + className="flex-1 px-1.5 py-0.5 text-xs bg-editor-bg border border-editor-border rounded focus:outline-none focus:border-editor-accent" + /> + + + + ) : ( + <> + {m.label} + + + + )} +
+ ))} +
+ )} + + {/* Chapters */} + {chapters.length > 0 && ( +
+ + {showChapters && ( +
+ {chapters.map((ch) => ( +
+ {ch.label} +
+ ))} + +
+ )} +
+ )} +
+ ); +} diff --git a/frontend/src/components/WaveformTimeline.tsx b/frontend/src/components/WaveformTimeline.tsx index bb75e55..a594e49 100644 --- a/frontend/src/components/WaveformTimeline.tsx +++ b/frontend/src/components/WaveformTimeline.tsx @@ -612,26 +612,39 @@ export default function WaveformTimeline({ if (markInTime !== null) drawMarkLine(markInTime, 'I'); if (markOutTime !== null) drawMarkLine(markOutTime, 'O'); - // Draw timeline markers (colored pins) - for (const marker of timelineMarkers) { + // Draw timeline markers (numbered circles) + const sortedMarkers = [...timelineMarkers].sort((a, b) => a.time - b.time); + for (let mi = 0; mi < sortedMarkers.length; mi++) { + const marker = sortedMarkers[mi]; + const number = mi + 1; const x = (sourceToDisplayTime(marker.time, timelineSegments, dur) - scroll) * pxPerSec; - if (x < -4 || x > width + 4) continue; - // Draw a pin triangle + if (x < -8 || x > width + 8) continue; + const radius = 7; + const cy = waveTop - radius - 2; + // Draw filled circle ctx.beginPath(); - ctx.moveTo(x, waveTop - 8); - ctx.lineTo(x - 4, waveTop - 2); - ctx.lineTo(x + 4, waveTop - 2); - ctx.closePath(); + ctx.arc(x, cy, radius, 0, Math.PI * 2); ctx.fillStyle = marker.color; ctx.fill(); + ctx.strokeStyle = '#0f1117'; + ctx.lineWidth = 1.5; + ctx.stroke(); + // Draw number + ctx.fillStyle = '#ffffff'; + ctx.font = 'bold 9px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(String(number), x, cy); + ctx.textAlign = 'start'; + ctx.textBaseline = 'alphabetic'; + // Draw a thin vertical line below the circle + ctx.beginPath(); + ctx.moveTo(x, cy + radius); + ctx.lineTo(x, waveTop + waveH); ctx.strokeStyle = marker.color; ctx.lineWidth = 1; - // Draw a thin vertical line - ctx.beginPath(); - ctx.moveTo(x, waveTop); - ctx.lineTo(x, waveTop + waveH); ctx.stroke(); - // Label + // Label to the right ctx.fillStyle = marker.color; ctx.font = '9px sans-serif'; ctx.textBaseline = 'bottom'; diff --git a/frontend/src/lib/keybindings.ts b/frontend/src/lib/keybindings.ts new file mode 100644 index 0000000..3d6785d --- /dev/null +++ b/frontend/src/lib/keybindings.ts @@ -0,0 +1,83 @@ +/** + * Configurable keyboard shortcuts system. + * Stores bindings in localStorage under 'talkedit:keybindings'. + * Provides default presets and conflict detection. + */ +import type { KeyBinding, HotkeyPreset } from '../types/project'; + +const STORAGE_KEY = 'talkedit:keybindings'; + +export const DEFAULT_PRESETS: Record = { + 'left-hand': [ + { id: 'play-pause', label: 'Play / Pause', keys: 'Space', category: 'transport' }, + { id: 'rewind', label: 'Rewind 5s', keys: 'Q', category: 'transport' }, + { id: 'forward', label: 'Forward 5s', keys: 'E', category: 'transport' }, + { id: 'speed-up', label: 'Speed Up', keys: 'W', category: 'transport' }, + { id: 'slow-down', label: 'Slow Down', keys: 'S', category: 'transport' }, + { id: 'pause', label: 'Pause', keys: 'D', category: 'transport' }, + { id: 'mark-in', label: 'Mark In Point', keys: 'A', category: 'edit' }, + { id: 'mark-out', label: 'Mark Out Point', keys: 'F', category: 'edit' }, + { id: 'cut', label: 'Cut Selection', keys: 'X', category: 'edit' }, + { id: 'undo', label: 'Undo', keys: 'Ctrl+Z', category: 'edit' }, + { id: 'redo', label: 'Redo', keys: 'Ctrl+Shift+Z', category: 'edit' }, + { id: 'save', label: 'Save', keys: 'Ctrl+S', category: 'file' }, + { id: 'export', label: 'Export', keys: 'Ctrl+E', category: 'file' }, + { id: 'search', label: 'Find in Transcript', keys: 'Ctrl+F', category: 'edit' }, + { id: 'help', label: 'Shortcut Help', keys: '?', category: 'view' }, + ], + 'standard': [ + { id: 'play-pause', label: 'Play / Pause', keys: 'Space', category: 'transport' }, + { id: 'rewind', label: 'Rewind 5s', keys: 'ArrowLeft', category: 'transport' }, + { id: 'forward', label: 'Forward 5s', keys: 'ArrowRight', category: 'transport' }, + { id: 'speed-up', label: 'Speed Up', keys: 'L', category: 'transport' }, + { id: 'slow-down', label: 'Slow Down', keys: 'J', category: 'transport' }, + { id: 'pause', label: 'Pause', keys: 'K', category: 'transport' }, + { id: 'mark-in', label: 'Mark In Point', keys: 'I', category: 'edit' }, + { id: 'mark-out', label: 'Mark Out Point', keys: 'O', category: 'edit' }, + { id: 'cut', label: 'Cut Selection', keys: 'Delete', category: 'edit' }, + { id: 'undo', label: 'Undo', keys: 'Ctrl+Z', category: 'edit' }, + { id: 'redo', label: 'Redo', keys: 'Ctrl+Shift+Z', category: 'edit' }, + { id: 'save', label: 'Save', keys: 'Ctrl+S', category: 'file' }, + { id: 'export', label: 'Export', keys: 'Ctrl+E', category: 'file' }, + { id: 'search', label: 'Find in Transcript', keys: 'Ctrl+F', category: 'edit' }, + { id: 'help', label: 'Shortcut Help', keys: '?', category: 'view' }, + ], +}; + +export function loadBindings(): KeyBinding[] { + try { + const stored = localStorage.getItem(STORAGE_KEY); + if (stored) return JSON.parse(stored); + } catch { /* use defaults */ } + return DEFAULT_PRESETS['standard']; +} + +export function saveBindings(bindings: KeyBinding[]) { + localStorage.setItem(STORAGE_KEY, JSON.stringify(bindings)); +} + +export function applyPreset(preset: HotkeyPreset): KeyBinding[] { + const bindings = DEFAULT_PRESETS[preset]; + saveBindings(bindings); + return bindings; +} + +export function detectConflicts(bindings: KeyBinding[]): string[] { + const conflicts: string[] = []; + const seen = new Map(); + for (const b of bindings) { + if (seen.has(b.keys)) { + conflicts.push(`"${b.keys}" is used by both "${seen.get(b.keys)}" and "${b.label}"`); + } + seen.set(b.keys, b.label); + } + return conflicts; +} + +export function findBinding(bindings: KeyBinding[], id: string): KeyBinding | undefined { + return bindings.find((b) => b.id === id); +} + +export function getBoundKey(bindings: KeyBinding[], id: string): string { + return findBinding(bindings, id)?.keys || ''; +} diff --git a/frontend/src/lib/thumbnails.ts b/frontend/src/lib/thumbnails.ts new file mode 100644 index 0000000..e902666 --- /dev/null +++ b/frontend/src/lib/thumbnails.ts @@ -0,0 +1,81 @@ +/** + * Frontend-side video thumbnail extraction. + * Captures frames from the