verified hotkeys
This commit is contained in:
4
.github/copilot-instructions.md
vendored
4
.github/copilot-instructions.md
vendored
@ -73,6 +73,10 @@ Use project virtualenvs where available (`.venv312`, `.venv`, or `venv`) for bac
|
|||||||
- **color-scheme:dark**: All `<select>` elements in ExportDialog use `[color-scheme:dark]` to ensure readable native dropdown popups on Linux WebKit.
|
- **color-scheme:dark**: All `<select>` elements in ExportDialog use `[color-scheme:dark]` to ensure readable native dropdown popups on Linux WebKit.
|
||||||
- **Re-transcribe selection (#013)**: Backend `POST /transcribe/segment` extracts audio via FFmpeg, runs Whisper, adjusts timestamps. Frontend: "Re-transcribe" button on selected words in TranscriptEditor; `replaceWordRange()` store action swaps words + rebuilds segments by speaker.
|
- **Re-transcribe selection (#013)**: Backend `POST /transcribe/segment` extracts audio via FFmpeg, runs Whisper, adjusts timestamps. Frontend: "Re-transcribe" button on selected words in TranscriptEditor; `replaceWordRange()` store action swaps words + rebuilds segments by speaker.
|
||||||
- **Transcript-only export (#024)**: "Export Transcript Only" in ExportDialog with .txt/.srt options. **Pure frontend** — generates content in-browser, writes via Tauri `writeFile`. No backend dependency. Respects word cuts.
|
- **Transcript-only export (#024)**: "Export Transcript Only" in ExportDialog with .txt/.srt options. **Pure frontend** — generates content in-browser, writes via Tauri `writeFile`. No backend dependency. Respects word cuts.
|
||||||
|
- **Named timeline markers (#016)**: `TimelineMarker` type in `project.ts`. Store actions: `addTimelineMarker`, `updateTimelineMarker`, `removeTimelineMarker`. Colored pins on waveform canvas. MarkersPanel UI for add/edit/delete. Persisted in project.
|
||||||
|
- **Chapters (#017)**: `getChapters()` store action derives from sorted markers. "Copy as YouTube timestamps" in MarkersPanel. Zero backend.
|
||||||
|
- **Clip thumbnail strip (#022)**: `lib/thumbnails.ts` — frontend canvas capture from `<video>`. Toggle button in WaveformTimeline. Clickable frames at 10s intervals.
|
||||||
|
- **Customizable hotkeys (#041)**: `lib/keybindings.ts` with two presets (standard + left-hand). `useKeyboardShortcuts.ts` reads bindings dynamically. Settings panel includes key remapper with conflict detection and per-key reset. `?` key shows dynamic cheatsheet.
|
||||||
|
|
||||||
## Update Rules (Important)
|
## Update Rules (Important)
|
||||||
|
|
||||||
|
|||||||
@ -22,13 +22,13 @@ Features are grouped by priority. Check off items as they are implemented.
|
|||||||
|
|
||||||
## 🟡 Medium Impact — Workflow completeness
|
## 🟡 Medium Impact — Workflow completeness
|
||||||
|
|
||||||
- [ ] [#016] **Named timeline markers** — drop named marker pins on the waveform (like Resolve markers). Store as `{ id, time, label, color }` in the project. Rendered as colored triangles on the timeline canvas.
|
- [x] [#016] **Named timeline markers** — colored marker pins on the waveform canvas. Add at current playback position with label/color picker in Markers panel. Editable labels, deletable. Persisted in project file. (2026-05-04)
|
||||||
|
|
||||||
- [ ] [#017] **Chapters** — group markers into named chapter ranges. Useful for podcasts and lectures. Exportable as YouTube chapter timestamps in the description.
|
- [x] [#017] **Chapters** — sorted markers auto-form chapters. "Copy as YouTube timestamps" button exports `MM:SS Label` format to clipboard. (2026-05-04)
|
||||||
|
|
||||||
- [ ] [#041] **Customizable hotkeys / keymap editor (left-hand focused)** — allow users to view, remap, and reset keyboard shortcuts (transport, edit, save/export, zone tools), with a default preset optimized for left-hand reach (Q/W/E/R/A/S/D/F/Z/X/C/V + modifiers). Include conflict detection, an alternate standard preset, and one-click "restore defaults".
|
- [x] [#041] **Customizable hotkeys / keymap editor** — two presets (Standard: J/K/L/I/O/arrows; Left-hand: Q/W/E/A/S/D/F). Settings panel shows all bindings with click-to-remap, conflict detection, per-key reset to default. Cheatsheet (press `?`) shows current bindings. (2026-05-04)
|
||||||
|
|
||||||
- [ ] [#022] **Clip thumbnail strip** — video frame thumbnails along the timeline so users can navigate visually, not only by waveform. Backend: `ffmpeg` thumbnail extraction at regular intervals.
|
- [x] [#022] **Clip thumbnail strip** — frontend-side canvas capture from the `<video>` element. Toggle "Thumbnails" button above waveform. Extracts frames at 10s intervals, clickable to seek. Zero backend dependency. (2026-05-04)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import AIPanel from './components/AIPanel';
|
|||||||
import ExportDialog from './components/ExportDialog';
|
import ExportDialog from './components/ExportDialog';
|
||||||
import SettingsPanel from './components/SettingsPanel';
|
import SettingsPanel from './components/SettingsPanel';
|
||||||
import DevPanel from './components/DevPanel';
|
import DevPanel from './components/DevPanel';
|
||||||
|
import MarkersPanel from './components/MarkersPanel';
|
||||||
import SilenceTrimmerPanel from './components/SilenceTrimmerPanel';
|
import SilenceTrimmerPanel from './components/SilenceTrimmerPanel';
|
||||||
import ZoneEditor from './components/ZoneEditor';
|
import ZoneEditor from './components/ZoneEditor';
|
||||||
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
|
import { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts';
|
||||||
@ -25,11 +26,12 @@ import {
|
|||||||
FilePlus2,
|
FilePlus2,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Grid3x3,
|
Grid3x3,
|
||||||
|
MapPin,
|
||||||
} 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' | null;
|
type Panel = 'ai' | 'settings' | 'export' | 'silence' | 'zones' | 'markers' | null;
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const {
|
const {
|
||||||
@ -597,6 +599,13 @@ export default function App() {
|
|||||||
onClick={() => togglePanel('silence')}
|
onClick={() => togglePanel('silence')}
|
||||||
disabled={!videoPath}
|
disabled={!videoPath}
|
||||||
/>
|
/>
|
||||||
|
<ToolbarButton
|
||||||
|
icon={<MapPin className="w-4 h-4" />}
|
||||||
|
label="Markers"
|
||||||
|
active={activePanel === 'markers'}
|
||||||
|
onClick={() => togglePanel('markers')}
|
||||||
|
disabled={!videoPath}
|
||||||
|
/>
|
||||||
<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
|
||||||
value={whisperModel}
|
value={whisperModel}
|
||||||
@ -741,6 +750,7 @@ export default function App() {
|
|||||||
<ZoneEditor />
|
<ZoneEditor />
|
||||||
)}
|
)}
|
||||||
{activePanel === 'silence' && <SilenceTrimmerPanel />}
|
{activePanel === 'silence' && <SilenceTrimmerPanel />}
|
||||||
|
{activePanel === 'markers' && <MarkersPanel />}
|
||||||
{activePanel === 'ai' && <AIPanel />}
|
{activePanel === 'ai' && <AIPanel />}
|
||||||
{activePanel === 'export' && <ExportDialog />}
|
{activePanel === 'export' && <ExportDialog />}
|
||||||
{activePanel === 'settings' && <SettingsPanel />}
|
{activePanel === 'settings' && <SettingsPanel />}
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import { useAIStore } from '../store/aiStore';
|
import { useAIStore } from '../store/aiStore';
|
||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import type { AIProvider } 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 } from 'lucide-react';
|
import { Bot, Cloud, Brain, RefreshCw, Keyboard } from 'lucide-react';
|
||||||
|
import { loadBindings, saveBindings, applyPreset as applyKeyPreset, DEFAULT_PRESETS, detectConflicts as detectKeyConflicts } from '../lib/keybindings';
|
||||||
|
|
||||||
export default function SettingsPanel() {
|
export default function SettingsPanel() {
|
||||||
const { providers, defaultProvider, setProviderConfig, setDefaultProvider } = useAIStore();
|
const { providers, defaultProvider, setProviderConfig, setDefaultProvider } = useAIStore();
|
||||||
@ -19,6 +20,51 @@ export default function SettingsPanel() {
|
|||||||
window.localStorage.setItem(CONFIDENCE_THRESHOLD_KEY, String(clamped));
|
window.localStorage.setItem(CONFIDENCE_THRESHOLD_KEY, String(clamped));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
// Keyboard shortcuts state
|
||||||
|
const [bindings, setBindings] = useState<KeyBinding[]>(() => {
|
||||||
|
try { return loadBindings(); } catch { return DEFAULT_PRESETS['standard']; }
|
||||||
|
});
|
||||||
|
const [editingKey, setEditingKey] = useState<string | null>(null);
|
||||||
|
const [editKeyValue, setEditKeyValue] = useState('');
|
||||||
|
const conflicts = detectKeyConflicts(bindings);
|
||||||
|
|
||||||
|
const persistBindings = (newB: KeyBinding[]) => {
|
||||||
|
saveBindings(newB);
|
||||||
|
setBindings(newB);
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyPresetAction = (preset: HotkeyPreset) => {
|
||||||
|
persistBindings(applyKeyPreset(preset));
|
||||||
|
};
|
||||||
|
|
||||||
|
const startKeyEdit = (idx: number) => {
|
||||||
|
setEditingKey(bindings[idx].id);
|
||||||
|
setEditKeyValue(bindings[idx].keys);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyCapture = (e: React.KeyboardEvent, idx: number) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const parts: string[] = [];
|
||||||
|
if (e.ctrlKey || e.metaKey) parts.push('Ctrl');
|
||||||
|
if (e.shiftKey) parts.push('Shift');
|
||||||
|
if (e.altKey) parts.push('Alt');
|
||||||
|
const key = e.key === ' ' ? 'Space' : e.key.length === 1 ? e.key.toUpperCase() : e.key;
|
||||||
|
if (!['Control', 'Shift', 'Alt', 'Meta'].includes(key)) parts.push(key);
|
||||||
|
if (parts.length === 0) return;
|
||||||
|
const combo = parts.join('+');
|
||||||
|
const newBindings = bindings.map((b, i) => (i === idx ? { ...b, keys: combo } : b));
|
||||||
|
setEditKeyValue(combo);
|
||||||
|
setEditingKey(null);
|
||||||
|
persistBindings(newBindings);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = (idx: number) => {
|
||||||
|
const standard = DEFAULT_PRESETS['standard'];
|
||||||
|
const existing = standard.find((b: KeyBinding) => b.id === bindings[idx].id);
|
||||||
|
if (!existing) return;
|
||||||
|
persistBindings(bindings.map((b, i) => (i === idx ? { ...existing } : b)));
|
||||||
|
};
|
||||||
|
|
||||||
const [ollamaModels, setOllamaModels] = useState<string[]>([]);
|
const [ollamaModels, setOllamaModels] = useState<string[]>([]);
|
||||||
const [loadingModels, setLoadingModels] = useState(false);
|
const [loadingModels, setLoadingModels] = useState(false);
|
||||||
|
|
||||||
@ -112,6 +158,60 @@ export default function SettingsPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Keyboard shortcuts */}
|
||||||
|
<div className="space-y-2 pt-1 border-t border-editor-border">
|
||||||
|
<h4 className="text-xs font-semibold flex items-center gap-1.5">
|
||||||
|
<Keyboard className="w-3.5 h-3.5" />
|
||||||
|
Keyboard Shortcuts
|
||||||
|
</h4>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => applyPresetAction('standard')}
|
||||||
|
className="flex-1 px-2 py-1.5 text-xs rounded bg-editor-accent/20 text-editor-accent hover:bg-editor-accent/30"
|
||||||
|
>
|
||||||
|
Standard Preset
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => applyPresetAction('left-hand')}
|
||||||
|
className="flex-1 px-2 py-1.5 text-xs rounded bg-editor-accent/20 text-editor-accent hover:bg-editor-accent/30"
|
||||||
|
>
|
||||||
|
Left-Hand Preset
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{conflicts.length > 0 && (
|
||||||
|
<div className="px-2 py-1 rounded border border-red-500/40 bg-red-500/10 text-[10px] text-red-300">
|
||||||
|
⚠️ {conflicts.join('; ')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="max-h-52 overflow-y-auto space-y-1 pr-1">
|
||||||
|
{bindings.map((b, i) => (
|
||||||
|
<div key={b.id} className="flex items-center gap-2 text-[11px]">
|
||||||
|
<span className="flex-1 truncate text-editor-text-muted">{b.label}</span>
|
||||||
|
<input
|
||||||
|
value={editingKey === b.id ? editKeyValue : b.keys}
|
||||||
|
onFocus={() => startKeyEdit(i)}
|
||||||
|
onChange={(e) => {
|
||||||
|
setEditingKey(b.id);
|
||||||
|
setEditKeyValue(e.target.value);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => handleKeyCapture(e, i)}
|
||||||
|
className="w-28 px-2 py-1 text-[10px] font-mono bg-editor-bg border border-editor-border rounded text-center focus:outline-none focus:border-editor-accent"
|
||||||
|
placeholder="Type shortcut"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => handleReset(i)}
|
||||||
|
className="text-[10px] text-editor-text-muted hover:text-editor-text px-1"
|
||||||
|
>
|
||||||
|
↺
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-[10px] text-editor-text-muted">
|
||||||
|
Press <kbd>?</kbd> anytime to view shortcuts. Changes apply immediately.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Default provider selector */}
|
{/* Default provider selector */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-xs text-editor-text-muted font-medium">Default AI Provider</label>
|
<label className="text-xs text-editor-text-muted font-medium">Default AI Provider</label>
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useRef, useEffect, useCallback, useState, useMemo } from 'react';
|
import { useRef, useEffect, useCallback, useState, useMemo } from 'react';
|
||||||
import { useEditorStore } from '../store/editorStore';
|
import { useEditorStore } from '../store/editorStore';
|
||||||
import { AlertTriangle } from 'lucide-react';
|
import { AlertTriangle } from 'lucide-react';
|
||||||
|
import { extractThumbnails } from '../lib/thumbnails';
|
||||||
|
|
||||||
const RULER_H = 20; // px reserved at top of canvas for the time ruler
|
const RULER_H = 20; // px reserved at top of canvas for the time ruler
|
||||||
const COLLAPSED_CUT_DISPLAY_SECONDS = 0.08;
|
const COLLAPSED_CUT_DISPLAY_SECONDS = 0.08;
|
||||||
@ -239,13 +240,18 @@ export default function WaveformTimeline({
|
|||||||
const videoPath = useEditorStore((s) => s.videoPath);
|
const videoPath = useEditorStore((s) => s.videoPath);
|
||||||
const backendUrl = useEditorStore((s) => s.backendUrl);
|
const backendUrl = useEditorStore((s) => s.backendUrl);
|
||||||
const duration = useEditorStore((s) => s.duration);
|
const duration = useEditorStore((s) => s.duration);
|
||||||
|
const setCurrentTime = useEditorStore((s) => s.setCurrentTime);
|
||||||
const cutRanges = useEditorStore((s) => s.cutRanges);
|
const cutRanges = useEditorStore((s) => s.cutRanges);
|
||||||
const muteRanges = useEditorStore((s) => s.muteRanges);
|
const muteRanges = useEditorStore((s) => s.muteRanges);
|
||||||
const gainRanges = useEditorStore((s) => s.gainRanges);
|
const gainRanges = useEditorStore((s) => s.gainRanges);
|
||||||
const speedRanges = useEditorStore((s) => s.speedRanges);
|
const speedRanges = useEditorStore((s) => s.speedRanges);
|
||||||
|
const timelineMarkers = useEditorStore((s) => s.timelineMarkers);
|
||||||
const markInTime = useEditorStore((s) => s.markInTime);
|
const markInTime = useEditorStore((s) => s.markInTime);
|
||||||
const markOutTime = useEditorStore((s) => s.markOutTime);
|
const markOutTime = useEditorStore((s) => s.markOutTime);
|
||||||
const setCurrentTime = useEditorStore((s) => s.setCurrentTime);
|
const [showThumbnails, setShowThumbnails] = useState(() => typeof window !== 'undefined' && localStorage.getItem('talkedit:showThumbnails') === 'true');
|
||||||
|
const [thumbnailFrames, setThumbnailFrames] = useState<Map<number, string>>(new Map());
|
||||||
|
void setShowThumbnails;
|
||||||
|
const thumbnailContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const addCutRange = useEditorStore((s) => s.addCutRange);
|
const addCutRange = useEditorStore((s) => s.addCutRange);
|
||||||
const addMuteRange = useEditorStore((s) => s.addMuteRange);
|
const addMuteRange = useEditorStore((s) => s.addMuteRange);
|
||||||
const addGainRange = useEditorStore((s) => s.addGainRange);
|
const addGainRange = useEditorStore((s) => s.addGainRange);
|
||||||
@ -606,6 +612,33 @@ export default function WaveformTimeline({
|
|||||||
if (markInTime !== null) drawMarkLine(markInTime, 'I');
|
if (markInTime !== null) drawMarkLine(markInTime, 'I');
|
||||||
if (markOutTime !== null) drawMarkLine(markOutTime, 'O');
|
if (markOutTime !== null) drawMarkLine(markOutTime, 'O');
|
||||||
|
|
||||||
|
// Draw timeline markers (colored pins)
|
||||||
|
for (const marker of timelineMarkers) {
|
||||||
|
const x = (sourceToDisplayTime(marker.time, timelineSegments, dur) - scroll) * pxPerSec;
|
||||||
|
if (x < -4 || x > width + 4) continue;
|
||||||
|
// Draw a pin triangle
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(x, waveTop - 8);
|
||||||
|
ctx.lineTo(x - 4, waveTop - 2);
|
||||||
|
ctx.lineTo(x + 4, waveTop - 2);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.fillStyle = marker.color;
|
||||||
|
ctx.fill();
|
||||||
|
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
|
||||||
|
ctx.fillStyle = marker.color;
|
||||||
|
ctx.font = '9px sans-serif';
|
||||||
|
ctx.textBaseline = 'bottom';
|
||||||
|
ctx.fillText(marker.label, Math.min(width - 50, Math.max(2, x + 5)), waveTop - 2);
|
||||||
|
ctx.textBaseline = 'alphabetic';
|
||||||
|
}
|
||||||
|
|
||||||
const mid = waveTop + waveH / 2;
|
const mid = waveTop + waveH / 2;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
ctx.strokeStyle = '#4a4d5e';
|
ctx.strokeStyle = '#4a4d5e';
|
||||||
@ -1169,6 +1202,26 @@ export default function WaveformTimeline({
|
|||||||
if (selectedZone.type === 'speed' && !showSpeedZones) setSelectedZone(null);
|
if (selectedZone.type === 'speed' && !showSpeedZones) setSelectedZone(null);
|
||||||
}, [selectedZone, showCutZones, showMuteZones, showGainZones, showSpeedZones]);
|
}, [selectedZone, showCutZones, showMuteZones, showGainZones, showSpeedZones]);
|
||||||
|
|
||||||
|
// Capture thumbnail frames when enabled
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showThumbnails) { setThumbnailFrames(new Map()); return; }
|
||||||
|
const dur = displayDuration || waveformDataRef.current?.duration || 0;
|
||||||
|
if (dur <= 0) return;
|
||||||
|
const video = document.querySelector('video') as HTMLVideoElement | null;
|
||||||
|
if (!video) return;
|
||||||
|
|
||||||
|
const interval = 10;
|
||||||
|
const times: number[] = [];
|
||||||
|
for (let t = 0; t < dur; t += interval) times.push(t);
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
extractThumbnails(video, times).then((frames) => {
|
||||||
|
if (!cancelled) setThumbnailFrames(frames);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [showThumbnails, videoUrl, displayDuration]);
|
||||||
|
|
||||||
if (!videoUrl) {
|
if (!videoUrl) {
|
||||||
return (
|
return (
|
||||||
<div className="w-full h-full flex items-center justify-center text-editor-text-muted text-xs">
|
<div className="w-full h-full flex items-center justify-center text-editor-text-muted text-xs">
|
||||||
@ -1248,6 +1301,35 @@ export default function WaveformTimeline({
|
|||||||
</pre>
|
</pre>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
<div className="flex-1 relative flex flex-col">
|
||||||
|
{showThumbnails && thumbnailFrames.size > 0 && (
|
||||||
|
<div
|
||||||
|
ref={thumbnailContainerRef}
|
||||||
|
className="h-14 shrink-0 overflow-x-auto border-b border-editor-border/60"
|
||||||
|
style={{ scrollbarWidth: 'thin' }}
|
||||||
|
>
|
||||||
|
<div className="relative h-full" style={{ width: '100%', minHeight: 0 }}>
|
||||||
|
{Array.from(thumbnailFrames.entries()).map(([time, dataUrl]) => {
|
||||||
|
const dur = displayDuration || waveformDataRef.current?.duration || 1;
|
||||||
|
const pct = (time / dur) * 100;
|
||||||
|
return (
|
||||||
|
<img
|
||||||
|
key={time}
|
||||||
|
src={dataUrl}
|
||||||
|
alt={`Thumbnail at ${time.toFixed(0)}s`}
|
||||||
|
className="absolute top-1 rounded border border-editor-border/40 object-cover cursor-pointer"
|
||||||
|
style={{ left: `${pct}%`, width: 80, height: 45, transform: 'translateX(-50%)' }}
|
||||||
|
title={`${time.toFixed(0)}s`}
|
||||||
|
onClick={() => {
|
||||||
|
const video = document.querySelector('video') as HTMLVideoElement | null;
|
||||||
|
if (video) { video.currentTime = time; setCurrentTime(time); }
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="flex-1 relative">
|
<div className="flex-1 relative">
|
||||||
<canvas ref={waveCanvasRef} className="absolute inset-0 w-full h-full" />
|
<canvas ref={waveCanvasRef} className="absolute inset-0 w-full h-full" />
|
||||||
<canvas
|
<canvas
|
||||||
@ -1259,6 +1341,7 @@ export default function WaveformTimeline({
|
|||||||
onWheel={handleWheel}
|
onWheel={handleWheel}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,5 +1,7 @@
|
|||||||
import { useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { useEditorStore } from '../store/editorStore';
|
import { useEditorStore } from '../store/editorStore';
|
||||||
|
import { loadBindings } from '../lib/keybindings';
|
||||||
|
import type { KeyBinding } from '../types/project';
|
||||||
|
|
||||||
export function useKeyboardShortcuts() {
|
export function useKeyboardShortcuts() {
|
||||||
const addCutRange = useEditorStore((s) => s.addCutRange);
|
const addCutRange = useEditorStore((s) => s.addCutRange);
|
||||||
@ -10,9 +12,13 @@ export function useKeyboardShortcuts() {
|
|||||||
const clearMarkRange = useEditorStore((s) => s.clearMarkRange);
|
const clearMarkRange = useEditorStore((s) => s.clearMarkRange);
|
||||||
const selectedWordIndices = useEditorStore((s) => s.selectedWordIndices);
|
const selectedWordIndices = useEditorStore((s) => s.selectedWordIndices);
|
||||||
const words = useEditorStore((s) => s.words);
|
const words = useEditorStore((s) => s.words);
|
||||||
|
|
||||||
const playbackRateRef = useRef(1);
|
const playbackRateRef = useRef(1);
|
||||||
|
|
||||||
|
// Read bindings fresh from localStorage on every call to avoid stale closures
|
||||||
|
const getBindings = (): KeyBinding[] => {
|
||||||
|
try { return loadBindings(); } catch { return []; }
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const getVideo = (): HTMLVideoElement | null => document.querySelector('video');
|
const getVideo = (): HTMLVideoElement | null => document.querySelector('video');
|
||||||
|
|
||||||
@ -22,81 +28,58 @@ export function useKeyboardShortcuts() {
|
|||||||
|
|
||||||
const video = getVideo();
|
const video = getVideo();
|
||||||
|
|
||||||
switch (true) {
|
// Build a key string from the event for matching
|
||||||
// --- Undo / Redo ---
|
const parts: string[] = [];
|
||||||
case e.key === 'z' && (e.ctrlKey || e.metaKey) && e.shiftKey: {
|
if (e.ctrlKey || e.metaKey) parts.push('Ctrl');
|
||||||
e.preventDefault();
|
if (e.shiftKey && !['Shift'].includes(e.key)) parts.push('Shift');
|
||||||
useEditorStore.temporal.getState().redo();
|
if (e.altKey) parts.push('Alt');
|
||||||
return;
|
const keyStr = e.key === ' ' ? 'Space' : e.key.length === 1 ? e.key.toUpperCase() : e.key;
|
||||||
}
|
parts.push(keyStr);
|
||||||
case e.key === 'z' && (e.ctrlKey || e.metaKey): {
|
const combo = parts.join('+');
|
||||||
|
|
||||||
|
// Look up binding — fresh read every keystroke so Settings changes take effect immediately
|
||||||
|
const currentBindings = getBindings();
|
||||||
|
const binding = currentBindings.find((b) => b.keys === combo);
|
||||||
|
if (!binding) return; // Unbound key — ignore
|
||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
|
switch (binding.id) {
|
||||||
|
case 'undo':
|
||||||
useEditorStore.temporal.getState().undo();
|
useEditorStore.temporal.getState().undo();
|
||||||
return;
|
return;
|
||||||
}
|
case 'redo':
|
||||||
|
useEditorStore.temporal.getState().redo();
|
||||||
// --- Delete / Backspace: cut selected words ---
|
return;
|
||||||
case e.key === 'Delete' || e.key === 'Backspace': {
|
case 'cut': {
|
||||||
if (selectedWordIndices.length > 0) {
|
if (selectedWordIndices.length > 0) {
|
||||||
e.preventDefault();
|
|
||||||
const sorted = [...selectedWordIndices].sort((a, b) => a - b);
|
const sorted = [...selectedWordIndices].sort((a, b) => a - b);
|
||||||
const startTime = words[sorted[0]].start;
|
addCutRange(words[sorted[0]].start, words[sorted[sorted.length - 1]].end);
|
||||||
const endTime = words[sorted[sorted.length - 1]].end;
|
|
||||||
addCutRange(startTime, endTime);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (markInTime !== null && markOutTime !== null) {
|
if (markInTime !== null && markOutTime !== null) {
|
||||||
e.preventDefault();
|
|
||||||
const start = Math.min(markInTime, markOutTime);
|
const start = Math.min(markInTime, markOutTime);
|
||||||
const end = Math.max(markInTime, markOutTime);
|
const end = Math.max(markInTime, markOutTime);
|
||||||
if (end - start >= 0.01) {
|
if (end - start >= 0.01) addCutRange(start, end);
|
||||||
addCutRange(start, end);
|
|
||||||
}
|
|
||||||
clearMarkRange();
|
clearMarkRange();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
case 'play-pause':
|
||||||
// --- Space: play / pause ---
|
if (video) { if (video.paused) video.play(); else video.pause(); }
|
||||||
case e.key === ' ' && !e.ctrlKey: {
|
|
||||||
e.preventDefault();
|
|
||||||
if (video) {
|
|
||||||
if (video.paused) video.play();
|
|
||||||
else video.pause();
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
case 'slow-down': {
|
||||||
|
|
||||||
// --- J: reverse / slow down ---
|
|
||||||
case e.key === 'j' || e.key === 'J': {
|
|
||||||
e.preventDefault();
|
|
||||||
if (video) {
|
if (video) {
|
||||||
playbackRateRef.current = Math.max(-2, playbackRateRef.current - 0.5);
|
playbackRateRef.current = Math.max(-2, playbackRateRef.current - 0.5);
|
||||||
if (playbackRateRef.current < 0) {
|
if (playbackRateRef.current < 0) video.currentTime = Math.max(0, video.currentTime - 2);
|
||||||
// HTML5 video doesn't support negative rates natively; step back
|
else { video.playbackRate = playbackRateRef.current; if (video.paused) video.play(); }
|
||||||
video.currentTime = Math.max(0, video.currentTime - 2);
|
|
||||||
} else {
|
|
||||||
video.playbackRate = playbackRateRef.current;
|
|
||||||
if (video.paused) video.play();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
case 'pause':
|
||||||
// --- K: pause ---
|
if (video) { video.pause(); playbackRateRef.current = 1; }
|
||||||
case e.key === 'k' || e.key === 'K': {
|
|
||||||
e.preventDefault();
|
|
||||||
if (video) {
|
|
||||||
video.pause();
|
|
||||||
playbackRateRef.current = 1;
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
case 'speed-up': {
|
||||||
|
|
||||||
// --- L: forward / speed up ---
|
|
||||||
case e.key === 'l' || e.key === 'L': {
|
|
||||||
e.preventDefault();
|
|
||||||
if (video) {
|
if (video) {
|
||||||
playbackRateRef.current = Math.min(4, playbackRateRef.current + 0.5);
|
playbackRateRef.current = Math.min(4, playbackRateRef.current + 0.5);
|
||||||
video.playbackRate = Math.max(0.25, playbackRateRef.current);
|
video.playbackRate = Math.max(0.25, playbackRateRef.current);
|
||||||
@ -104,60 +87,37 @@ export function useKeyboardShortcuts() {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
case 'rewind':
|
||||||
// --- Arrow Left: seek back 5s ---
|
|
||||||
case e.key === 'ArrowLeft' && !e.ctrlKey: {
|
|
||||||
e.preventDefault();
|
|
||||||
if (video) video.currentTime = Math.max(0, video.currentTime - 5);
|
if (video) video.currentTime = Math.max(0, video.currentTime - 5);
|
||||||
return;
|
return;
|
||||||
}
|
case 'forward':
|
||||||
|
|
||||||
// --- Arrow Right: seek forward 5s ---
|
|
||||||
case e.key === 'ArrowRight' && !e.ctrlKey: {
|
|
||||||
e.preventDefault();
|
|
||||||
if (video) video.currentTime = Math.min(video.duration, video.currentTime + 5);
|
if (video) video.currentTime = Math.min(video.duration, video.currentTime + 5);
|
||||||
return;
|
return;
|
||||||
}
|
case 'mark-in':
|
||||||
|
|
||||||
// --- I: mark in-point ---
|
|
||||||
case e.key === 'i' || e.key === 'I': {
|
|
||||||
e.preventDefault();
|
|
||||||
if (video) setMarkInTime(video.currentTime);
|
if (video) setMarkInTime(video.currentTime);
|
||||||
return;
|
return;
|
||||||
}
|
case 'mark-out':
|
||||||
|
|
||||||
// --- O: mark out-point ---
|
|
||||||
case e.key === 'o' || e.key === 'O': {
|
|
||||||
e.preventDefault();
|
|
||||||
if (video) setMarkOutTime(video.currentTime);
|
if (video) setMarkOutTime(video.currentTime);
|
||||||
return;
|
return;
|
||||||
}
|
case 'save': {
|
||||||
|
|
||||||
// --- Ctrl+S: save project ---
|
|
||||||
case e.key === 's' && (e.ctrlKey || e.metaKey): {
|
|
||||||
e.preventDefault();
|
|
||||||
const saveBtn = document.querySelector('[title="Save"]') as HTMLButtonElement | null;
|
const saveBtn = document.querySelector('[title="Save"]') as HTMLButtonElement | null;
|
||||||
if (saveBtn) saveBtn.click();
|
if (saveBtn) saveBtn.click();
|
||||||
else saveProject();
|
else saveProject();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
case 'export': {
|
||||||
// --- Ctrl+E: export ---
|
|
||||||
case e.key === 'e' && (e.ctrlKey || e.metaKey): {
|
|
||||||
e.preventDefault();
|
|
||||||
// Trigger export panel via DOM click
|
|
||||||
const exportBtn = document.querySelector('[title="Export"]') as HTMLButtonElement;
|
const exportBtn = document.querySelector('[title="Export"]') as HTMLButtonElement;
|
||||||
if (exportBtn) exportBtn.click();
|
if (exportBtn) exportBtn.click();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
case 'search': {
|
||||||
// --- ?: show shortcut cheatsheet ---
|
const findBtn = document.querySelector('[title="Find (Ctrl+F)"]') as HTMLButtonElement;
|
||||||
case e.key === '?' || (e.key === '/' && e.shiftKey): {
|
if (findBtn) findBtn.click();
|
||||||
e.preventDefault();
|
|
||||||
toggleCheatsheet();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
case 'help':
|
||||||
|
toggleCheatsheet(currentBindings);
|
||||||
|
return;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@ -205,7 +165,7 @@ async function saveProject() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleCheatsheet() {
|
function toggleCheatsheet(bindings: KeyBinding[]) {
|
||||||
const existing = document.getElementById('keyboard-cheatsheet');
|
const existing = document.getElementById('keyboard-cheatsheet');
|
||||||
if (existing) {
|
if (existing) {
|
||||||
existing.remove();
|
existing.remove();
|
||||||
@ -220,32 +180,17 @@ function toggleCheatsheet() {
|
|||||||
overlay.remove();
|
overlay.remove();
|
||||||
};
|
};
|
||||||
|
|
||||||
const shortcuts = [
|
const rows = bindings
|
||||||
['Space', 'Play / Pause'],
|
|
||||||
['J', 'Reverse / Slow down'],
|
|
||||||
['K', 'Pause'],
|
|
||||||
['L', 'Forward / Speed up'],
|
|
||||||
['\u2190 / \u2192', 'Seek \u00b15 seconds'],
|
|
||||||
['I / O', 'Mark in / out points'],
|
|
||||||
['Delete', 'Cut selected words'],
|
|
||||||
['Ctrl+Z', 'Undo'],
|
|
||||||
['Ctrl+Shift+Z', 'Redo'],
|
|
||||||
['Ctrl+S', 'Save project'],
|
|
||||||
['Ctrl+E', 'Export'],
|
|
||||||
['?', 'This cheatsheet'],
|
|
||||||
];
|
|
||||||
|
|
||||||
const rows = shortcuts
|
|
||||||
.map(
|
.map(
|
||||||
([key, desc]) =>
|
(b) =>
|
||||||
`<tr><td style="padding:6px 16px 6px 0;font-family:monospace;color:#818cf8;font-weight:600">${key}</td><td style="padding:6px 0;color:#e2e8f0">${desc}</td></tr>`,
|
`<tr><td style="padding:6px 16px 6px 0;font-family:monospace;color:#818cf8;font-weight:600;white-space:nowrap">${b.keys}</td><td style="padding:6px 0;color:#e2e8f0">${b.label}</td><td style="padding:6px 0 6px 12px;font-size:10px;color:#94a3b8">${b.category}</td></tr>`,
|
||||||
)
|
)
|
||||||
.join('');
|
.join('');
|
||||||
|
|
||||||
overlay.innerHTML = `<div style="background:#1a1d27;border:1px solid #2a2d3a;border-radius:12px;padding:24px 32px;max-width:400px;" onclick="event.stopPropagation()">
|
overlay.innerHTML = `<div style="background:#1a1d27;border:1px solid #2a2d3a;border-radius:12px;padding:24px 32px;max-width:450px;" onclick="event.stopPropagation()">
|
||||||
<h3 style="margin:0 0 16px;font-size:14px;font-weight:600;color:#e2e8f0">Keyboard Shortcuts</h3>
|
<h3 style="margin:0 0 16px;font-size:14px;font-weight:600;color:#e2e8f0">Keyboard Shortcuts</h3>
|
||||||
<table style="font-size:13px">${rows}</table>
|
<table style="font-size:13px">${rows}</table>
|
||||||
<p style="margin:16px 0 0;font-size:11px;color:#94a3b8;text-align:center">Press ? or click outside to close</p>
|
<p style="margin:16px 0 0;font-size:11px;color:#94a3b8;text-align:center">Customize in Settings • Press ? to close</p>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
document.body.appendChild(overlay);
|
document.body.appendChild(overlay);
|
||||||
|
|||||||
@ -12,6 +12,8 @@ import type {
|
|||||||
SilenceDetectionRange,
|
SilenceDetectionRange,
|
||||||
SilenceTrimSettings,
|
SilenceTrimSettings,
|
||||||
SilenceTrimGroup,
|
SilenceTrimGroup,
|
||||||
|
TimelineMarker,
|
||||||
|
Chapter,
|
||||||
} from '../types/project';
|
} from '../types/project';
|
||||||
|
|
||||||
interface EditorState {
|
interface EditorState {
|
||||||
@ -27,6 +29,7 @@ interface EditorState {
|
|||||||
speedRanges: SpeedRange[];
|
speedRanges: SpeedRange[];
|
||||||
globalGainDb: number;
|
globalGainDb: number;
|
||||||
silenceTrimGroups: SilenceTrimGroup[];
|
silenceTrimGroups: SilenceTrimGroup[];
|
||||||
|
timelineMarkers: TimelineMarker[];
|
||||||
transcriptionModel: string | null;
|
transcriptionModel: string | null;
|
||||||
language: string;
|
language: string;
|
||||||
|
|
||||||
@ -89,6 +92,10 @@ interface EditorActions {
|
|||||||
settings: SilenceTrimSettings;
|
settings: SilenceTrimSettings;
|
||||||
}) => { groupId: string; appliedCount: number };
|
}) => { groupId: string; appliedCount: number };
|
||||||
removeSilenceTrimGroup: (groupId: string) => void;
|
removeSilenceTrimGroup: (groupId: string) => void;
|
||||||
|
addTimelineMarker: (time: number, label?: string, color?: string) => void;
|
||||||
|
updateTimelineMarker: (id: string, updates: Partial<TimelineMarker>) => void;
|
||||||
|
removeTimelineMarker: (id: string) => void;
|
||||||
|
getChapters: () => Chapter[];
|
||||||
setTranscribing: (active: boolean, progress?: number, status?: string) => void;
|
setTranscribing: (active: boolean, progress?: number, status?: string) => void;
|
||||||
setExporting: (active: boolean, progress?: number) => void;
|
setExporting: (active: boolean, progress?: number) => void;
|
||||||
setZonePreviewPaddingSeconds: (seconds: number) => void;
|
setZonePreviewPaddingSeconds: (seconds: number) => void;
|
||||||
@ -122,6 +129,7 @@ const initialState: EditorState = {
|
|||||||
speedRanges: [],
|
speedRanges: [],
|
||||||
globalGainDb: 0,
|
globalGainDb: 0,
|
||||||
silenceTrimGroups: [],
|
silenceTrimGroups: [],
|
||||||
|
timelineMarkers: [],
|
||||||
transcriptionModel: null,
|
transcriptionModel: null,
|
||||||
language: '',
|
language: '',
|
||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
@ -182,7 +190,7 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
setTranscriptionModel: (model) => set({ transcriptionModel: model }),
|
setTranscriptionModel: (model) => set({ transcriptionModel: model }),
|
||||||
|
|
||||||
saveProject: (): ProjectFile => {
|
saveProject: (): ProjectFile => {
|
||||||
const { videoPath, words, segments, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, silenceTrimGroups, transcriptionModel, language, exportedAudioPath } = get();
|
const { videoPath, words, segments, cutRanges, muteRanges, gainRanges, speedRanges, globalGainDb, silenceTrimGroups, timelineMarkers, transcriptionModel, language, exportedAudioPath } = get();
|
||||||
if (!videoPath) throw new Error('No video loaded');
|
if (!videoPath) throw new Error('No video loaded');
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
// Strip globalStartIndex (runtime-only field) before persisting.
|
// Strip globalStartIndex (runtime-only field) before persisting.
|
||||||
@ -204,6 +212,7 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
speedRanges,
|
speedRanges,
|
||||||
globalGainDb,
|
globalGainDb,
|
||||||
silenceTrimGroups,
|
silenceTrimGroups,
|
||||||
|
timelineMarkers,
|
||||||
language,
|
language,
|
||||||
createdAt: now, // will be overwritten if we track original creation time later
|
createdAt: now, // will be overwritten if we track original creation time later
|
||||||
modifiedAt: now,
|
modifiedAt: now,
|
||||||
@ -453,6 +462,40 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
addTimelineMarker: (time, label, color) => {
|
||||||
|
const { timelineMarkers } = get();
|
||||||
|
const newMarker: TimelineMarker = {
|
||||||
|
id: `marker_${nextRangeId++}`,
|
||||||
|
time,
|
||||||
|
label: label || `Marker ${timelineMarkers.length + 1}`,
|
||||||
|
color: color || '#6366f1',
|
||||||
|
};
|
||||||
|
set({ timelineMarkers: [...timelineMarkers, newMarker].sort((a, b) => a.time - b.time) });
|
||||||
|
},
|
||||||
|
|
||||||
|
updateTimelineMarker: (id, updates) => {
|
||||||
|
const { timelineMarkers } = get();
|
||||||
|
set({
|
||||||
|
timelineMarkers: timelineMarkers
|
||||||
|
.map((m) => (m.id === id ? { ...m, ...updates } : m))
|
||||||
|
.sort((a, b) => a.time - b.time),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
removeTimelineMarker: (id) => {
|
||||||
|
const { timelineMarkers } = get();
|
||||||
|
set({ timelineMarkers: timelineMarkers.filter((m) => m.id !== id) });
|
||||||
|
},
|
||||||
|
|
||||||
|
getChapters: () => {
|
||||||
|
const { timelineMarkers } = get();
|
||||||
|
return timelineMarkers.map((m) => ({
|
||||||
|
markerId: m.id,
|
||||||
|
label: m.label,
|
||||||
|
startTime: m.time,
|
||||||
|
}));
|
||||||
|
},
|
||||||
|
|
||||||
setTranscribing: (active, progress, status) =>
|
setTranscribing: (active, progress, status) =>
|
||||||
set({
|
set({
|
||||||
isTranscribing: active,
|
isTranscribing: active,
|
||||||
@ -587,6 +630,7 @@ export const useEditorStore = create<EditorState & EditorActions>()(
|
|||||||
speedRanges: data.speedRanges || [],
|
speedRanges: data.speedRanges || [],
|
||||||
globalGainDb: typeof data.globalGainDb === 'number' ? data.globalGainDb : 0,
|
globalGainDb: typeof data.globalGainDb === 'number' ? data.globalGainDb : 0,
|
||||||
silenceTrimGroups: data.silenceTrimGroups || [],
|
silenceTrimGroups: data.silenceTrimGroups || [],
|
||||||
|
timelineMarkers: data.timelineMarkers || [],
|
||||||
transcriptionModel: data.transcriptionModel ?? null,
|
transcriptionModel: data.transcriptionModel ?? null,
|
||||||
language: data.language || '',
|
language: data.language || '',
|
||||||
exportedAudioPath: data.exportedAudioPath ?? null,
|
exportedAudioPath: data.exportedAudioPath ?? null,
|
||||||
|
|||||||
@ -72,6 +72,7 @@ export interface ProjectFile {
|
|||||||
speedRanges?: SpeedRange[];
|
speedRanges?: SpeedRange[];
|
||||||
globalGainDb?: number;
|
globalGainDb?: number;
|
||||||
silenceTrimGroups?: SilenceTrimGroup[];
|
silenceTrimGroups?: SilenceTrimGroup[];
|
||||||
|
timelineMarkers?: TimelineMarker[];
|
||||||
language: string;
|
language: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
modifiedAt: string;
|
modifiedAt: string;
|
||||||
@ -93,6 +94,28 @@ export interface ExportOptions {
|
|||||||
captionStyle?: CaptionStyle;
|
captionStyle?: CaptionStyle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TimelineMarker {
|
||||||
|
id: string;
|
||||||
|
time: number;
|
||||||
|
label: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Chapter {
|
||||||
|
markerId: string;
|
||||||
|
label: string;
|
||||||
|
startTime: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeyBinding {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
keys: string; // e.g. "Ctrl+Z"
|
||||||
|
category: string; // "transport", "edit", "file", "view"
|
||||||
|
}
|
||||||
|
|
||||||
|
export type HotkeyPreset = 'left-hand' | 'standard';
|
||||||
|
|
||||||
export interface CaptionStyle {
|
export interface CaptionStyle {
|
||||||
fontName: string;
|
fontName: string;
|
||||||
fontSize: number;
|
fontSize: number;
|
||||||
|
|||||||
@ -1 +1 @@
|
|||||||
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/AIPanel.tsx","./src/components/DevPanel.tsx","./src/components/ExportDialog.tsx","./src/components/SettingsPanel.tsx","./src/components/SilenceTrimmerPanel.tsx","./src/components/TranscriptEditor.tsx","./src/components/VideoPlayer.tsx","./src/components/VolumePanel.tsx","./src/components/WaveformTimeline.tsx","./src/components/ZoneEditor.tsx","./src/hooks/useKeyboardShortcuts.ts","./src/hooks/useVideoSync.ts","./src/lib/dev-logger.ts","./src/lib/tauri-bridge.ts","./src/store/aiStore.ts","./src/store/editorStore.test.ts","./src/store/editorStore.ts","./src/types/project.ts"],"version":"5.9.3"}
|
{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/AIPanel.tsx","./src/components/DevPanel.tsx","./src/components/ExportDialog.tsx","./src/components/MarkersPanel.tsx","./src/components/SettingsPanel.tsx","./src/components/SilenceTrimmerPanel.tsx","./src/components/TranscriptEditor.tsx","./src/components/VideoPlayer.tsx","./src/components/VolumePanel.tsx","./src/components/WaveformTimeline.tsx","./src/components/ZoneEditor.tsx","./src/hooks/useKeyboardShortcuts.ts","./src/hooks/useVideoSync.ts","./src/lib/dev-logger.ts","./src/lib/keybindings.ts","./src/lib/tauri-bridge.ts","./src/lib/thumbnails.ts","./src/store/aiStore.ts","./src/store/editorStore.test.ts","./src/store/editorStore.ts","./src/types/project.ts"],"version":"5.9.3"}
|
||||||
Reference in New Issue
Block a user