initial commit
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
<script lang="ts">
|
||||
let count: number = $state(0)
|
||||
const increment = () => {
|
||||
count += 1
|
||||
}
|
||||
</script>
|
||||
|
||||
<button type="button" class="counter" onclick={increment}>
|
||||
Count is {count}
|
||||
</button>
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { Marker, SwitchType } from '../types';
|
||||
import { app } from '../store.svelte';
|
||||
|
||||
const SAMPLE_PATHS: Record<SwitchType, string> = {
|
||||
'cherry-mx-blue': '/assets/samples/cherry-mx-blue.wav',
|
||||
'cherry-mx-red': '/assets/samples/cherry-mx-red.wav',
|
||||
'cherry-mx-brown': '/assets/samples/cherry-mx-brown.wav',
|
||||
};
|
||||
|
||||
export type AudioEngine = {
|
||||
init: (video: HTMLVideoElement) => Promise<void>;
|
||||
reschedule: (currentTime: number, markers: Marker[], isPlaying: boolean) => void;
|
||||
destroy: () => void;
|
||||
};
|
||||
|
||||
export function createAudioEngine(): AudioEngine {
|
||||
let ctx: AudioContext | null = null;
|
||||
let videoSource: MediaElementAudioSourceNode | null = null;
|
||||
let videoGain: GainNode | null = null;
|
||||
const buffers = new Map<SwitchType, AudioBuffer>();
|
||||
const scheduledSources: AudioBufferSourceNode[] = [];
|
||||
|
||||
async function ensureContext() {
|
||||
if (!ctx) ctx = new AudioContext();
|
||||
if (ctx.state === 'suspended') await ctx.resume();
|
||||
return ctx;
|
||||
}
|
||||
|
||||
async function loadBuffer(switchType: SwitchType): Promise<AudioBuffer> {
|
||||
const cached = buffers.get(switchType);
|
||||
if (cached) return cached;
|
||||
const audioCtx = await ensureContext();
|
||||
const response = await fetch(SAMPLE_PATHS[switchType]);
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const audioBuffer = await audioCtx.decodeAudioData(arrayBuffer);
|
||||
buffers.set(switchType, audioBuffer);
|
||||
return audioBuffer;
|
||||
}
|
||||
|
||||
function clearScheduled() {
|
||||
for (const source of scheduledSources) {
|
||||
try {
|
||||
source.stop();
|
||||
} catch {
|
||||
// already stopped
|
||||
}
|
||||
}
|
||||
scheduledSources.length = 0;
|
||||
}
|
||||
|
||||
return {
|
||||
async init(video: HTMLVideoElement) {
|
||||
const audioCtx = await ensureContext();
|
||||
if (!videoSource) {
|
||||
videoSource = audioCtx.createMediaElementSource(video);
|
||||
videoGain = audioCtx.createGain();
|
||||
videoSource.connect(videoGain);
|
||||
videoGain.connect(audioCtx.destination);
|
||||
}
|
||||
await Promise.all(
|
||||
(Object.keys(SAMPLE_PATHS) as SwitchType[]).map((id) => loadBuffer(id)),
|
||||
);
|
||||
},
|
||||
|
||||
reschedule(time: number, markers: Marker[], playing: boolean) {
|
||||
if (!ctx || !playing) {
|
||||
clearScheduled();
|
||||
return;
|
||||
}
|
||||
|
||||
clearScheduled();
|
||||
const audioCtx = ctx;
|
||||
const startAt = audioCtx.currentTime + 0.02;
|
||||
const buffer = buffers.get(app.activeSwitch);
|
||||
if (!buffer) return;
|
||||
|
||||
for (const marker of markers) {
|
||||
if (marker.time < time - 0.05) continue;
|
||||
const delay = marker.time - time;
|
||||
const source = audioCtx.createBufferSource();
|
||||
source.buffer = buffer;
|
||||
source.connect(audioCtx.destination);
|
||||
source.start(startAt + Math.max(0, delay));
|
||||
scheduledSources.push(source);
|
||||
}
|
||||
},
|
||||
|
||||
destroy() {
|
||||
clearScheduled();
|
||||
videoSource?.disconnect();
|
||||
videoGain?.disconnect();
|
||||
void ctx?.close();
|
||||
ctx = null;
|
||||
videoSource = null;
|
||||
videoGain = null;
|
||||
buffers.clear();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
const MODIFIER_KEYS = new Set([
|
||||
'Control',
|
||||
'Shift',
|
||||
'Alt',
|
||||
'Meta',
|
||||
'CapsLock',
|
||||
'Tab',
|
||||
'Escape',
|
||||
]);
|
||||
|
||||
const IGNORED_KEYS = new Set([
|
||||
...MODIFIER_KEYS,
|
||||
'ArrowUp',
|
||||
'ArrowDown',
|
||||
'ArrowLeft',
|
||||
'ArrowRight',
|
||||
'Backspace',
|
||||
'Delete',
|
||||
'Enter',
|
||||
' ',
|
||||
]);
|
||||
|
||||
export function isEditableTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
const tag = target.tagName;
|
||||
return tag === 'INPUT' || tag === 'SELECT' || tag === 'TEXTAREA' || target.isContentEditable;
|
||||
}
|
||||
|
||||
export function keyLabel(event: KeyboardEvent): string | null {
|
||||
if (event.ctrlKey && event.code === 'Space') return null;
|
||||
if (IGNORED_KEYS.has(event.key)) return null;
|
||||
if (event.key.length === 1) return event.key;
|
||||
if (event.key === ' ') return 'Space';
|
||||
return event.key;
|
||||
}
|
||||
|
||||
export function shouldOverrideSelected(event: KeyboardEvent, hasSelection: boolean): boolean {
|
||||
if (!hasSelection) return false;
|
||||
if (event.ctrlKey || event.metaKey || event.altKey) return false;
|
||||
if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) return false;
|
||||
return keyLabel(event) !== null;
|
||||
}
|
||||
|
||||
export function shouldRecordMarker(event: KeyboardEvent): boolean {
|
||||
if (event.ctrlKey || event.metaKey || event.altKey) return false;
|
||||
return keyLabel(event) !== null;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { Project } from './types';
|
||||
import { app, setActiveSwitch, setMarkers, setSelectedIds } from './store.svelte';
|
||||
|
||||
export function buildProject(): Project {
|
||||
return {
|
||||
version: 1,
|
||||
switch: app.activeSwitch,
|
||||
markers: app.markers.map((m) => ({ ...m })),
|
||||
};
|
||||
}
|
||||
|
||||
export function downloadProject(filename = 'project.json') {
|
||||
const project = buildProject();
|
||||
const blob = new Blob([JSON.stringify(project, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = filename;
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function loadProject(data: Project) {
|
||||
if (data.version !== 1) {
|
||||
throw new Error(`Unsupported project version: ${data.version}`);
|
||||
}
|
||||
setMarkers(data.markers.map((m) => ({ ...m })));
|
||||
setActiveSwitch(data.switch);
|
||||
setSelectedIds(new Set());
|
||||
}
|
||||
|
||||
export async function exportAudio(duration: number) {
|
||||
const project = buildProject();
|
||||
const response = await fetch('/api/export/audio', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
duration,
|
||||
switch: project.switch,
|
||||
markers: project.markers,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text();
|
||||
throw new Error(detail || 'Audio export failed');
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const url = URL.createObjectURL(blob);
|
||||
const anchor = document.createElement('a');
|
||||
anchor.href = url;
|
||||
anchor.download = 'keyboard_track.wav';
|
||||
anchor.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
import type { Marker, SwitchType } from './types';
|
||||
import { ZOOM_DEFAULT } from './types';
|
||||
|
||||
export const app = $state({
|
||||
markers: [] as Marker[],
|
||||
selectedIds: new Set<string>(),
|
||||
isPlaying: false,
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
fps: 30,
|
||||
pixelsPerSecond: ZOOM_DEFAULT,
|
||||
scrollLeft: 0,
|
||||
activeSwitch: 'cherry-mx-blue' as SwitchType,
|
||||
videoUrl: null as string | null,
|
||||
videoName: null as string | null,
|
||||
timelineWidth: 800,
|
||||
});
|
||||
|
||||
let markerCounter = 0;
|
||||
|
||||
export function createMarkerId(): string {
|
||||
markerCounter += 1;
|
||||
return `m${markerCounter}`;
|
||||
}
|
||||
|
||||
export function setMarkers(value: Marker[]) {
|
||||
app.markers = value;
|
||||
const maxId = value.reduce((max, m) => {
|
||||
const num = parseInt(m.id.replace(/\D/g, ''), 10);
|
||||
return Number.isFinite(num) ? Math.max(max, num) : max;
|
||||
}, 0);
|
||||
markerCounter = maxId;
|
||||
}
|
||||
|
||||
export function setSelectedIds(ids: Set<string>) {
|
||||
app.selectedIds = ids;
|
||||
}
|
||||
|
||||
export function setCurrentTime(value: number) {
|
||||
app.currentTime = value;
|
||||
}
|
||||
|
||||
export function setDuration(value: number) {
|
||||
app.duration = value;
|
||||
}
|
||||
|
||||
export function setIsPlaying(value: boolean) {
|
||||
app.isPlaying = value;
|
||||
}
|
||||
|
||||
export function setFps(value: number) {
|
||||
app.fps = value;
|
||||
}
|
||||
|
||||
export function setPixelsPerSecond(value: number) {
|
||||
app.pixelsPerSecond = value;
|
||||
}
|
||||
|
||||
export function setScrollLeft(value: number) {
|
||||
app.scrollLeft = value;
|
||||
}
|
||||
|
||||
export function setActiveSwitch(value: SwitchType) {
|
||||
app.activeSwitch = value;
|
||||
}
|
||||
|
||||
export function setTimelineWidth(value: number) {
|
||||
app.timelineWidth = value;
|
||||
}
|
||||
|
||||
export function addMarker(time: number, key: string): Marker {
|
||||
const marker: Marker = { id: createMarkerId(), time, key };
|
||||
app.markers = [...app.markers, marker].sort((a, b) => a.time - b.time);
|
||||
return marker;
|
||||
}
|
||||
|
||||
export function updateMarker(id: string, patch: Partial<Pick<Marker, 'time' | 'key'>>) {
|
||||
app.markers = app.markers
|
||||
.map((m) => (m.id === id ? { ...m, ...patch } : m))
|
||||
.sort((a, b) => a.time - b.time);
|
||||
}
|
||||
|
||||
export function setMarkerTimes(updates: Map<string, number>) {
|
||||
app.markers = app.markers
|
||||
.map((m) => {
|
||||
const time = updates.get(m.id);
|
||||
return time === undefined ? m : { ...m, time };
|
||||
})
|
||||
.sort((a, b) => a.time - b.time);
|
||||
}
|
||||
|
||||
export function deleteMarkers(ids: Set<string>) {
|
||||
app.markers = app.markers.filter((m) => !ids.has(m.id));
|
||||
const next = new Set(app.selectedIds);
|
||||
for (const id of ids) next.delete(id);
|
||||
app.selectedIds = next;
|
||||
}
|
||||
|
||||
export function selectAll() {
|
||||
app.selectedIds = new Set(app.markers.map((m) => m.id));
|
||||
}
|
||||
|
||||
export function toggleSelection(id: string, additive: boolean) {
|
||||
const next = additive ? new Set(app.selectedIds) : new Set<string>();
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
app.selectedIds = next;
|
||||
}
|
||||
|
||||
export function setVideoUrl(url: string | null, name: string | null = null) {
|
||||
if (app.videoUrl) URL.revokeObjectURL(app.videoUrl);
|
||||
app.videoUrl = url;
|
||||
app.videoName = name;
|
||||
}
|
||||
|
||||
export function snapToFrame(time: number): number {
|
||||
const frameDuration = 1 / app.fps;
|
||||
return Math.round(time / frameDuration) * frameDuration;
|
||||
}
|
||||
|
||||
export function nudgeSelected(deltaFrames: number) {
|
||||
if (app.selectedIds.size === 0) return;
|
||||
const frameDuration = 1 / app.fps;
|
||||
const delta = deltaFrames * frameDuration;
|
||||
app.markers = app.markers
|
||||
.map((m) => {
|
||||
if (!app.selectedIds.has(m.id)) return m;
|
||||
const next = Math.max(0, Math.min(app.duration || Infinity, m.time + delta));
|
||||
return { ...m, time: snapToFrame(next) };
|
||||
})
|
||||
.sort((a, b) => a.time - b.time);
|
||||
}
|
||||
@@ -0,0 +1,353 @@
|
||||
<script lang="ts">
|
||||
import {
|
||||
app,
|
||||
setMarkerTimes,
|
||||
setPixelsPerSecond,
|
||||
setScrollLeft,
|
||||
setSelectedIds,
|
||||
setTimelineWidth,
|
||||
snapToFrame,
|
||||
toggleSelection,
|
||||
} from '../store.svelte';
|
||||
import { ZOOM_MAX, ZOOM_MIN } from '../types';
|
||||
import {
|
||||
centerScrollOnPlayhead,
|
||||
formatTime,
|
||||
pxToTime,
|
||||
tickInterval,
|
||||
timeToPx,
|
||||
} from './math';
|
||||
|
||||
interface Props {
|
||||
onSeek?: (time: number) => void;
|
||||
onReschedule?: () => void;
|
||||
}
|
||||
|
||||
let { onSeek, onReschedule }: Props = $props();
|
||||
|
||||
let viewportEl: HTMLDivElement | undefined = $state();
|
||||
let dragMode = $state<'none' | 'marker' | 'marquee' | 'move'>('none');
|
||||
let dragMarkerId = $state<string | null>(null);
|
||||
let dragStartX = 0;
|
||||
let marqueeStart = 0;
|
||||
let marqueeEnd = 0;
|
||||
let moveStartTimes = new Map<string, number>();
|
||||
|
||||
function updateScrollCenter() {
|
||||
setScrollLeft(
|
||||
centerScrollOnPlayhead(app.currentTime, app.pixelsPerSecond, app.timelineWidth),
|
||||
);
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
void app.currentTime;
|
||||
void app.pixelsPerSecond;
|
||||
if (app.duration > 0 && viewportEl) updateScrollCenter();
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
if (!viewportEl) return;
|
||||
const observer = new ResizeObserver(() => {
|
||||
setTimelineWidth(viewportEl!.clientWidth);
|
||||
});
|
||||
observer.observe(viewportEl);
|
||||
setTimelineWidth(viewportEl.clientWidth);
|
||||
return () => observer.disconnect();
|
||||
});
|
||||
|
||||
function clampZoom(value: number) {
|
||||
return Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, value));
|
||||
}
|
||||
|
||||
function handleWheel(event: WheelEvent) {
|
||||
if (!event.altKey) return;
|
||||
event.preventDefault();
|
||||
const factor = event.deltaY < 0 ? 1.15 : 1 / 1.15;
|
||||
setPixelsPerSecond(clampZoom(app.pixelsPerSecond * factor));
|
||||
updateScrollCenter();
|
||||
}
|
||||
|
||||
function localX(event: PointerEvent): number {
|
||||
if (!viewportEl) return 0;
|
||||
const rect = viewportEl.getBoundingClientRect();
|
||||
return event.clientX - rect.left + app.scrollLeft;
|
||||
}
|
||||
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
if (!viewportEl) return;
|
||||
const x = localX(event);
|
||||
const time = pxToTime(x, app.pixelsPerSecond);
|
||||
const target = event.target as HTMLElement;
|
||||
const markerEl = target.closest('[data-marker-id]') as HTMLElement | null;
|
||||
|
||||
if (markerEl?.dataset.markerId) {
|
||||
const id = markerEl.dataset.markerId;
|
||||
toggleSelection(id, event.shiftKey);
|
||||
dragMode = app.selectedIds.has(id) ? 'move' : 'marker';
|
||||
dragMarkerId = id;
|
||||
dragStartX = x;
|
||||
moveStartTimes = new Map(
|
||||
app.markers.filter((m) => app.selectedIds.has(m.id)).map((m) => [m.id, m.time]),
|
||||
);
|
||||
viewportEl.setPointerCapture(event.pointerId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!event.shiftKey) setSelectedIds(new Set());
|
||||
dragMode = 'marquee';
|
||||
marqueeStart = time;
|
||||
marqueeEnd = time;
|
||||
viewportEl.setPointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
if (dragMode === 'none' || !viewportEl) return;
|
||||
const x = localX(event);
|
||||
|
||||
if (dragMode === 'marquee') {
|
||||
marqueeEnd = pxToTime(x, app.pixelsPerSecond);
|
||||
return;
|
||||
}
|
||||
|
||||
if (dragMode === 'move' && moveStartTimes.size > 0) {
|
||||
const deltaTime = pxToTime(x - dragStartX, app.pixelsPerSecond);
|
||||
const updates = new Map<string, number>();
|
||||
for (const [id, start] of moveStartTimes) {
|
||||
const next = Math.max(0, Math.min(app.duration || Infinity, start + deltaTime));
|
||||
updates.set(id, snapToFrame(next));
|
||||
}
|
||||
setMarkerTimes(updates);
|
||||
return;
|
||||
}
|
||||
|
||||
if (dragMode === 'marker' && dragMarkerId) {
|
||||
const time = snapToFrame(
|
||||
Math.max(0, Math.min(app.duration || Infinity, pxToTime(x, app.pixelsPerSecond))),
|
||||
);
|
||||
setMarkerTimes(new Map([[dragMarkerId, time]]));
|
||||
}
|
||||
}
|
||||
|
||||
function handlePointerUp(event: PointerEvent) {
|
||||
if (dragMode === 'marquee') {
|
||||
const minT = Math.min(marqueeStart, marqueeEnd);
|
||||
const maxT = Math.max(marqueeStart, marqueeEnd);
|
||||
const hits = app.markers.filter((m) => m.time >= minT && m.time <= maxT).map((m) => m.id);
|
||||
setSelectedIds(new Set(hits));
|
||||
}
|
||||
|
||||
if (dragMode === 'move' || dragMode === 'marker') {
|
||||
onReschedule?.();
|
||||
}
|
||||
|
||||
dragMode = 'none';
|
||||
dragMarkerId = null;
|
||||
moveStartTimes.clear();
|
||||
viewportEl?.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
function handleRulerClick(event: MouseEvent) {
|
||||
if (dragMode !== 'none') return;
|
||||
const x = localX(event as unknown as PointerEvent);
|
||||
const time = snapToFrame(
|
||||
Math.max(0, Math.min(app.duration || Infinity, pxToTime(x, app.pixelsPerSecond))),
|
||||
);
|
||||
onSeek?.(time);
|
||||
}
|
||||
|
||||
function handleZoomInput(event: Event) {
|
||||
const value = Number((event.currentTarget as HTMLInputElement).value);
|
||||
setPixelsPerSecond(value);
|
||||
updateScrollCenter();
|
||||
}
|
||||
|
||||
const contentWidth = $derived(
|
||||
Math.max(app.timelineWidth, timeToPx(app.duration || 1, app.pixelsPerSecond) + 40),
|
||||
);
|
||||
const ticks = $derived.by(() => {
|
||||
const interval = tickInterval(app.pixelsPerSecond);
|
||||
const count = Math.ceil((app.duration || 1) / interval);
|
||||
return Array.from({ length: count + 1 }, (_, i) => i * interval);
|
||||
});
|
||||
const playheadX = $derived(timeToPx(app.currentTime, app.pixelsPerSecond));
|
||||
const marqueeVisible = $derived(dragMode === 'marquee');
|
||||
const marqueeLeft = $derived(timeToPx(Math.min(marqueeStart, marqueeEnd), app.pixelsPerSecond));
|
||||
const marqueeWidth = $derived(
|
||||
Math.abs(
|
||||
timeToPx(marqueeEnd, app.pixelsPerSecond) - timeToPx(marqueeStart, app.pixelsPerSecond),
|
||||
),
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="timeline-panel">
|
||||
<div class="zoom-row">
|
||||
<label for="zoom-slider">Zoom</label>
|
||||
<input
|
||||
id="zoom-slider"
|
||||
type="range"
|
||||
min={ZOOM_MIN}
|
||||
max={ZOOM_MAX}
|
||||
value={app.pixelsPerSecond}
|
||||
oninput={handleZoomInput}
|
||||
/>
|
||||
<span class="hint">Alt + scroll to zoom</span>
|
||||
</div>
|
||||
|
||||
<div class="timeline-viewport" bind:this={viewportEl} onwheel={handleWheel}>
|
||||
<div
|
||||
class="timeline-content"
|
||||
style="width: {contentWidth}px; transform: translateX({-app.scrollLeft}px);"
|
||||
>
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="ruler"
|
||||
onclick={handleRulerClick}
|
||||
onpointerdown={handlePointerDown}
|
||||
onpointermove={handlePointerMove}
|
||||
onpointerup={handlePointerUp}
|
||||
>
|
||||
{#each ticks as tick}
|
||||
<div class="tick" style="left: {timeToPx(tick, app.pixelsPerSecond)}px">
|
||||
<span>{formatTime(tick)}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
||||
<div
|
||||
class="track"
|
||||
onpointerdown={handlePointerDown}
|
||||
onpointermove={handlePointerMove}
|
||||
onpointerup={handlePointerUp}
|
||||
>
|
||||
{#each app.markers as marker (marker.id)}
|
||||
<button
|
||||
type="button"
|
||||
class="marker"
|
||||
class:selected={app.selectedIds.has(marker.id)}
|
||||
data-marker-id={marker.id}
|
||||
style="left: {timeToPx(marker.time, app.pixelsPerSecond)}px"
|
||||
title="{marker.key} @ {formatTime(marker.time)}"
|
||||
>
|
||||
{marker.key}
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
{#if marqueeVisible}
|
||||
<div class="marquee" style="left: {marqueeLeft}px; width: {marqueeWidth}px"></div>
|
||||
{/if}
|
||||
|
||||
<div class="playhead" style="left: {playheadX}px"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.timeline-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
border-top: 1px solid #333;
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
|
||||
.zoom-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.zoom-row input[type='range'] {
|
||||
flex: 1;
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.timeline-viewport {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
height: 120px;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #333;
|
||||
border-radius: 6px;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.timeline-content {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.ruler {
|
||||
position: relative;
|
||||
height: 28px;
|
||||
border-bottom: 1px solid #333;
|
||||
background: #222;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tick {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
border-left: 1px solid #444;
|
||||
padding-left: 4px;
|
||||
font-size: 0.65rem;
|
||||
color: #888;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.track {
|
||||
position: relative;
|
||||
height: calc(100% - 28px);
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.marker {
|
||||
position: absolute;
|
||||
top: 18px;
|
||||
transform: translateX(-50%);
|
||||
padding: 0.2rem 0.45rem;
|
||||
border: 1px solid #555;
|
||||
border-radius: 4px;
|
||||
background: #2d3748;
|
||||
color: #e2e8f0;
|
||||
font-size: 0.75rem;
|
||||
cursor: grab;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.marker.selected {
|
||||
background: #3182ce;
|
||||
border-color: #63b3ed;
|
||||
box-shadow: 0 0 0 1px #63b3ed;
|
||||
}
|
||||
|
||||
.marquee {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
height: 72px;
|
||||
background: rgba(99, 179, 237, 0.2);
|
||||
border: 1px solid #63b3ed;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.playhead {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: #f56565;
|
||||
pointer-events: none;
|
||||
z-index: 2;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,30 @@
|
||||
export function timeToPx(time: number, pixelsPerSecond: number): number {
|
||||
return time * pixelsPerSecond;
|
||||
}
|
||||
|
||||
export function pxToTime(px: number, pixelsPerSecond: number): number {
|
||||
return px / pixelsPerSecond;
|
||||
}
|
||||
|
||||
export function centerScrollOnPlayhead(
|
||||
playheadTime: number,
|
||||
pixelsPerSecond: number,
|
||||
viewportWidth: number,
|
||||
): number {
|
||||
const playheadPx = timeToPx(playheadTime, pixelsPerSecond);
|
||||
return Math.max(0, playheadPx - viewportWidth / 2);
|
||||
}
|
||||
|
||||
export function formatTime(seconds: number): string {
|
||||
const mins = Math.floor(seconds / 60);
|
||||
const secs = seconds % 60;
|
||||
return `${mins}:${secs.toFixed(2).padStart(5, '0')}`;
|
||||
}
|
||||
|
||||
export function tickInterval(pixelsPerSecond: number): number {
|
||||
if (pixelsPerSecond >= 200) return 0.1;
|
||||
if (pixelsPerSecond >= 100) return 0.5;
|
||||
if (pixelsPerSecond >= 50) return 1;
|
||||
if (pixelsPerSecond >= 25) return 2;
|
||||
return 5;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export type SwitchType = 'cherry-mx-blue' | 'cherry-mx-red' | 'cherry-mx-brown';
|
||||
|
||||
export interface Marker {
|
||||
id: string;
|
||||
time: number;
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
version: number;
|
||||
switch: SwitchType;
|
||||
markers: Marker[];
|
||||
}
|
||||
|
||||
export const SWITCH_OPTIONS: { id: SwitchType; label: string }[] = [
|
||||
{ id: 'cherry-mx-blue', label: 'Cherry MX Blue (clicky)' },
|
||||
{ id: 'cherry-mx-red', label: 'Cherry MX Red (linear)' },
|
||||
{ id: 'cherry-mx-brown', label: 'Cherry MX Brown (tactile)' },
|
||||
];
|
||||
|
||||
export const ZOOM_MIN = 20;
|
||||
export const ZOOM_MAX = 400;
|
||||
export const ZOOM_DEFAULT = 80;
|
||||
@@ -0,0 +1,103 @@
|
||||
import { app, setCurrentTime, setDuration, setFps, setIsPlaying, snapToFrame } from '../store.svelte';
|
||||
|
||||
export type VideoController = {
|
||||
element: HTMLVideoElement;
|
||||
destroy: () => void;
|
||||
togglePlayPause: () => void;
|
||||
stepFrame: (delta: number) => void;
|
||||
seek: (time: number) => void;
|
||||
};
|
||||
|
||||
export function attachVideoPlayer(video: HTMLVideoElement): VideoController {
|
||||
let rafId = 0;
|
||||
let lastFrameTime: number | null = null;
|
||||
const frameDeltas: number[] = [];
|
||||
|
||||
const onLoadedMetadata = () => {
|
||||
setDuration(video.duration);
|
||||
setCurrentTime(video.currentTime);
|
||||
};
|
||||
|
||||
const onPlay = () => setIsPlaying(true);
|
||||
const onPause = () => setIsPlaying(false);
|
||||
const onSeeked = () => setCurrentTime(video.currentTime);
|
||||
const onEnded = () => setIsPlaying(false);
|
||||
|
||||
const detectFps = (mediaTime: number) => {
|
||||
if (lastFrameTime !== null) {
|
||||
const delta = mediaTime - lastFrameTime;
|
||||
if (delta > 0.001 && delta < 0.2) {
|
||||
frameDeltas.push(delta);
|
||||
if (frameDeltas.length > 30) frameDeltas.shift();
|
||||
if (frameDeltas.length >= 10) {
|
||||
const avg = frameDeltas.reduce((a, b) => a + b, 0) / frameDeltas.length;
|
||||
const detected = Math.round(1 / avg);
|
||||
if (detected >= 10 && detected <= 120) setFps(detected);
|
||||
}
|
||||
}
|
||||
}
|
||||
lastFrameTime = mediaTime;
|
||||
};
|
||||
|
||||
const frameLoop = (_now: number, metadata: VideoFrameCallbackMetadata) => {
|
||||
setCurrentTime(metadata.mediaTime);
|
||||
detectFps(metadata.mediaTime);
|
||||
if (!video.paused && !video.ended) {
|
||||
video.requestVideoFrameCallback(frameLoop);
|
||||
}
|
||||
};
|
||||
|
||||
const timeLoop = () => {
|
||||
if (!video.paused) {
|
||||
setCurrentTime(video.currentTime);
|
||||
rafId = requestAnimationFrame(timeLoop);
|
||||
}
|
||||
};
|
||||
|
||||
const onPlayStart = () => {
|
||||
if ('requestVideoFrameCallback' in video) {
|
||||
video.requestVideoFrameCallback(frameLoop);
|
||||
} else {
|
||||
rafId = requestAnimationFrame(timeLoop);
|
||||
}
|
||||
};
|
||||
|
||||
video.addEventListener('loadedmetadata', onLoadedMetadata);
|
||||
video.addEventListener('play', onPlay);
|
||||
video.addEventListener('play', onPlayStart);
|
||||
video.addEventListener('pause', onPause);
|
||||
video.addEventListener('seeked', onSeeked);
|
||||
video.addEventListener('ended', onEnded);
|
||||
|
||||
if (video.readyState >= 1) onLoadedMetadata();
|
||||
|
||||
return {
|
||||
element: video,
|
||||
destroy() {
|
||||
cancelAnimationFrame(rafId);
|
||||
video.removeEventListener('loadedmetadata', onLoadedMetadata);
|
||||
video.removeEventListener('play', onPlay);
|
||||
video.removeEventListener('play', onPlayStart);
|
||||
video.removeEventListener('pause', onPause);
|
||||
video.removeEventListener('seeked', onSeeked);
|
||||
video.removeEventListener('ended', onEnded);
|
||||
},
|
||||
togglePlayPause() {
|
||||
if (video.paused || video.ended) void video.play();
|
||||
else video.pause();
|
||||
},
|
||||
stepFrame(delta: number) {
|
||||
video.pause();
|
||||
const next = snapToFrame(
|
||||
Math.max(0, Math.min(video.duration || 0, video.currentTime + delta / app.fps)),
|
||||
);
|
||||
video.currentTime = next;
|
||||
setCurrentTime(next);
|
||||
},
|
||||
seek(time: number) {
|
||||
const clamped = Math.max(0, Math.min(video.duration || 0, time));
|
||||
video.currentTime = clamped;
|
||||
setCurrentTime(clamped);
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user