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