chore: stage all pending work — caption styling, media server, processing modal, docs, summaries
Includes: - Extended caption styling (font, shadow, dimmed color, bg toggle) - Media server, subtitle downloader, VTT parser, processing modal - Waveform tiers, thumbnail/timeline improvements, transport controls - Hybrid download model, dependency management, clip export enhancements - 21 chat summaries, 2 implementation plans, 2 design specs Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
import SetupWizard from '$lib/components/SetupWizard.svelte';
|
||||
import PreferencesPanel from '$lib/components/PreferencesPanel.svelte';
|
||||
import ExportDialog from '$lib/components/ExportDialog.svelte';
|
||||
import ProcessingModal from '$lib/components/ProcessingModal.svelte';
|
||||
import { loadPreferences } from '$lib/stores/preferences.svelte';
|
||||
import { getSelectedClipId, removeClip } from '$lib/stores/clips.svelte';
|
||||
import { adjustShuttle, resetShuttleRate } from '$lib/transport/playback';
|
||||
@@ -21,6 +22,13 @@
|
||||
|
||||
let transportControls = $state<TransportControls | null>(null);
|
||||
|
||||
// Resizable split: timeline height in px (clip list gets the rest)
|
||||
let timelineHeight = $state(180);
|
||||
let isResizing = $state(false);
|
||||
let resizeStartY = $state(0);
|
||||
let resizeStartHeight = $state(0);
|
||||
let splitAreaEl = $state<HTMLDivElement | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
loadPreferences();
|
||||
});
|
||||
@@ -36,6 +44,25 @@
|
||||
transportControls?.handleKeyAction(action);
|
||||
}
|
||||
|
||||
function handleResizeStart(e: MouseEvent) {
|
||||
e.preventDefault();
|
||||
isResizing = true;
|
||||
resizeStartY = e.clientY;
|
||||
resizeStartHeight = timelineHeight;
|
||||
}
|
||||
|
||||
function handleResizeMove(e: MouseEvent) {
|
||||
if (!isResizing || !splitAreaEl) return;
|
||||
const delta = e.clientY - resizeStartY;
|
||||
const totalHeight = splitAreaEl.clientHeight;
|
||||
const newHeight = Math.max(40, Math.min(totalHeight - 20, resizeStartHeight + delta));
|
||||
timelineHeight = newHeight;
|
||||
}
|
||||
|
||||
function handleResizeEnd() {
|
||||
isResizing = false;
|
||||
}
|
||||
|
||||
function handleGlobalKeydown(e: KeyboardEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') return;
|
||||
@@ -110,7 +137,11 @@
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleGlobalKeydown} />
|
||||
<svelte:window
|
||||
onkeydown={handleGlobalKeydown}
|
||||
onmousemove={handleResizeMove}
|
||||
onmouseup={handleResizeEnd}
|
||||
/>
|
||||
|
||||
{#if showSetupWizard}
|
||||
<SetupWizard onComplete={() => (showSetupWizard = false)} />
|
||||
@@ -124,6 +155,10 @@
|
||||
<ExportDialog onClose={() => (showExportDialog = false)} />
|
||||
{/if}
|
||||
|
||||
{#if session.processingStep !== 'idle' && session.processingStep !== 'done'}
|
||||
<ProcessingModal />
|
||||
{/if}
|
||||
|
||||
<div class="app-shell">
|
||||
<header class="toolbar">
|
||||
<UrlInput />
|
||||
@@ -133,8 +168,21 @@
|
||||
<main class="content">
|
||||
<VideoPlayer />
|
||||
<TransportControls bind:this={transportControls} />
|
||||
<Timeline />
|
||||
<ClipList onExport={() => (showExportDialog = true)} />
|
||||
<div class="timeline-clip-area" bind:this={splitAreaEl}>
|
||||
<div class="timeline-pane" style="height: {timelineHeight}px">
|
||||
<Timeline />
|
||||
</div>
|
||||
<div
|
||||
class="resize-handle"
|
||||
class:active={isResizing}
|
||||
role="separator"
|
||||
aria-orientation="horizontal"
|
||||
onmousedown={handleResizeStart}
|
||||
></div>
|
||||
<div class="cliplist-pane">
|
||||
<ClipList onExport={() => (showExportDialog = true)} />
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<StatusBar />
|
||||
@@ -163,6 +211,38 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.timeline-clip-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.timeline-pane {
|
||||
flex-shrink: 0;
|
||||
min-height: 40px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.resize-handle {
|
||||
height: 5px;
|
||||
background: var(--border, #353550);
|
||||
cursor: ns-resize;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.resize-handle:hover,
|
||||
.resize-handle.active {
|
||||
background: var(--accent, #89b4fa);
|
||||
}
|
||||
|
||||
.cliplist-pane {
|
||||
flex: 1;
|
||||
min-height: 20px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.prefs-btn {
|
||||
font-size: 18px;
|
||||
padding: 4px 8px;
|
||||
|
||||
@@ -27,6 +27,10 @@ export async function checkDependencies(
|
||||
return invoke<DependencyStatus[]>('check_dependencies', { cookieSource });
|
||||
}
|
||||
|
||||
export async function checkSubtitlesFilterAvailable(): Promise<boolean> {
|
||||
return invoke<boolean>('check_subtitles_filter_available');
|
||||
}
|
||||
|
||||
export async function installDependency(
|
||||
name: string,
|
||||
onOutput: (line: string) => void,
|
||||
|
||||
@@ -8,6 +8,8 @@ export interface VideoMetadata {
|
||||
fps: number;
|
||||
thumbnailUrl: string | null;
|
||||
streamUrl: string;
|
||||
hasCaptions: boolean;
|
||||
captionsAreAuto: boolean;
|
||||
}
|
||||
|
||||
type DownloadEvent =
|
||||
@@ -16,6 +18,17 @@ type DownloadEvent =
|
||||
| { event: 'finished'; data: { success: boolean; path: string } }
|
||||
| { event: 'error'; data: { message: string } };
|
||||
|
||||
export async function getMediaServerPort(): Promise<number> {
|
||||
return invoke<number>('get_media_server_port');
|
||||
}
|
||||
|
||||
export async function checkCachedDownload(
|
||||
title: string,
|
||||
variant: string
|
||||
): Promise<string | null> {
|
||||
return invoke<string | null>('check_cached_download', { title, variant });
|
||||
}
|
||||
|
||||
export async function resolveUrl(url: string): Promise<VideoMetadata> {
|
||||
return invoke<VideoMetadata>('resolve_url', {
|
||||
url,
|
||||
@@ -23,8 +36,21 @@ export async function resolveUrl(url: string): Promise<VideoMetadata> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function downloadSubtitles(
|
||||
url: string,
|
||||
isAuto: boolean
|
||||
): Promise<string> {
|
||||
return invoke<string>('download_subtitles', {
|
||||
url,
|
||||
cookieSource: preferences.cookieSource,
|
||||
isAuto,
|
||||
});
|
||||
}
|
||||
|
||||
export async function startDownload(
|
||||
url: string,
|
||||
formatSpec: string,
|
||||
variant: string,
|
||||
onProgress: (percent: number) => void,
|
||||
onFilePath: (path: string) => void,
|
||||
onFinished: (success: boolean, path: string) => void,
|
||||
@@ -54,6 +80,8 @@ export async function startDownload(
|
||||
await invoke('start_download', {
|
||||
url,
|
||||
cookieSource: preferences.cookieSource,
|
||||
formatSpec,
|
||||
variant,
|
||||
onEvent,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -33,9 +33,17 @@
|
||||
function handleLabelEdit(clipId: string, value: string) {
|
||||
updateClip(clipId, { label: value });
|
||||
}
|
||||
|
||||
function handleContainerClick(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
// Only deselect if the click landed on the container itself, not a child
|
||||
if (target.classList.contains('clip-list')) {
|
||||
selectClip(null);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="clip-list">
|
||||
<div class="clip-list" role="listbox" onclick={handleContainerClick}>
|
||||
{#if clips.length === 0}
|
||||
<div class="empty">No clips yet — press I to mark in-point, O to mark out-point</div>
|
||||
{:else}
|
||||
@@ -46,7 +54,8 @@
|
||||
<div
|
||||
class="clip-row"
|
||||
class:selected={clip.id === selectedId}
|
||||
role="button"
|
||||
role="option"
|
||||
aria-selected={clip.id === selectedId}
|
||||
tabindex="0"
|
||||
onclick={() => handleSelect(clip.id)}
|
||||
onkeydown={(e) => {
|
||||
@@ -91,11 +100,14 @@
|
||||
>✕</button>
|
||||
</div>
|
||||
{/each}
|
||||
{#if onExport}
|
||||
<div class="export-buttons">
|
||||
<div class="action-buttons">
|
||||
{#if selectedId}
|
||||
<button type="button" class="deselect-btn" onclick={() => selectClip(null)}>Deselect</button>
|
||||
{/if}
|
||||
{#if onExport}
|
||||
<button type="button" onclick={() => onExport()}>Export All</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -103,10 +115,11 @@
|
||||
.clip-list {
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-secondary);
|
||||
min-height: 60px;
|
||||
max-height: 200px;
|
||||
height: 100%;
|
||||
min-height: 40px;
|
||||
overflow-y: auto;
|
||||
border-top: 1px solid var(--border);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.empty {
|
||||
@@ -123,17 +136,24 @@
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.export-buttons {
|
||||
.action-buttons {
|
||||
margin-top: 8px;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.export-buttons button {
|
||||
.action-buttons button {
|
||||
font-size: 13px;
|
||||
padding: 6px 12px;
|
||||
}
|
||||
|
||||
.deselect-btn {
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-tertiary);
|
||||
}
|
||||
|
||||
.clip-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
183
src/lib/components/ProcessingModal.svelte
Normal file
183
src/lib/components/ProcessingModal.svelte
Normal file
@@ -0,0 +1,183 @@
|
||||
<script lang="ts">
|
||||
import { session, type ProcessingStep } from '$lib/stores/videoSession.svelte';
|
||||
|
||||
const STEP_LABELS: Record<ProcessingStep, string> = {
|
||||
idle: '',
|
||||
downloading: 'Downloading preview…',
|
||||
waveform: 'Generating waveform…',
|
||||
keyframes: 'Extracting keyframes…',
|
||||
thumbnails: 'Generating thumbnails…',
|
||||
captions: 'Downloading captions…',
|
||||
done: 'Ready!',
|
||||
};
|
||||
|
||||
const STEP_ORDER: ProcessingStep[] = [
|
||||
'downloading',
|
||||
'waveform',
|
||||
'keyframes',
|
||||
'thumbnails',
|
||||
'captions',
|
||||
'done',
|
||||
];
|
||||
|
||||
let currentStepIndex = $derived(STEP_ORDER.indexOf(session.processingStep));
|
||||
|
||||
// Pick progress source based on current step
|
||||
let stepProgress = $derived(() => {
|
||||
switch (session.processingStep) {
|
||||
case 'downloading':
|
||||
return session.previewProgress;
|
||||
case 'waveform':
|
||||
case 'keyframes':
|
||||
case 'thumbnails':
|
||||
case 'captions':
|
||||
return session.processingProgress;
|
||||
case 'done':
|
||||
return 1;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
let progressPercent = $derived(Math.round(stepProgress() * 100));
|
||||
let showProgressBar = $derived(
|
||||
session.processingStep !== 'idle' && session.processingStep !== 'done'
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="modal-overlay">
|
||||
<div class="modal">
|
||||
<h2>Processing Video</h2>
|
||||
<p class="title">{session.title}</p>
|
||||
|
||||
<div class="steps">
|
||||
{#each STEP_ORDER as step, i}
|
||||
{@const stepLabel = STEP_LABELS[step]}
|
||||
{@const isDone = i < currentStepIndex}
|
||||
{@const isCurrent = step === session.processingStep}
|
||||
<div class="step" class:done={isDone} class:current={isCurrent}>
|
||||
<span class="icon">
|
||||
{#if isDone}
|
||||
✓
|
||||
{:else if isCurrent}
|
||||
⏳
|
||||
{:else}
|
||||
○
|
||||
{/if}
|
||||
</span>
|
||||
<span class="label">{stepLabel}</span>
|
||||
{#if isCurrent && progressPercent > 0}
|
||||
<span class="step-pct">{progressPercent}%</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if showProgressBar}
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: {progressPercent}%"></div>
|
||||
</div>
|
||||
<p class="progress-text">{progressPercent}%</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg-primary, #1e1e2e);
|
||||
border: 1px solid var(--border, #353550);
|
||||
border-radius: 12px;
|
||||
padding: 32px 40px;
|
||||
min-width: 360px;
|
||||
max-width: 480px;
|
||||
text-align: center;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 4px;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #cdd6f4);
|
||||
}
|
||||
|
||||
.title {
|
||||
margin: 0 0 24px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary, #6c7086);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.steps {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
text-align: left;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary, #6c7086);
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.step.done {
|
||||
color: #a6e3a1;
|
||||
}
|
||||
|
||||
.step.current {
|
||||
color: var(--text-primary, #cdd6f4);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 18px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.step-pct {
|
||||
margin-left: auto;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #6c7086);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 6px;
|
||||
background: var(--bg-tertiary, #2a2a3e);
|
||||
border-radius: 3px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: #89b4fa;
|
||||
border-radius: 3px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.progress-text {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary, #6c7086);
|
||||
}
|
||||
</style>
|
||||
@@ -1,32 +1,43 @@
|
||||
<script lang="ts">
|
||||
import { session } from '$lib/stores/videoSession.svelte';
|
||||
import { session, upgradePreview } from '$lib/stores/videoSession.svelte';
|
||||
|
||||
let progressPercent = $derived(Math.round(session.downloadProgress * 100));
|
||||
let isDownloading = $derived(
|
||||
let previewPercent = $derived(Math.round(session.previewProgress * 100));
|
||||
let exportPercent = $derived(Math.round(session.exportProgress * 100));
|
||||
|
||||
let isPreviewDownloading = $derived(
|
||||
session.status === 'ready' &&
|
||||
session.downloadStatus === 'downloading' &&
|
||||
session.downloadProgress > 0 &&
|
||||
session.downloadProgress < 1
|
||||
);
|
||||
let isDownloaded = $derived(
|
||||
session.localFilePath !== null &&
|
||||
(session.downloadStatus === 'complete' || session.downloadProgress >= 1)
|
||||
session.previewStatus === 'downloading' &&
|
||||
session.previewProgress > 0 &&
|
||||
session.previewProgress < 1
|
||||
);
|
||||
|
||||
let isPreviewComplete = $derived(session.previewStatus === 'complete');
|
||||
let isExportDownloading = $derived(session.exportStatus === 'downloading');
|
||||
let isExportComplete = $derived(session.exportStatus === 'complete');
|
||||
</script>
|
||||
|
||||
<div class="status-bar">
|
||||
{#if isDownloading}
|
||||
{#if isPreviewDownloading}
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: {progressPercent}%"></div>
|
||||
<div class="progress-fill" style="width: {previewPercent}%"></div>
|
||||
</div>
|
||||
<span class="status-text">Downloading: {progressPercent}%</span>
|
||||
{:else if isDownloaded}
|
||||
<span class="status-text success">✓ Downloaded: {session.title}</span>
|
||||
<span class="status-text">Preview: {previewPercent}%</span>
|
||||
{:else if isPreviewComplete && isExportDownloading}
|
||||
<span class="status-text success">✓ Preview ready</span>
|
||||
<div class="progress-bar">
|
||||
<div class="progress-fill" style="width: {exportPercent}%"></div>
|
||||
</div>
|
||||
<span class="status-text">Best quality: {exportPercent}%</span>
|
||||
{:else if isPreviewComplete && isExportComplete}
|
||||
<span class="status-text success">✓ {session.title} — ready to export</span>
|
||||
{#if session.showUpgradeToast}
|
||||
<button class="upgrade-link" onclick={upgradePreview}>Reload Preview (HQ)</button>
|
||||
{/if}
|
||||
{:else if session.status === 'resolving'}
|
||||
<span class="status-text">Resolving URL…</span>
|
||||
{:else if session.status === 'ready' && session.downloadStatus === 'downloading'}
|
||||
<span class="status-text">Starting download…</span>
|
||||
{:else if session.downloadStatus === 'failed'}
|
||||
{:else if session.status === 'ready' && session.previewStatus === 'idle'}
|
||||
<span class="status-text">Preparing download…</span>
|
||||
{:else if session.previewStatus === 'failed' || session.exportStatus === 'failed'}
|
||||
<span class="status-text">Download failed</span>
|
||||
{:else}
|
||||
<span class="status-text">Ready</span>
|
||||
@@ -64,4 +75,20 @@
|
||||
.success {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.upgrade-link {
|
||||
background: none;
|
||||
border: 1px solid var(--accent, #89b4fa);
|
||||
color: var(--accent, #89b4fa);
|
||||
font-size: 11px;
|
||||
padding: 2px 8px;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.upgrade-link:hover {
|
||||
background: var(--accent, #89b4fa);
|
||||
color: var(--bg-primary, #1e1e2e);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -8,15 +8,20 @@
|
||||
import { drawTimeline, type TimelineState } from '$lib/timeline/renderer';
|
||||
import {
|
||||
computeZoom,
|
||||
panBy,
|
||||
handleClick as computeClickTime,
|
||||
} from '$lib/timeline/interactions';
|
||||
import { hitTestClip } from '$lib/timeline/clipRenderer';
|
||||
import { drawWaveform, type WaveformData } from '$lib/timeline/waveformRenderer';
|
||||
import { setThumbnailLoadCallback } from '$lib/timeline/thumbnailRenderer';
|
||||
|
||||
type ClipDragTarget = { clipId: string; edge: 'start' | 'end' };
|
||||
|
||||
let canvas = $state<HTMLCanvasElement | null>(null);
|
||||
let minimapCanvas = $state<HTMLCanvasElement | null>(null);
|
||||
let containerEl = $state<HTMLDivElement | null>(null);
|
||||
let animFrameId = $state(0);
|
||||
let pendingDraw = 0;
|
||||
let pendingMinimapDraw = 0;
|
||||
|
||||
let timelineState = $state<TimelineState>({
|
||||
visibleStart: 0,
|
||||
@@ -28,6 +33,139 @@
|
||||
|
||||
let isDragging = $state(false);
|
||||
let dragTarget = $state<ClipDragTarget | null>(null);
|
||||
let isMinimapDragging = $state(false);
|
||||
let minimapDragStartX = $state(0);
|
||||
let minimapDragStartVisibleStart = $state(0);
|
||||
|
||||
let isZoomed = $derived(timelineState.zoom > 1.01);
|
||||
|
||||
// Construct waveform data from pre-computed tiers — pure reactive, no IPC
|
||||
let waveformData = $derived<WaveformData>({
|
||||
tiers: session.waveformTiers,
|
||||
});
|
||||
|
||||
// Register thumbnail load callback
|
||||
$effect(() => {
|
||||
setThumbnailLoadCallback(() => requestDraw());
|
||||
return () => setThumbnailLoadCallback(null);
|
||||
});
|
||||
|
||||
function requestDraw() {
|
||||
if (!pendingDraw) {
|
||||
pendingDraw = requestAnimationFrame(() => {
|
||||
drawMainCanvas();
|
||||
pendingDraw = 0;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function requestMinimapDraw() {
|
||||
if (!pendingMinimapDraw) {
|
||||
pendingMinimapDraw = requestAnimationFrame(() => {
|
||||
drawMinimapCanvas();
|
||||
pendingMinimapDraw = 0;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function drawMainCanvas() {
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
ctx.save();
|
||||
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
|
||||
drawTimeline(
|
||||
ctx,
|
||||
timelineState,
|
||||
session.currentTime,
|
||||
session.duration,
|
||||
clipStore.clips,
|
||||
clipStore.selectedClipId,
|
||||
clipStore.pendingInPoint,
|
||||
waveformData,
|
||||
session.thumbnailSpritesheets
|
||||
);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawMinimapCanvas() {
|
||||
if (!minimapCanvas || !isZoomed || session.duration <= 0) return;
|
||||
const ctx = minimapCanvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
const w = minimapCanvas.width / window.devicePixelRatio;
|
||||
const h = minimapCanvas.height / window.devicePixelRatio;
|
||||
|
||||
ctx.save();
|
||||
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
ctx.fillStyle = '#12121e';
|
||||
ctx.fillRect(0, 0, w, h);
|
||||
|
||||
// Minimap always shows the overview tier at full duration
|
||||
if (session.waveformTiers) {
|
||||
const fullState: TimelineState = {
|
||||
visibleStart: 0,
|
||||
visibleEnd: session.duration,
|
||||
zoom: 1,
|
||||
width: w,
|
||||
height: h,
|
||||
};
|
||||
const minimapWaveform: WaveformData = {
|
||||
tiers: session.waveformTiers,
|
||||
};
|
||||
drawWaveform(ctx, fullState, minimapWaveform, session.duration, 0, h);
|
||||
}
|
||||
|
||||
const vpLeft = (timelineState.visibleStart / session.duration) * w;
|
||||
const vpRight = (timelineState.visibleEnd / session.duration) * w;
|
||||
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.5)';
|
||||
ctx.fillRect(0, 0, vpLeft, h);
|
||||
ctx.fillRect(vpRight, 0, w - vpRight, h);
|
||||
|
||||
ctx.strokeStyle = '#89b4fa';
|
||||
ctx.lineWidth = 1.5;
|
||||
ctx.strokeRect(vpLeft, 0, vpRight - vpLeft, h);
|
||||
|
||||
const phX = (session.currentTime / session.duration) * w;
|
||||
ctx.strokeStyle = '#ffffff';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(phX, 0);
|
||||
ctx.lineTo(phX, h);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Single unified redraw effect — coalesced by requestAnimationFrame
|
||||
$effect(() => {
|
||||
void session.currentTime;
|
||||
void session.duration;
|
||||
void session.waveformTiers;
|
||||
void session.thumbnailSpritesheets;
|
||||
void clipStore.clips;
|
||||
void clipStore.selectedClipId;
|
||||
void clipStore.pendingInPoint;
|
||||
void timelineState.visibleStart;
|
||||
void timelineState.visibleEnd;
|
||||
void timelineState.width;
|
||||
void timelineState.height;
|
||||
void canvas;
|
||||
requestDraw();
|
||||
});
|
||||
|
||||
// Minimap redraws
|
||||
$effect(() => {
|
||||
void session.currentTime;
|
||||
void session.waveformTiers;
|
||||
void timelineState.visibleStart;
|
||||
void timelineState.visibleEnd;
|
||||
void isZoomed;
|
||||
void minimapCanvas;
|
||||
requestMinimapDraw();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (session.duration > 0) {
|
||||
@@ -56,31 +194,21 @@
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
function draw() {
|
||||
if (!ctx || !canvas) return;
|
||||
ctx.save();
|
||||
ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
|
||||
drawTimeline(
|
||||
ctx,
|
||||
timelineState,
|
||||
session.currentTime,
|
||||
session.duration,
|
||||
clipStore.clips,
|
||||
clipStore.selectedClipId,
|
||||
clipStore.pendingInPoint,
|
||||
session.waveformPeaks,
|
||||
session.thumbnailSpritesheets
|
||||
);
|
||||
ctx.restore();
|
||||
animFrameId = requestAnimationFrame(draw);
|
||||
}
|
||||
|
||||
draw();
|
||||
return () => cancelAnimationFrame(animFrameId);
|
||||
if (!minimapCanvas || !containerEl) return;
|
||||
const observer = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (minimapCanvas) {
|
||||
const w = entry.contentRect.width;
|
||||
const h = 28;
|
||||
minimapCanvas.width = w * window.devicePixelRatio;
|
||||
minimapCanvas.height = h * window.devicePixelRatio;
|
||||
minimapCanvas.style.width = `${w}px`;
|
||||
minimapCanvas.style.height = `${h}px`;
|
||||
}
|
||||
}
|
||||
});
|
||||
observer.observe(containerEl);
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
function getCanvasX(e: MouseEvent): number {
|
||||
@@ -119,6 +247,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
selectClip(null);
|
||||
isDragging = true;
|
||||
const targetTime = computeClickTime(x, timelineState, session.duration);
|
||||
seekTo(targetTime);
|
||||
@@ -156,6 +285,14 @@
|
||||
|
||||
function handleWheel(e: WheelEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
if (e.deltaX !== 0) {
|
||||
const result = panBy(timelineState, e.deltaX, session.duration);
|
||||
timelineState.visibleStart = result.visibleStart;
|
||||
timelineState.visibleEnd = result.visibleEnd;
|
||||
return;
|
||||
}
|
||||
|
||||
const x = getCanvasX(e);
|
||||
const anchorTime = computeClickTime(x, timelineState, session.duration);
|
||||
const factor = e.deltaY < 0 ? 1.15 : 0.87;
|
||||
@@ -164,22 +301,121 @@
|
||||
timelineState.visibleEnd = result.visibleEnd;
|
||||
timelineState.zoom = result.zoom;
|
||||
}
|
||||
|
||||
function handleMinimapMouseDown(e: MouseEvent) {
|
||||
if (!minimapCanvas || session.duration <= 0) return;
|
||||
const rect = minimapCanvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
isMinimapDragging = true;
|
||||
minimapDragStartX = x;
|
||||
minimapDragStartVisibleStart = timelineState.visibleStart;
|
||||
}
|
||||
|
||||
function handleMinimapMouseMove(e: MouseEvent) {
|
||||
if (!isMinimapDragging || !minimapCanvas || session.duration <= 0) return;
|
||||
const rect = minimapCanvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const deltaX = x - minimapDragStartX;
|
||||
const deltaTime = (deltaX / rect.width) * session.duration;
|
||||
const range = timelineState.visibleEnd - timelineState.visibleStart;
|
||||
|
||||
let newStart = minimapDragStartVisibleStart + deltaTime;
|
||||
let newEnd = newStart + range;
|
||||
|
||||
if (newStart < 0) {
|
||||
newStart = 0;
|
||||
newEnd = range;
|
||||
}
|
||||
if (newEnd > session.duration) {
|
||||
newEnd = session.duration;
|
||||
newStart = Math.max(0, newEnd - range);
|
||||
}
|
||||
|
||||
timelineState.visibleStart = newStart;
|
||||
timelineState.visibleEnd = newEnd;
|
||||
}
|
||||
|
||||
function handleMinimapMouseUp() {
|
||||
isMinimapDragging = false;
|
||||
}
|
||||
|
||||
function handleMinimapClick(e: MouseEvent) {
|
||||
if (!minimapCanvas || session.duration <= 0) return;
|
||||
const rect = minimapCanvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const clickTime = (x / rect.width) * session.duration;
|
||||
const range = timelineState.visibleEnd - timelineState.visibleStart;
|
||||
|
||||
let newStart = clickTime - range / 2;
|
||||
let newEnd = newStart + range;
|
||||
if (newStart < 0) {
|
||||
newStart = 0;
|
||||
newEnd = range;
|
||||
}
|
||||
if (newEnd > session.duration) {
|
||||
newEnd = session.duration;
|
||||
newStart = Math.max(0, newEnd - range);
|
||||
}
|
||||
timelineState.visibleStart = newStart;
|
||||
timelineState.visibleEnd = newEnd;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="timeline-container" bind:this={containerEl}>
|
||||
<canvas
|
||||
bind:this={canvas}
|
||||
onmousedown={handleMouseDown}
|
||||
onmousemove={handleMouseMove}
|
||||
onmouseup={handleMouseUp}
|
||||
onmouseleave={handleMouseUp}
|
||||
onwheel={handleWheel}
|
||||
></canvas>
|
||||
<div class="timeline-wrapper">
|
||||
{#if isZoomed}
|
||||
<div class="minimap">
|
||||
<canvas
|
||||
bind:this={minimapCanvas}
|
||||
onmousedown={handleMinimapMouseDown}
|
||||
onclick={handleMinimapClick}
|
||||
></canvas>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="timeline-container" bind:this={containerEl}>
|
||||
<canvas
|
||||
bind:this={canvas}
|
||||
onmousedown={handleMouseDown}
|
||||
onmousemove={handleMouseMove}
|
||||
onmouseup={handleMouseUp}
|
||||
onmouseleave={handleMouseUp}
|
||||
onwheel={handleWheel}
|
||||
></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<svelte:window
|
||||
onmousemove={handleMinimapMouseMove}
|
||||
onmouseup={handleMinimapMouseUp}
|
||||
/>
|
||||
|
||||
<style>
|
||||
.timeline-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.minimap {
|
||||
height: 28px;
|
||||
background: #12121e;
|
||||
border-bottom: 1px solid var(--border, #353550);
|
||||
cursor: grab;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.minimap:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.minimap canvas {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.timeline-container {
|
||||
height: 160px;
|
||||
flex: 1;
|
||||
min-height: 80px;
|
||||
background: var(--timeline-bg);
|
||||
position: relative;
|
||||
cursor: crosshair;
|
||||
|
||||
@@ -8,11 +8,35 @@
|
||||
seekBy,
|
||||
stepFrame,
|
||||
togglePlayPause,
|
||||
setVolume,
|
||||
setMuted,
|
||||
getVolume,
|
||||
getMuted,
|
||||
type TransportKeyAction,
|
||||
} from '$lib/transport/playback';
|
||||
|
||||
let hasKeyframes = $derived(session.keyframePositions.length > 0);
|
||||
|
||||
let volume = $state(1);
|
||||
let isMuted = $state(false);
|
||||
|
||||
function toggleMute() {
|
||||
isMuted = !isMuted;
|
||||
setMuted(isMuted);
|
||||
}
|
||||
|
||||
function handleVolumeChange(e: Event) {
|
||||
const val = parseFloat((e.target as HTMLInputElement).value);
|
||||
volume = val;
|
||||
if (val > 0 && isMuted) {
|
||||
isMuted = false;
|
||||
}
|
||||
setVolume(val);
|
||||
if (val > 0) {
|
||||
setMuted(false);
|
||||
}
|
||||
}
|
||||
|
||||
export function handleKeyAction(action: TransportKeyAction) {
|
||||
runTransportAction(action, markInPoint, markOutPoint);
|
||||
}
|
||||
@@ -53,6 +77,32 @@
|
||||
<span class="total">{formatTime(session.duration)}</span>
|
||||
</div>
|
||||
|
||||
<div class="volume-controls">
|
||||
<button
|
||||
class="vol-btn"
|
||||
onclick={toggleMute}
|
||||
title={isMuted ? 'Unmute' : 'Mute'}
|
||||
type="button"
|
||||
>
|
||||
{#if isMuted || volume === 0}
|
||||
🔇
|
||||
{:else if volume < 0.5}
|
||||
🔉
|
||||
{:else}
|
||||
🔊
|
||||
{/if}
|
||||
</button>
|
||||
<input
|
||||
type="range"
|
||||
class="vol-slider"
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.05"
|
||||
value={isMuted ? 0 : volume}
|
||||
oninput={handleVolumeChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mark-buttons">
|
||||
<button
|
||||
class="mark-btn"
|
||||
@@ -114,6 +164,34 @@
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.volume-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.vol-btn {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
padding: 3px 6px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.vol-btn:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.vol-slider {
|
||||
width: 80px;
|
||||
height: 4px;
|
||||
accent-color: #89b4fa;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mark-buttons {
|
||||
margin-left: auto;
|
||||
display: flex;
|
||||
|
||||
@@ -9,11 +9,17 @@
|
||||
} from '$lib/stores/videoSession.svelte';
|
||||
|
||||
let inputValue = $state('');
|
||||
let inputEl = $state<HTMLInputElement | null>(null);
|
||||
|
||||
function blurInput() {
|
||||
inputEl?.blur();
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
const trimmed = inputValue.trim();
|
||||
if (!trimmed) return;
|
||||
|
||||
blurInput();
|
||||
setResolving();
|
||||
try {
|
||||
const meta = await resolveUrl(trimmed);
|
||||
@@ -43,6 +49,7 @@
|
||||
<div class="url-input-wrapper">
|
||||
<div class="url-input">
|
||||
<input
|
||||
bind:this={inputEl}
|
||||
type="text"
|
||||
placeholder="Paste a video URL and press Enter…"
|
||||
bind:value={inputValue}
|
||||
|
||||
@@ -8,6 +8,8 @@ export interface Clip {
|
||||
color: string;
|
||||
}
|
||||
|
||||
const EDIT_TOLERANCE = 0.5; // seconds — how close the playhead must be to a clip edge to count as "inside"
|
||||
|
||||
export const clipStore = $state({
|
||||
clips: [] as Clip[],
|
||||
selectedClipId: null as string | null,
|
||||
@@ -27,9 +29,20 @@ export function getPendingInPoint(): number | null {
|
||||
return clipStore.pendingInPoint;
|
||||
}
|
||||
|
||||
function isTimeInsideClip(time: number, clipId: string): boolean {
|
||||
const clip = clipStore.clips.find((c) => c.id === clipId);
|
||||
if (!clip) return false;
|
||||
return time >= clip.startTime - EDIT_TOLERANCE && time <= clip.endTime + EDIT_TOLERANCE;
|
||||
}
|
||||
|
||||
export function selectClip(id: string | null) {
|
||||
clipStore.selectedClipId = id;
|
||||
clipStore.pendingInPoint = null;
|
||||
// Only clear pendingInPoint when selecting a specific clip.
|
||||
// When deselecting (id=null), preserve it so clicking the timeline
|
||||
// to seek after pressing I doesn't lose the pending in-point.
|
||||
if (id !== null) {
|
||||
clipStore.pendingInPoint = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function addClip(startTime: number, endTime: number): Clip {
|
||||
@@ -71,20 +84,32 @@ export function updateClip(
|
||||
}
|
||||
|
||||
export function markInPoint(time: number) {
|
||||
if (clipStore.selectedClipId) {
|
||||
if (
|
||||
clipStore.selectedClipId &&
|
||||
isTimeInsideClip(time, clipStore.selectedClipId)
|
||||
) {
|
||||
// Playhead is inside the selected clip — edit its in-point
|
||||
updateClip(clipStore.selectedClipId, { startTime: time });
|
||||
} else {
|
||||
// Playhead is outside — deselect and begin a new clip
|
||||
clipStore.selectedClipId = null;
|
||||
clipStore.pendingInPoint = time;
|
||||
}
|
||||
}
|
||||
|
||||
export function markOutPoint(time: number) {
|
||||
if (clipStore.selectedClipId) {
|
||||
if (
|
||||
clipStore.selectedClipId &&
|
||||
isTimeInsideClip(time, clipStore.selectedClipId)
|
||||
) {
|
||||
// Playhead is inside the selected clip — edit its out-point
|
||||
updateClip(clipStore.selectedClipId, { endTime: time });
|
||||
} else if (clipStore.pendingInPoint !== null) {
|
||||
// We have a pending in-point — create a new clip
|
||||
addClip(clipStore.pendingInPoint, time);
|
||||
clipStore.pendingInPoint = null;
|
||||
}
|
||||
// Otherwise (no selection, no pending in-point) — no-op
|
||||
}
|
||||
|
||||
export function clearAll() {
|
||||
|
||||
@@ -1,17 +1,33 @@
|
||||
import type { VideoMetadata } from '$lib/bindings/video';
|
||||
import { startDownload } from '$lib/bindings/video';
|
||||
import type { ThumbnailSpritesheet } from '$lib/bindings/mediaAnalysis';
|
||||
import { checkCachedDownload, downloadSubtitles, startDownload } from '$lib/bindings/video';
|
||||
import type { ThumbnailSpritesheet, WaveformTiers } from '$lib/bindings/mediaAnalysis';
|
||||
import {
|
||||
checkEmbeddedSubtitles,
|
||||
cleanupThumbnails,
|
||||
extractKeyframes,
|
||||
extractThumbnails,
|
||||
extractWaveform,
|
||||
extractWaveformTiers,
|
||||
} from '$lib/bindings/mediaAnalysis';
|
||||
import { clearThumbnailCache } from '$lib/timeline/thumbnailRenderer';
|
||||
import { clearAll as clearAllClips } from '$lib/stores/clips.svelte';
|
||||
|
||||
export type SessionStatus = 'idle' | 'resolving' | 'ready' | 'error';
|
||||
export type DownloadStatus = 'idle' | 'downloading' | 'complete' | 'failed';
|
||||
export type ProcessingStep =
|
||||
| 'idle'
|
||||
| 'downloading'
|
||||
| 'waveform'
|
||||
| 'keyframes'
|
||||
| 'thumbnails'
|
||||
| 'captions'
|
||||
| 'done';
|
||||
|
||||
// H.264+AAC ≤360p for fast preview/scrubbing in WKWebView
|
||||
const PREVIEW_FORMAT =
|
||||
'bv*[vcodec^=avc1][height<=360]+ba[acodec^=mp4a]/b[ext=mp4][height<=360]/worst[ext=mp4]/worst';
|
||||
// Best available H.264+AAC in MP4 for export — WKWebView can't play AV1/VP9/WebM
|
||||
const EXPORT_FORMAT =
|
||||
'bv*[vcodec^=avc1]+ba[acodec^=mp4a]/b[ext=mp4]/best[ext=mp4]';
|
||||
|
||||
export const session = $state({
|
||||
status: 'idle' as SessionStatus,
|
||||
@@ -24,12 +40,35 @@ export const session = $state({
|
||||
streamUrl: '',
|
||||
currentTime: 0,
|
||||
isPlaying: false,
|
||||
localFilePath: null as string | null,
|
||||
downloadProgress: 0,
|
||||
downloadStatus: 'idle' as DownloadStatus,
|
||||
|
||||
// Preview (low-res for playback/scrubbing/waveform/thumbnails)
|
||||
previewFilePath: null as string | null,
|
||||
previewProgress: 0,
|
||||
previewStatus: 'idle' as DownloadStatus,
|
||||
|
||||
// Export (best quality for clip extraction)
|
||||
exportFilePath: null as string | null,
|
||||
exportProgress: 0,
|
||||
exportStatus: 'idle' as DownloadStatus,
|
||||
|
||||
keyframePositions: [] as number[],
|
||||
waveformPeaks: [] as number[],
|
||||
waveformTiers: null as WaveformTiers | null,
|
||||
thumbnailSpritesheets: [] as ThumbnailSpritesheet[],
|
||||
|
||||
// Captions
|
||||
hasCaptions: false,
|
||||
captionsAreAuto: false,
|
||||
captionFilePath: null as string | null,
|
||||
|
||||
// The path used for the <video> element (may be preview or export quality)
|
||||
activeVideoPath: null as string | null,
|
||||
|
||||
// Preview upgrade toast
|
||||
showUpgradeToast: false,
|
||||
|
||||
// Processing modal state
|
||||
processingStep: 'idle' as ProcessingStep,
|
||||
processingProgress: 0,
|
||||
});
|
||||
|
||||
export function setMetadata(meta: VideoMetadata) {
|
||||
@@ -40,15 +79,25 @@ export function setMetadata(meta: VideoMetadata) {
|
||||
session.fps = meta.fps;
|
||||
session.thumbnailUrl = meta.thumbnailUrl;
|
||||
session.streamUrl = meta.streamUrl;
|
||||
session.hasCaptions = meta.hasCaptions;
|
||||
session.captionsAreAuto = meta.captionsAreAuto;
|
||||
session.status = 'ready';
|
||||
session.error = null;
|
||||
|
||||
session.localFilePath = null;
|
||||
session.downloadProgress = 0;
|
||||
session.downloadStatus = 'idle';
|
||||
session.captionFilePath = null;
|
||||
session.previewFilePath = null;
|
||||
session.previewProgress = 0;
|
||||
session.previewStatus = 'idle';
|
||||
session.exportFilePath = null;
|
||||
session.exportProgress = 0;
|
||||
session.exportStatus = 'idle';
|
||||
session.keyframePositions = [];
|
||||
session.waveformPeaks = [];
|
||||
session.waveformTiers = null;
|
||||
session.thumbnailSpritesheets = [];
|
||||
session.activeVideoPath = null;
|
||||
session.showUpgradeToast = false;
|
||||
session.processingStep = 'idle';
|
||||
session.processingProgress = 0;
|
||||
}
|
||||
|
||||
function clearMediaFields() {
|
||||
@@ -60,12 +109,22 @@ function clearMediaFields() {
|
||||
session.streamUrl = '';
|
||||
session.currentTime = 0;
|
||||
session.isPlaying = false;
|
||||
session.localFilePath = null;
|
||||
session.downloadProgress = 0;
|
||||
session.downloadStatus = 'idle';
|
||||
session.previewFilePath = null;
|
||||
session.previewProgress = 0;
|
||||
session.previewStatus = 'idle';
|
||||
session.exportFilePath = null;
|
||||
session.exportProgress = 0;
|
||||
session.exportStatus = 'idle';
|
||||
session.keyframePositions = [];
|
||||
session.waveformPeaks = [];
|
||||
session.waveformTiers = null;
|
||||
session.thumbnailSpritesheets = [];
|
||||
session.hasCaptions = false;
|
||||
session.captionsAreAuto = false;
|
||||
session.captionFilePath = null;
|
||||
session.activeVideoPath = null;
|
||||
session.showUpgradeToast = false;
|
||||
session.processingStep = 'idle';
|
||||
session.processingProgress = 0;
|
||||
}
|
||||
|
||||
export function setError(msg: string) {
|
||||
@@ -87,45 +146,112 @@ export function reset() {
|
||||
session.error = null;
|
||||
}
|
||||
|
||||
async function triggerPostDownloadProcessing(filePath: string) {
|
||||
const duration = session.duration;
|
||||
const [keyframes, waveform, thumbnails] = await Promise.allSettled([
|
||||
extractKeyframes(filePath),
|
||||
extractWaveform(filePath, 8000),
|
||||
extractThumbnails(filePath, duration),
|
||||
]);
|
||||
|
||||
if (keyframes.status === 'fulfilled') {
|
||||
session.keyframePositions = keyframes.value;
|
||||
} else {
|
||||
console.error('Keyframe extraction failed:', keyframes.reason);
|
||||
}
|
||||
|
||||
if (waveform.status === 'fulfilled') {
|
||||
session.waveformPeaks = waveform.value;
|
||||
} else {
|
||||
console.error('Waveform extraction failed:', waveform.reason);
|
||||
}
|
||||
|
||||
if (thumbnails.status === 'fulfilled') {
|
||||
session.thumbnailSpritesheets = thumbnails.value;
|
||||
} else {
|
||||
console.error('Thumbnail extraction failed:', thumbnails.reason);
|
||||
/** Switch the preview video to the full-quality export version. */
|
||||
export function upgradePreview() {
|
||||
if (session.exportFilePath) {
|
||||
console.log('[upgradePreview] switching from', session.activeVideoPath, 'to', session.exportFilePath);
|
||||
session.activeVideoPath = session.exportFilePath;
|
||||
session.showUpgradeToast = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function dismissUpgradeToast() {
|
||||
session.showUpgradeToast = false;
|
||||
}
|
||||
|
||||
async function loadCaptions(filePath: string) {
|
||||
session.processingStep = 'captions';
|
||||
|
||||
// Priority 1: Check for embedded subtitle streams in the file
|
||||
try {
|
||||
const embeddedPath = await checkEmbeddedSubtitles(filePath);
|
||||
if (embeddedPath) {
|
||||
session.captionFilePath = embeddedPath;
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Embedded subtitle check failed:', err);
|
||||
}
|
||||
|
||||
// Priority 2: Download external subtitles via yt-dlp (manual > auto-generated)
|
||||
if (session.hasCaptions && session.url) {
|
||||
try {
|
||||
const path = await downloadSubtitles(session.url, session.captionsAreAuto);
|
||||
session.captionFilePath = path;
|
||||
} catch (err) {
|
||||
console.error('Subtitle download failed:', err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerPostDownloadProcessing(filePath: string) {
|
||||
const duration = session.duration;
|
||||
|
||||
// Run processing SEQUENTIALLY to avoid overloading the CPU with
|
||||
// concurrent ffmpeg subprocesses. Each one decodes the full file.
|
||||
// Order: waveform first (most useful for editing), then keyframes, then thumbnails.
|
||||
|
||||
session.processingStep = 'waveform';
|
||||
session.processingProgress = 0;
|
||||
try {
|
||||
const tiers = await extractWaveformTiers(filePath, duration, (pct) => {
|
||||
session.processingProgress = pct;
|
||||
});
|
||||
session.waveformTiers = tiers;
|
||||
} catch (err) {
|
||||
console.error('Waveform extraction failed:', err);
|
||||
}
|
||||
|
||||
session.processingStep = 'keyframes';
|
||||
session.processingProgress = 0;
|
||||
try {
|
||||
const kf = await extractKeyframes(filePath);
|
||||
session.keyframePositions = kf;
|
||||
} catch (err) {
|
||||
console.error('Keyframe extraction failed:', err);
|
||||
}
|
||||
|
||||
session.processingStep = 'thumbnails';
|
||||
session.processingProgress = 0;
|
||||
try {
|
||||
const sheets = await extractThumbnails(filePath, duration);
|
||||
session.thumbnailSpritesheets = sheets;
|
||||
} catch (err) {
|
||||
console.error('Thumbnail extraction failed:', err);
|
||||
}
|
||||
|
||||
// Captions can run after analysis since they use yt-dlp, not ffmpeg
|
||||
await loadCaptions(filePath);
|
||||
|
||||
session.processingStep = 'done';
|
||||
|
||||
// If export download already completed while we were processing, show upgrade toast
|
||||
if (session.exportStatus === 'complete' && session.exportFilePath) {
|
||||
session.showUpgradeToast = true;
|
||||
}
|
||||
}
|
||||
|
||||
async function runDownload(
|
||||
url: string,
|
||||
formatSpec: string,
|
||||
outputDir: string,
|
||||
onProgress: (pct: number) => void,
|
||||
onFilePath: (path: string) => void,
|
||||
onFinished: (success: boolean, path: string) => void,
|
||||
onError: (message: string) => void,
|
||||
): Promise<void> {
|
||||
await startDownload(url, formatSpec, outputDir, onProgress, onFilePath, onFinished, onError);
|
||||
}
|
||||
|
||||
export async function beginDownload() {
|
||||
if (!session.url) return;
|
||||
if (!session.url || !session.title) return;
|
||||
|
||||
const oldThumbnailDirs = [
|
||||
...new Set(session.thumbnailSpritesheets.map((sheet) => sheet.filePath)),
|
||||
];
|
||||
clearThumbnailCache();
|
||||
session.downloadStatus = 'downloading';
|
||||
session.downloadProgress = 0;
|
||||
session.localFilePath = null;
|
||||
session.keyframePositions = [];
|
||||
session.waveformPeaks = [];
|
||||
session.waveformTiers = null;
|
||||
session.thumbnailSpritesheets = [];
|
||||
|
||||
if (oldThumbnailDirs.length > 0) {
|
||||
@@ -134,32 +260,90 @@ export async function beginDownload() {
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
await startDownload(
|
||||
session.processingStep = 'downloading';
|
||||
|
||||
// --- Preview download (low-res, fast) ---
|
||||
const previewCached = await checkCachedDownload(session.title, 'preview').catch(() => null);
|
||||
if (previewCached) {
|
||||
session.previewFilePath = previewCached;
|
||||
session.activeVideoPath = previewCached;
|
||||
session.previewProgress = 1.0;
|
||||
session.previewStatus = 'complete';
|
||||
triggerPostDownloadProcessing(previewCached);
|
||||
} else {
|
||||
session.previewStatus = 'downloading';
|
||||
session.previewProgress = 0;
|
||||
session.previewFilePath = null;
|
||||
|
||||
try {
|
||||
await runDownload(
|
||||
session.url,
|
||||
PREVIEW_FORMAT,
|
||||
'preview',
|
||||
(pct) => { session.previewProgress = pct; },
|
||||
(path) => { session.previewFilePath = path; },
|
||||
(success, path) => {
|
||||
if (success) {
|
||||
session.previewProgress = 1.0;
|
||||
session.previewFilePath = path;
|
||||
session.activeVideoPath = path;
|
||||
session.previewStatus = 'complete';
|
||||
triggerPostDownloadProcessing(path);
|
||||
} else {
|
||||
session.previewStatus = 'failed';
|
||||
}
|
||||
},
|
||||
(message) => {
|
||||
session.previewStatus = 'failed';
|
||||
console.error('Preview download error:', message);
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
session.previewStatus = 'failed';
|
||||
console.error('Preview download failed:', e);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Export download (best quality, background) ---
|
||||
const exportCached = await checkCachedDownload(session.title, 'export').catch(() => null);
|
||||
if (exportCached) {
|
||||
session.exportFilePath = exportCached;
|
||||
session.exportProgress = 1.0;
|
||||
session.exportStatus = 'complete';
|
||||
if ((session.processingStep as ProcessingStep) === 'done') {
|
||||
session.showUpgradeToast = true;
|
||||
}
|
||||
} else {
|
||||
session.exportStatus = 'downloading';
|
||||
session.exportProgress = 0;
|
||||
session.exportFilePath = null;
|
||||
|
||||
// Fire-and-forget — runs in background
|
||||
runDownload(
|
||||
session.url,
|
||||
(percent) => {
|
||||
session.downloadProgress = percent;
|
||||
},
|
||||
(path) => {
|
||||
session.localFilePath = path;
|
||||
},
|
||||
EXPORT_FORMAT,
|
||||
'export',
|
||||
(pct) => { session.exportProgress = pct; },
|
||||
(path) => { session.exportFilePath = path; },
|
||||
(success, path) => {
|
||||
if (success) {
|
||||
session.downloadProgress = 1.0;
|
||||
session.localFilePath = path;
|
||||
session.downloadStatus = 'complete';
|
||||
void triggerPostDownloadProcessing(path);
|
||||
session.exportProgress = 1.0;
|
||||
session.exportFilePath = path;
|
||||
session.exportStatus = 'complete';
|
||||
if ((session.processingStep as ProcessingStep) === 'done') {
|
||||
session.showUpgradeToast = true;
|
||||
}
|
||||
} else {
|
||||
session.downloadStatus = 'failed';
|
||||
session.exportStatus = 'failed';
|
||||
}
|
||||
},
|
||||
(message) => {
|
||||
session.downloadStatus = 'failed';
|
||||
console.error('Download error:', message);
|
||||
session.exportStatus = 'failed';
|
||||
console.error('Export download error:', message);
|
||||
}
|
||||
);
|
||||
} catch (e) {
|
||||
session.downloadStatus = 'failed';
|
||||
console.error('Download failed:', e);
|
||||
).catch((e) => {
|
||||
session.exportStatus = 'failed';
|
||||
console.error('Export download failed:', e);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,28 @@ export function computeZoom(
|
||||
return { visibleStart: newStart, visibleEnd: newEnd, zoom: newZoom };
|
||||
}
|
||||
|
||||
export function panBy(
|
||||
state: TimelineState,
|
||||
deltaPixels: number,
|
||||
duration: number
|
||||
): { visibleStart: number; visibleEnd: number } {
|
||||
const range = state.visibleEnd - state.visibleStart;
|
||||
const deltaTime = (deltaPixels / state.width) * range;
|
||||
let newStart = state.visibleStart + deltaTime;
|
||||
let newEnd = newStart + range;
|
||||
|
||||
if (newStart < 0) {
|
||||
newStart = 0;
|
||||
newEnd = range;
|
||||
}
|
||||
if (newEnd > duration) {
|
||||
newEnd = duration;
|
||||
newStart = Math.max(0, newEnd - range);
|
||||
}
|
||||
|
||||
return { visibleStart: newStart, visibleEnd: newEnd };
|
||||
}
|
||||
|
||||
export function handleClick(
|
||||
x: number,
|
||||
state: TimelineState,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { drawClips } from './clipRenderer';
|
||||
import type { Clip } from '$lib/stores/clips.svelte';
|
||||
import { drawWaveform } from './waveformRenderer';
|
||||
import { drawWaveform, type WaveformData } from './waveformRenderer';
|
||||
import { drawThumbnails } from './thumbnailRenderer';
|
||||
import type { ThumbnailSpritesheet } from '$lib/bindings/mediaAnalysis';
|
||||
|
||||
@@ -36,7 +36,7 @@ export function drawTimeline(
|
||||
clips: Clip[] = [],
|
||||
selectedClipId: string | null = null,
|
||||
pendingInPoint: number | null = null,
|
||||
waveformPeaks: number[] = [],
|
||||
waveform: WaveformData = { tiers: null },
|
||||
thumbnailSpritesheets: ThumbnailSpritesheet[] = []
|
||||
): void {
|
||||
const { width, height } = state;
|
||||
@@ -53,8 +53,8 @@ export function drawTimeline(
|
||||
drawPlaceholderLane(ctx, state, 0, THUMB_LANE_HEIGHT, 'Thumbnails');
|
||||
}
|
||||
|
||||
if (waveformPeaks.length > 0) {
|
||||
drawWaveform(ctx, state, waveformPeaks, duration, THUMB_LANE_HEIGHT, WAVEFORM_LANE_HEIGHT);
|
||||
if (waveform.tiers) {
|
||||
drawWaveform(ctx, state, waveform, duration, THUMB_LANE_HEIGHT, WAVEFORM_LANE_HEIGHT);
|
||||
} else {
|
||||
drawPlaceholderLane(ctx, state, THUMB_LANE_HEIGHT, WAVEFORM_LANE_HEIGHT, 'Waveform');
|
||||
}
|
||||
|
||||
@@ -3,9 +3,17 @@ import { convertFileSrc } from '@tauri-apps/api/core';
|
||||
import { timeToX, type TimelineState } from './renderer';
|
||||
|
||||
const imageCache = new Map<string, HTMLImageElement>();
|
||||
const pendingLoads = new Set<string>();
|
||||
let onLoadCallback: (() => void) | null = null;
|
||||
|
||||
export function clearThumbnailCache(): void {
|
||||
imageCache.clear();
|
||||
pendingLoads.clear();
|
||||
}
|
||||
|
||||
/** Register a callback to be invoked when any thumbnail finishes loading */
|
||||
export function setThumbnailLoadCallback(cb: (() => void) | null): void {
|
||||
onLoadCallback = cb;
|
||||
}
|
||||
|
||||
const TARGET_SPACING_PX = 100;
|
||||
@@ -16,14 +24,21 @@ function getImagePath(dir: string, frameIndex: number): string {
|
||||
}
|
||||
|
||||
function loadImage(path: string): void {
|
||||
if (imageCache.has(path)) {
|
||||
if (imageCache.has(path) || pendingLoads.has(path)) {
|
||||
return;
|
||||
}
|
||||
|
||||
pendingLoads.add(path);
|
||||
const img = new Image();
|
||||
img.src = convertFileSrc(path);
|
||||
img.onload = () => imageCache.set(path, img);
|
||||
imageCache.set(path, img);
|
||||
img.onload = () => {
|
||||
imageCache.set(path, img);
|
||||
pendingLoads.delete(path);
|
||||
onLoadCallback?.();
|
||||
};
|
||||
img.onerror = () => {
|
||||
pendingLoads.delete(path);
|
||||
};
|
||||
}
|
||||
|
||||
function findSheetForFrame(
|
||||
|
||||
@@ -1,17 +1,64 @@
|
||||
import type { WaveformTiers } from '$lib/bindings/mediaAnalysis';
|
||||
import type { TimelineState } from './renderer';
|
||||
|
||||
export interface WaveformData {
|
||||
tiers: WaveformTiers | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the best tier for the current viewport.
|
||||
* Returns the tier array that gives at least 1 peak per pixel,
|
||||
* preferring the lowest-resolution tier that meets that threshold.
|
||||
*/
|
||||
function pickTier(
|
||||
tiers: WaveformTiers,
|
||||
visibleStart: number,
|
||||
visibleEnd: number,
|
||||
duration: number,
|
||||
widthPx: number
|
||||
): number[] {
|
||||
const visibleRange = visibleEnd - visibleStart;
|
||||
const visibleFraction = visibleRange / duration;
|
||||
|
||||
const candidates: [number[], number][] = [
|
||||
[tiers.tier0, (tiers.tier0.length * visibleFraction) / widthPx],
|
||||
[tiers.tier1, (tiers.tier1.length * visibleFraction) / widthPx],
|
||||
[tiers.tier2, (tiers.tier2.length * visibleFraction) / widthPx],
|
||||
];
|
||||
|
||||
for (const [tier, peaksPerPx] of candidates) {
|
||||
if (peaksPerPx >= 1) return tier;
|
||||
}
|
||||
|
||||
return tiers.tier2;
|
||||
}
|
||||
|
||||
export function drawWaveform(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
state: TimelineState,
|
||||
peaks: number[],
|
||||
waveform: WaveformData,
|
||||
duration: number,
|
||||
y: number,
|
||||
height: number
|
||||
): void {
|
||||
if (peaks.length === 0 || duration <= 0) return;
|
||||
if (duration <= 0 || !waveform.tiers) return;
|
||||
|
||||
const { width, visibleStart, visibleEnd } = state;
|
||||
const visibleRange = visibleEnd - visibleStart;
|
||||
const peaks = pickTier(waveform.tiers, visibleStart, visibleEnd, duration, width);
|
||||
const peakCount = peaks.length;
|
||||
|
||||
// Find local maximum across the visible region for dynamic scaling.
|
||||
// This makes the waveform fill the available height when zoomed in,
|
||||
// rather than staying relative to the global max.
|
||||
const visStartIdx = Math.max(0, Math.floor((visibleStart / duration) * peakCount));
|
||||
const visEndIdx = Math.min(peakCount - 1, Math.ceil((visibleEnd / duration) * peakCount));
|
||||
let localMax = 0;
|
||||
for (let i = visStartIdx; i <= visEndIdx; i++) {
|
||||
localMax = Math.max(localMax, peaks[i]);
|
||||
}
|
||||
// Avoid division by zero; use 1.0 if all peaks are silent
|
||||
const scale = localMax > 0 ? 1 / localMax : 1;
|
||||
|
||||
ctx.fillStyle = '#89b4fa44';
|
||||
ctx.strokeStyle = '#89b4fa88';
|
||||
@@ -21,19 +68,18 @@ export function drawWaveform(
|
||||
ctx.moveTo(0, y + height);
|
||||
|
||||
for (let px = 0; px < width; px++) {
|
||||
const startSample = Math.floor(
|
||||
((visibleStart + ((px - 0.5) / width) * visibleRange) / duration) * peaks.length
|
||||
);
|
||||
const endSample = Math.floor(
|
||||
((visibleStart + ((px + 0.5) / width) * visibleRange) / duration) * peaks.length
|
||||
);
|
||||
const timeStart = visibleStart + ((px - 0.5) / width) * visibleRange;
|
||||
const timeEnd = visibleStart + ((px + 0.5) / width) * visibleRange;
|
||||
|
||||
const s0 = Math.floor((timeStart / duration) * peakCount);
|
||||
const s1 = Math.floor((timeEnd / duration) * peakCount);
|
||||
|
||||
let maxPeak = 0;
|
||||
for (let i = Math.max(0, startSample); i <= Math.min(endSample, peaks.length - 1); i++) {
|
||||
for (let i = Math.max(0, s0); i <= Math.min(s1, peakCount - 1); i++) {
|
||||
maxPeak = Math.max(maxPeak, peaks[i]);
|
||||
}
|
||||
|
||||
const barHeight = maxPeak * height;
|
||||
const barHeight = maxPeak * scale * height;
|
||||
ctx.lineTo(px, y + height - barHeight);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { session } from '$lib/stores/videoSession.svelte';
|
||||
|
||||
let _videoEl: HTMLVideoElement | null = null;
|
||||
|
||||
export function setVideoElement(el: HTMLVideoElement | null) {
|
||||
_videoEl = el;
|
||||
}
|
||||
|
||||
function getVideo(): HTMLVideoElement | null {
|
||||
return _videoEl;
|
||||
}
|
||||
|
||||
export function seekTo(time: number) {
|
||||
const clamped = Math.max(0, Math.min(time, session.duration));
|
||||
session.currentTime = clamped;
|
||||
const videoEl = document.querySelector('video');
|
||||
const videoEl = getVideo();
|
||||
if (videoEl) {
|
||||
videoEl.currentTime = clamped;
|
||||
}
|
||||
@@ -14,7 +24,7 @@ export function seekBy(seconds: number) {
|
||||
}
|
||||
|
||||
export function togglePlayPause() {
|
||||
const videoEl = document.querySelector('video');
|
||||
const videoEl = getVideo();
|
||||
if (!videoEl) return;
|
||||
if (videoEl.paused) {
|
||||
void videoEl.play();
|
||||
@@ -24,7 +34,7 @@ export function togglePlayPause() {
|
||||
}
|
||||
|
||||
export function stepFrame(direction: 1 | -1) {
|
||||
const videoEl = document.querySelector('video');
|
||||
const videoEl = getVideo();
|
||||
if (videoEl && !videoEl.paused) {
|
||||
videoEl.pause();
|
||||
}
|
||||
@@ -44,15 +54,38 @@ export function jumpKeyframe(direction: 1 | -1) {
|
||||
}
|
||||
}
|
||||
|
||||
export function setVolume(v: number) {
|
||||
const videoEl = getVideo();
|
||||
if (videoEl) {
|
||||
videoEl.volume = v;
|
||||
videoEl.muted = false;
|
||||
}
|
||||
}
|
||||
|
||||
export function setMuted(m: boolean) {
|
||||
const videoEl = getVideo();
|
||||
if (videoEl) {
|
||||
videoEl.muted = m;
|
||||
}
|
||||
}
|
||||
|
||||
export function getVolume(): number {
|
||||
return getVideo()?.volume ?? 1;
|
||||
}
|
||||
|
||||
export function getMuted(): boolean {
|
||||
return getVideo()?.muted ?? false;
|
||||
}
|
||||
|
||||
export function resetShuttleRate() {
|
||||
const videoEl = document.querySelector('video');
|
||||
const videoEl = getVideo();
|
||||
if (videoEl) {
|
||||
videoEl.playbackRate = 1;
|
||||
}
|
||||
}
|
||||
|
||||
export function adjustShuttle(dir: 1 | -1, shuttleRate: number): number {
|
||||
const videoEl = document.querySelector('video');
|
||||
const videoEl = getVideo();
|
||||
if (!videoEl) return shuttleRate;
|
||||
|
||||
const nextRate = Math.max(0.25, Math.min(4, shuttleRate + dir * 0.5));
|
||||
|
||||
142
src/lib/utils/vttParser.ts
Normal file
142
src/lib/utils/vttParser.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
export interface WordSegment {
|
||||
text: string;
|
||||
startTime: number;
|
||||
}
|
||||
|
||||
export interface VttCue {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
text: string;
|
||||
words?: WordSegment[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a WebVTT string into an array of cues.
|
||||
* Handles both standard VTT and common variations.
|
||||
* Extracts word-level timing data from YouTube-style <c> tags when present.
|
||||
*/
|
||||
export function parseVtt(vttContent: string): VttCue[] {
|
||||
const cues: VttCue[] = [];
|
||||
const blocks = vttContent.replace(/\r\n/g, '\n').split(/\n\n+/);
|
||||
|
||||
for (const block of blocks) {
|
||||
const lines = block.trim().split('\n');
|
||||
let timestampLineIdx = -1;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].includes('-->')) {
|
||||
timestampLineIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (timestampLineIdx === -1) continue;
|
||||
|
||||
const timestampLine = lines[timestampLineIdx];
|
||||
const match = timestampLine.match(
|
||||
/(\d{1,2}:?\d{2}:\d{2}[.,]\d{3})\s*-->\s*(\d{1,2}:?\d{2}:\d{2}[.,]\d{3})/
|
||||
);
|
||||
if (!match) continue;
|
||||
|
||||
const startTime = parseTimestamp(match[1]);
|
||||
const endTime = parseTimestamp(match[2]);
|
||||
|
||||
const rawText = lines
|
||||
.slice(timestampLineIdx + 1)
|
||||
.join('\n')
|
||||
.trim();
|
||||
|
||||
const words = parseWordTimings(rawText, startTime);
|
||||
const text = rawText.replace(/<[^>]+>/g, '').trim();
|
||||
|
||||
if (text && startTime < endTime) {
|
||||
cues.push({ startTime, endTime, text, words: words.length > 0 ? words : undefined });
|
||||
}
|
||||
}
|
||||
|
||||
return cues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse word-level timing data from YouTube-style VTT cue text.
|
||||
*
|
||||
* YouTube format: `the<00:00:00.599><c> gym</c><00:00:00.840><c> I</c>`
|
||||
*
|
||||
* The first word(s) before any timestamp tag start at `cueStartTime`.
|
||||
* Each `<HH:MM:SS.mmm>` sets the start time for the text in the following `<c>...</c>`.
|
||||
*/
|
||||
function parseWordTimings(rawText: string, cueStartTime: number): WordSegment[] {
|
||||
// Only parse if the text contains the YouTube-style <c> word timing pattern
|
||||
if (!rawText.includes('<c>')) return [];
|
||||
|
||||
const segments: WordSegment[] = [];
|
||||
// Pattern to match: optional leading text, then repeated <timestamp><c> word</c> groups
|
||||
// We process the raw text character by character using regex matches
|
||||
|
||||
// Match all timestamp + <c>word</c> pairs, plus any leading text
|
||||
const timestampCPattern = /<(\d{1,2}:?\d{2}:\d{2}[.,]\d{3})><c>(.*?)<\/c>/g;
|
||||
|
||||
// Get any leading text before the first timestamp
|
||||
const firstTimestampMatch = rawText.match(/<\d{1,2}:?\d{2}:\d{2}[.,]\d{3}>/);
|
||||
if (firstTimestampMatch && firstTimestampMatch.index !== undefined && firstTimestampMatch.index > 0) {
|
||||
const leadingText = rawText.substring(0, firstTimestampMatch.index).replace(/<[^>]+>/g, '').trim();
|
||||
if (leadingText) {
|
||||
segments.push({ text: leadingText, startTime: cueStartTime });
|
||||
}
|
||||
}
|
||||
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = timestampCPattern.exec(rawText)) !== null) {
|
||||
const wordTime = parseTimestamp(m[1]);
|
||||
const wordText = m[2].replace(/<[^>]+>/g, '').trim();
|
||||
if (wordText) {
|
||||
segments.push({ text: wordText, startTime: wordTime });
|
||||
}
|
||||
}
|
||||
|
||||
return segments;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a VTT/SRT timestamp like "00:01:23.456" or "1:23.456" into seconds.
|
||||
*/
|
||||
function parseTimestamp(ts: string): number {
|
||||
const normalized = ts.replace(',', '.');
|
||||
const parts = normalized.split(':');
|
||||
if (parts.length === 3) {
|
||||
return (
|
||||
parseInt(parts[0], 10) * 3600 +
|
||||
parseInt(parts[1], 10) * 60 +
|
||||
parseFloat(parts[2])
|
||||
);
|
||||
} else if (parts.length === 2) {
|
||||
return parseInt(parts[0], 10) * 60 + parseFloat(parts[1]);
|
||||
}
|
||||
return parseFloat(normalized);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find all active cues at the given time.
|
||||
* Assumes cues are sorted by startTime (VTT spec requires this).
|
||||
*/
|
||||
export function getActiveCues(cues: VttCue[], currentTime: number): VttCue[] {
|
||||
return cues.filter(
|
||||
(cue) => currentTime >= cue.startTime && currentTime < cue.endTime
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a cue with word segments and the current playback time,
|
||||
* return which words are "spoken" (their startTime <= currentTime)
|
||||
* and which are "upcoming" (their startTime > currentTime).
|
||||
*/
|
||||
export function getActiveWords(
|
||||
cue: VttCue,
|
||||
currentTime: number
|
||||
): { text: string; spoken: boolean }[] {
|
||||
if (!cue.words || cue.words.length === 0) {
|
||||
return [{ text: cue.text, spoken: true }];
|
||||
}
|
||||
return cue.words.map((word) => ({
|
||||
text: word.text,
|
||||
spoken: currentTime >= word.startTime,
|
||||
}));
|
||||
}
|
||||
Reference in New Issue
Block a user