feat: add waveform generation and timeline display
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -13,3 +13,10 @@ export interface ThumbnailSpritesheet {
|
||||
export async function extractKeyframes(filePath: string): Promise<number[]> {
|
||||
return invoke<number[]>('extract_keyframes', { filePath });
|
||||
}
|
||||
|
||||
export async function extractWaveform(
|
||||
filePath: string,
|
||||
sampleCount: number
|
||||
): Promise<number[]> {
|
||||
return invoke<number[]>('extract_waveform', { filePath, sampleCount });
|
||||
}
|
||||
|
||||
@@ -69,7 +69,9 @@
|
||||
session.duration,
|
||||
clipStore.clips,
|
||||
clipStore.selectedClipId,
|
||||
clipStore.pendingInPoint
|
||||
clipStore.pendingInPoint,
|
||||
session.waveformPeaks,
|
||||
session.thumbnailSpritesheets
|
||||
);
|
||||
ctx.restore();
|
||||
animFrameId = requestAnimationFrame(draw);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { VideoMetadata } from '$lib/bindings/video';
|
||||
import { startDownload } from '$lib/bindings/video';
|
||||
import type { ThumbnailSpritesheet } from '$lib/bindings/mediaAnalysis';
|
||||
import { extractKeyframes } from '$lib/bindings/mediaAnalysis';
|
||||
import { extractKeyframes, extractWaveform } from '$lib/bindings/mediaAnalysis';
|
||||
|
||||
export type SessionStatus = 'idle' | 'resolving' | 'ready' | 'error';
|
||||
export type DownloadStatus = 'idle' | 'downloading' | 'complete' | 'failed';
|
||||
@@ -79,12 +79,22 @@ export function reset() {
|
||||
}
|
||||
|
||||
async function triggerPostDownloadProcessing(filePath: string) {
|
||||
try {
|
||||
session.keyframePositions = await extractKeyframes(filePath);
|
||||
} catch (e) {
|
||||
console.error('Keyframe extraction failed:', e);
|
||||
const [keyframes, waveform] = await Promise.allSettled([
|
||||
extractKeyframes(filePath),
|
||||
extractWaveform(filePath, 8000),
|
||||
]);
|
||||
|
||||
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);
|
||||
}
|
||||
// Waveform and thumbnails will be added in Tasks 10 and 11
|
||||
}
|
||||
|
||||
export async function beginDownload() {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { drawClips } from './clipRenderer';
|
||||
import type { Clip } from '$lib/stores/clips.svelte';
|
||||
import { drawWaveform } from './waveformRenderer';
|
||||
import type { ThumbnailSpritesheet } from '$lib/bindings/mediaAnalysis';
|
||||
|
||||
export interface TimelineState {
|
||||
visibleStart: number;
|
||||
@@ -32,7 +34,9 @@ export function drawTimeline(
|
||||
duration: number,
|
||||
clips: Clip[] = [],
|
||||
selectedClipId: string | null = null,
|
||||
pendingInPoint: number | null = null
|
||||
pendingInPoint: number | null = null,
|
||||
waveformPeaks: number[] = [],
|
||||
_thumbnailSpritesheets: ThumbnailSpritesheet[] = []
|
||||
): void {
|
||||
const { width, height } = state;
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
@@ -43,7 +47,12 @@ export function drawTimeline(
|
||||
drawTimeTicks(ctx, state, duration);
|
||||
|
||||
drawPlaceholderLane(ctx, state, 0, THUMB_LANE_HEIGHT, 'Thumbnails');
|
||||
drawPlaceholderLane(ctx, state, THUMB_LANE_HEIGHT, WAVEFORM_LANE_HEIGHT, 'Waveform');
|
||||
|
||||
if (waveformPeaks.length > 0) {
|
||||
drawWaveform(ctx, state, waveformPeaks, duration, THUMB_LANE_HEIGHT, WAVEFORM_LANE_HEIGHT);
|
||||
} else {
|
||||
drawPlaceholderLane(ctx, state, THUMB_LANE_HEIGHT, WAVEFORM_LANE_HEIGHT, 'Waveform');
|
||||
}
|
||||
|
||||
drawClips(ctx, state, clips, selectedClipId, pendingInPoint);
|
||||
|
||||
|
||||
44
src/lib/timeline/waveformRenderer.ts
Normal file
44
src/lib/timeline/waveformRenderer.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import type { TimelineState } from './renderer';
|
||||
|
||||
export function drawWaveform(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
state: TimelineState,
|
||||
peaks: number[],
|
||||
duration: number,
|
||||
y: number,
|
||||
height: number
|
||||
): void {
|
||||
if (peaks.length === 0 || duration <= 0) return;
|
||||
|
||||
const { width, visibleStart, visibleEnd } = state;
|
||||
const visibleRange = visibleEnd - visibleStart;
|
||||
|
||||
ctx.fillStyle = '#89b4fa44';
|
||||
ctx.strokeStyle = '#89b4fa88';
|
||||
ctx.lineWidth = 1;
|
||||
|
||||
ctx.beginPath();
|
||||
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
|
||||
);
|
||||
|
||||
let maxPeak = 0;
|
||||
for (let i = Math.max(0, startSample); i <= Math.min(endSample, peaks.length - 1); i++) {
|
||||
maxPeak = Math.max(maxPeak, peaks[i]);
|
||||
}
|
||||
|
||||
const barHeight = maxPeak * height;
|
||||
ctx.lineTo(px, y + height - barHeight);
|
||||
}
|
||||
|
||||
ctx.lineTo(width, y + height);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
}
|
||||
Reference in New Issue
Block a user