improved chapters/markers

This commit is contained in:
2026-05-05 12:29:25 -06:00
parent 21e4255325
commit cde635a660
5 changed files with 410 additions and 20 deletions

View File

@ -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<HotkeyPreset, KeyBinding[]> = {
'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<string, string>();
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 || '';
}

View File

@ -0,0 +1,81 @@
/**
* Frontend-side video thumbnail extraction.
* Captures frames from the <video> element using canvas.
*/
const THUMBNAIL_CACHE = new Map<string, string>();
export function extractThumbnail(video: HTMLVideoElement, time: number, width = 160, height = 90): string | null {
const cacheKey = `${video.src}_${time.toFixed(3)}_${width}x${height}`;
const cached = THUMBNAIL_CACHE.get(cacheKey);
if (cached) return cached;
// Seek to the time, wait for seeked, then capture
// Since this is synchronous, we use the current ready frame
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
// Try to draw the current frame at the requested time
const originalTime = video.currentTime;
video.currentTime = time;
// We can't synchronously wait for seek, so catch the 'seeked' event externally
// For now, draw whatever video frame is available
ctx.drawImage(video, 0, 0, width, height);
// Return to original time (best-effort)
video.currentTime = originalTime;
const dataUrl = canvas.toDataURL('image/jpeg', 0.6);
THUMBNAIL_CACHE.set(cacheKey, dataUrl);
return dataUrl;
}
export async function extractThumbnails(
video: HTMLVideoElement,
times: number[],
width = 160,
height = 90,
): Promise<Map<number, string>> {
const results = new Map<number, string>();
const originalTime = video.currentTime;
for (const time of times) {
const cacheKey = `${video.src}_${time.toFixed(3)}_${width}x${height}`;
const cached = THUMBNAIL_CACHE.get(cacheKey);
if (cached) {
results.set(time, cached);
continue;
}
// Seek and wait for the frame to be available
video.currentTime = time;
await new Promise<void>((resolve) => {
const handler = () => {
video.removeEventListener('seeked', handler);
resolve();
};
video.addEventListener('seeked', handler);
// Fallback: resolve after a short timeout
setTimeout(resolve, 500);
});
const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
if (ctx) {
ctx.drawImage(video, 0, 0, width, height);
const dataUrl = canvas.toDataURL('image/jpeg', 0.5);
THUMBNAIL_CACHE.set(cacheKey, dataUrl);
results.set(time, dataUrl);
}
}
// Restore original position
video.currentTime = originalTime;
return results;
}