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