improved chapters/markers
This commit is contained in:
@ -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<Panel>(null);
|
||||
const [projectName, setProjectName] = useState<string | null>(null);
|
||||
const [splitRatio, setSplitRatio] = useState(() => {
|
||||
try { return Number(localStorage.getItem('talkedit:splitRatio')) || 0.5; } catch { return 0.5; }
|
||||
});
|
||||
const splitRef = useRef<HTMLDivElement>(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() {
|
||||
</header>
|
||||
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
<div className="main-content flex-1 flex overflow-hidden">
|
||||
{/* Left: video + transcript */}
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<div className="flex-1 flex min-h-0">
|
||||
<div ref={splitRef} className="flex-1 flex min-h-0" style={{ position: 'relative' }}>
|
||||
{/* Video player */}
|
||||
<div className="w-1/2 p-3 flex items-center justify-center bg-black/20">
|
||||
<div className="p-3 flex items-center justify-center bg-black/20 overflow-hidden" style={{ width: `${splitRatio * 100}%`, minWidth: 0 }}>
|
||||
<VideoPlayer />
|
||||
</div>
|
||||
|
||||
{/* Draggable divider */}
|
||||
<div
|
||||
className="w-1 shrink-0 bg-editor-border cursor-col-resize hover:bg-editor-accent/50 active:bg-editor-accent transition-colors relative z-10"
|
||||
style={{ cursor: isDraggingSplit.current ? 'col-resize' : 'col-resize' }}
|
||||
onMouseDown={startSplitDrag}
|
||||
/>
|
||||
|
||||
{/* Transcript */}
|
||||
<div className="w-1/2 border-l border-editor-border flex flex-col min-h-0">
|
||||
<div className="border-l border-editor-border flex flex-col min-h-0" style={{ width: `${(1 - splitRatio) * 100}%`, minWidth: 0 }}>
|
||||
{videoPath && (
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-editor-border shrink-0 bg-editor-surface/50">
|
||||
{projectName && (
|
||||
@ -745,7 +800,13 @@ export default function App() {
|
||||
|
||||
{/* Right panel (AI / Export / Settings) */}
|
||||
{activePanel && (
|
||||
<div className="w-80 border-l border-editor-border overflow-y-auto shrink-0">
|
||||
<div className="flex shrink-0">
|
||||
{/* Draggable sidebar divider */}
|
||||
<div
|
||||
className="w-1 shrink-0 bg-editor-border cursor-col-resize hover:bg-editor-accent/50 active:bg-editor-accent transition-colors relative z-10"
|
||||
onMouseDown={startSidebarDrag}
|
||||
/>
|
||||
<div className="overflow-y-auto" style={{ width: sidebarWidth }}>
|
||||
{activePanel === 'zones' && (
|
||||
<ZoneEditor />
|
||||
)}
|
||||
@ -755,7 +816,8 @@ export default function App() {
|
||||
{activePanel === 'export' && <ExportDialog />}
|
||||
{activePanel === 'settings' && <SettingsPanel />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{import.meta.env.DEV && <DevPanel />}
|
||||
|
||||
|
||||
151
frontend/src/components/MarkersPanel.tsx
Normal file
151
frontend/src/components/MarkersPanel.tsx
Normal file
@ -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<string | null>(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 (
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-1.5">
|
||||
<MapPin className="w-4 h-4" />
|
||||
Timeline Markers
|
||||
</h3>
|
||||
<p className="text-xs text-editor-text-muted">
|
||||
Drop markers at key points. Markers become YouTube-compatible chapters.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Add marker at current time */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
value={newLabel}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<div className="flex gap-0.5">
|
||||
{COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
onClick={() => setNewColor(c)}
|
||||
className={`w-4 h-4 rounded-full border ${newColor === c ? 'border-white ring-1 ring-white' : 'border-transparent'}`}
|
||||
style={{ backgroundColor: c }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={addAtCurrentTime}
|
||||
className="w-full flex items-center justify-center gap-1 px-2 py-1.5 text-xs bg-editor-accent/20 text-editor-accent hover:bg-editor-accent/30 rounded"
|
||||
>
|
||||
<MapPin className="w-3 h-3" />
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Marker list */}
|
||||
{timelineMarkers.length > 0 && (
|
||||
<div className="space-y-1 max-h-60 overflow-y-auto">
|
||||
{timelineMarkers.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="flex items-center gap-2 px-2 py-1.5 rounded bg-editor-surface border border-editor-border text-xs"
|
||||
>
|
||||
<div className="w-2.5 h-2.5 rounded-full shrink-0" style={{ backgroundColor: m.color }} />
|
||||
<span className="text-[10px] text-editor-text-muted w-14 shrink-0">{m.time.toFixed(2)}s</span>
|
||||
{editingId === m.id ? (
|
||||
<>
|
||||
<input
|
||||
value={editLabel}
|
||||
onChange={(e) => 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"
|
||||
/>
|
||||
<button onClick={() => commitEdit(m.id)} className="p-0.5 text-editor-success"><Check className="w-3 h-3" /></button>
|
||||
<button onClick={() => setEditingId(null)} className="p-0.5 text-editor-text-muted"><X className="w-3 h-3" /></button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<span className="flex-1 truncate">{m.label}</span>
|
||||
<button onClick={() => startEdit(m.id, m.label)} className="p-0.5 hover:text-editor-accent"><PencilLine className="w-3 h-3" /></button>
|
||||
<button onClick={() => removeTimelineMarker(m.id)} className="p-0.5 hover:text-editor-danger"><Trash2 className="w-3 h-3" /></button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Chapters */}
|
||||
{chapters.length > 0 && (
|
||||
<div className="space-y-2 pt-1 border-t border-editor-border">
|
||||
<button
|
||||
onClick={() => setShowChapters(!showChapters)}
|
||||
className="flex items-center gap-1 text-xs font-medium text-editor-text-muted hover:text-editor-text"
|
||||
>
|
||||
{showChapters ? '▼' : '▶'} Chapters ({chapters.length})
|
||||
</button>
|
||||
{showChapters && (
|
||||
<div className="space-y-1">
|
||||
{chapters.map((ch) => (
|
||||
<div key={ch.markerId} className="flex items-center gap-2 text-[10px] text-editor-text-muted">
|
||||
<span className="font-mono">{ch.label}</span>
|
||||
</div>
|
||||
))}
|
||||
<button
|
||||
onClick={exportChapters}
|
||||
className="flex items-center gap-1 text-[10px] text-editor-accent hover:underline"
|
||||
>
|
||||
<Copy className="w-2.5 h-2.5" />
|
||||
Copy as YouTube timestamps
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -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';
|
||||
|
||||
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