improved chapters/markers
This commit is contained in:
83
frontend/src/lib/keybindings.ts
Normal file
83
frontend/src/lib/keybindings.ts
Normal 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 || '';
|
||||
}
|
||||
81
frontend/src/lib/thumbnails.ts
Normal file
81
frontend/src/lib/thumbnails.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user