Files
gui-video-clipper/src/lib/components/Timeline.svelte

522 lines
14 KiB
Svelte
Raw Normal View History

<script lang="ts">
import TimelineContextMenu from '$lib/components/TimelineContextMenu.svelte';
import type { ContextMenuAction } from '$lib/components/TimelineContextMenu.svelte';
import { session } from '$lib/stores/videoSession.svelte';
import {
clipStore,
selectClip,
updateClip,
markInPoint,
markOutPoint,
removeClip,
} from '$lib/stores/clips.svelte';
import { drawTimeline, type TimelineState, computeLanes } 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 pendingDraw = 0;
let pendingMinimapDraw = 0;
let timelineState = $state<TimelineState>({
visibleStart: 0,
visibleEnd: 60,
zoom: 1,
width: 600,
height: 160,
});
let isDragging = $state(false);
let dragTarget = $state<ClipDragTarget | null>(null);
let isMinimapDragging = $state(false);
let minimapDragStartX = $state(0);
let minimapDragStartVisibleStart = $state(0);
let mouseX = $state<number | null>(null);
let contextMenu = $state<{
x: number;
y: number;
time: number;
} | null>(null);
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,
session.status === 'ready',
mouseX
);
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;
void mouseX;
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) {
timelineState.visibleStart = 0;
timelineState.visibleEnd = session.duration;
timelineState.zoom = 1;
}
});
$effect(() => {
if (!containerEl) return;
const observer = new ResizeObserver((entries) => {
for (const entry of entries) {
timelineState.width = entry.contentRect.width;
timelineState.height = entry.contentRect.height;
if (canvas) {
canvas.width = entry.contentRect.width * window.devicePixelRatio;
canvas.height = entry.contentRect.height * window.devicePixelRatio;
canvas.style.width = `${entry.contentRect.width}px`;
canvas.style.height = `${entry.contentRect.height}px`;
}
}
});
observer.observe(containerEl);
return () => observer.disconnect();
});
$effect(() => {
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 {
if (!canvas) return 0;
const rect = canvas.getBoundingClientRect();
return e.clientX - rect.left;
}
function getCanvasY(e: MouseEvent): number {
if (!canvas) return 0;
const rect = canvas.getBoundingClientRect();
return e.clientY - rect.top;
}
function seekTo(time: number) {
session.currentTime = time;
const videoEl = document.querySelector('video');
if (videoEl) {
videoEl.currentTime = time;
}
}
function handleContextMenu(e: MouseEvent) {
e.preventDefault();
const x = getCanvasX(e);
const time = computeClickTime(x, timelineState, session.duration);
seekTo(time);
contextMenu = {
x: e.clientX,
y: e.clientY,
time,
};
}
function handleContextMenuAction(action: ContextMenuAction) {
if (!contextMenu) return;
switch (action.type) {
case 'mark-in':
markInPoint(contextMenu.time);
break;
case 'mark-out':
markOutPoint(contextMenu.time);
break;
case 'go-to-clip-start': {
const clip = clipStore.clips.find((c) => c.id === clipStore.selectedClipId);
if (clip) seekTo(clip.startTime);
break;
}
case 'go-to-clip-end': {
const clip = clipStore.clips.find((c) => c.id === clipStore.selectedClipId);
if (clip) seekTo(clip.endTime);
break;
}
case 'delete-clip':
if (clipStore.selectedClipId) removeClip(clipStore.selectedClipId);
break;
case 'play-clip': {
const clip = clipStore.clips.find((c) => c.id === clipStore.selectedClipId);
if (clip) {
seekTo(clip.startTime);
const videoEl = document.querySelector('video');
if (videoEl?.paused) videoEl.play();
}
break;
}
default: {
const _exhaustive: never = action;
void _exhaustive;
}
}
}
function handleMouseDown(e: MouseEvent) {
contextMenu = null;
const x = getCanvasX(e);
const y = getCanvasY(e);
const hit = hitTestClip(x, y, timelineState, clipStore.clips, computeLanes(timelineState.height));
if (hit) {
if (hit.edge === 'body') {
selectClip(hit.clipId);
} else if (hit.edge === 'start' || hit.edge === 'end') {
dragTarget = { clipId: hit.clipId, edge: hit.edge };
selectClip(hit.clipId);
}
isDragging = true;
return;
}
selectClip(null);
isDragging = true;
const targetTime = computeClickTime(x, timelineState, session.duration);
seekTo(targetTime);
}
function handleMouseMove(e: MouseEvent) {
if (!isDragging) return;
const x = getCanvasX(e);
if (dragTarget) {
const time = computeClickTime(x, timelineState, session.duration);
switch (dragTarget.edge) {
case 'start':
updateClip(dragTarget.clipId, { startTime: time });
break;
case 'end':
updateClip(dragTarget.clipId, { endTime: time });
break;
default: {
const _exhaustive: never = dragTarget.edge;
void _exhaustive;
}
}
return;
}
const targetTime = computeClickTime(x, timelineState, session.duration);
seekTo(targetTime);
}
function handleMouseUp() {
isDragging = false;
dragTarget = null;
}
function handleMouseTrack(e: MouseEvent) {
mouseX = getCanvasX(e);
}
function handleMouseLeave() {
handleMouseUp();
mouseX = null;
}
function handleWheel(e: WheelEvent) {
e.preventDefault();
contextMenu = null;
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;
const result = computeZoom(timelineState, anchorTime, factor, session.duration);
timelineState.visibleStart = result.visibleStart;
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-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={(e) => {
handleMouseMove(e);
handleMouseTrack(e);
}}
onmouseup={handleMouseUp}
onmouseleave={handleMouseLeave}
onwheel={handleWheel}
oncontextmenu={handleContextMenu}
></canvas>
</div>
</div>
{#if contextMenu}
<TimelineContextMenu
x={contextMenu.x}
y={contextMenu.y}
time={contextMenu.time}
selectedClipId={clipStore.selectedClipId}
onAction={handleContextMenuAction}
onClose={() => {
contextMenu = null;
}}
/>
{/if}
<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 {
flex: 1;
min-height: 80px;
background: var(--timeline-bg);
position: relative;
cursor: crosshair;
}
canvas {
display: block;
width: 100%;
height: 100%;
}
</style>