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

@@ -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() {

View File

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