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,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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user