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:
2026-09-22 10:48:16 -04:00
parent fd05ae19b5
commit 8ad2f1c800
57 changed files with 4929 additions and 210 deletions

View File

@@ -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;

View 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>

View File

@@ -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>

View File

@@ -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;

View File

@@ -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;

View File

@@ -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}