initial commit

This commit is contained in:
2026-06-08 22:49:50 -07:00
commit f6a6681e16
40 changed files with 3026 additions and 0 deletions
+374
View File
@@ -0,0 +1,374 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { createAudioEngine } from './lib/audio/engine';
import {
isEditableTarget,
keyLabel,
shouldOverrideSelected,
shouldRecordMarker,
} from './lib/keyboard';
import { downloadProject, exportAudio, loadProject } from './lib/project';
import {
addMarker,
app,
deleteMarkers,
nudgeSelected,
selectAll,
setActiveSwitch,
setFps,
setVideoUrl,
snapToFrame,
updateMarker,
} from './lib/store.svelte';
import Timeline from './lib/timeline/Timeline.svelte';
import { SWITCH_OPTIONS } from './lib/types';
import { attachVideoPlayer, type VideoController } from './lib/video/player';
let videoEl: HTMLVideoElement | undefined = $state();
let videoInput: HTMLInputElement | undefined = $state();
let projectInput: HTMLInputElement | undefined = $state();
let controller: VideoController | null = null;
let audioEngine = createAudioEngine();
let statusMessage = $state('');
let lastScheduledTime = -1;
function rescheduleAudio() {
if (!videoEl) return;
audioEngine.reschedule(videoEl.currentTime, app.markers, !videoEl.paused);
lastScheduledTime = videoEl.currentTime;
}
function initVideo() {
if (!videoEl) return;
controller?.destroy();
void audioEngine.init(videoEl).then(() => rescheduleAudio());
controller = attachVideoPlayer(videoEl);
}
$effect(() => {
if (app.videoUrl && videoEl) initVideo();
});
$effect(() => {
if (!videoEl) return;
const t = app.currentTime;
const playing = app.isPlaying;
void app.markers;
void app.activeSwitch;
if (playing && Math.abs(t - lastScheduledTime) > 0.25) {
rescheduleAudio();
}
});
onMount(() => {
const onKeyDown = (event: KeyboardEvent) => {
if (isEditableTarget(event.target)) return;
if (event.ctrlKey && event.code === 'Space') {
event.preventDefault();
controller?.togglePlayPause();
rescheduleAudio();
return;
}
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'a') {
event.preventDefault();
selectAll();
return;
}
if (event.key === 'Backspace' || event.key === 'Delete') {
if (app.selectedIds.size > 0) {
event.preventDefault();
deleteMarkers(new Set(app.selectedIds));
rescheduleAudio();
}
return;
}
if (['ArrowLeft', 'ArrowRight'].includes(event.key) && app.selectedIds.size > 0) {
event.preventDefault();
nudgeSelected(event.key === 'ArrowLeft' ? -1 : 1);
rescheduleAudio();
return;
}
if (shouldOverrideSelected(event, app.selectedIds.size > 0)) {
const label = keyLabel(event);
if (!label) return;
event.preventDefault();
for (const id of app.selectedIds) updateMarker(id, { key: label });
return;
}
if (!app.videoUrl || !videoEl) return;
if (shouldRecordMarker(event)) {
const label = keyLabel(event);
if (!label) return;
event.preventDefault();
const time = snapToFrame(videoEl.currentTime);
addMarker(time, label);
rescheduleAudio();
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
});
onDestroy(() => {
controller?.destroy();
audioEngine.destroy();
});
async function handleVideoFile(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
setVideoUrl(URL.createObjectURL(file), file.name);
statusMessage = `Loaded video: ${file.name}`;
input.value = '';
}
async function handleProjectFile(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
try {
const text = await file.text();
loadProject(JSON.parse(text));
statusMessage = `Loaded project: ${file.name}`;
rescheduleAudio();
} catch (error) {
statusMessage = error instanceof Error ? error.message : 'Failed to load project';
}
input.value = '';
}
async function handleExportAudio() {
if (!app.duration) {
statusMessage = 'Load a video before exporting audio';
return;
}
try {
statusMessage = 'Exporting audio...';
await exportAudio(app.duration);
statusMessage = 'Audio export complete';
} catch (error) {
statusMessage = error instanceof Error ? error.message : 'Export failed';
}
}
</script>
<div class="app">
<header class="toolbar">
<div class="file-actions">
<button type="button" onclick={() => videoInput?.click()}>Open Video</button>
<button type="button" onclick={() => projectInput?.click()}>Open Project</button>
<button type="button" onclick={() => downloadProject()}>Save Project</button>
<button type="button" onclick={handleExportAudio}>Export Audio</button>
</div>
<div class="meta">
{#if app.videoName}
<span>{app.videoName}</span>
{/if}
{#if app.duration}
<label class="fps-control">
FPS
<input
type="number"
min="1"
max="240"
value={app.fps}
onchange={(event) => {
const value = Number(event.currentTarget.value);
if (value >= 1 && value <= 240) setFps(value);
}}
/>
</label>
{/if}
{#if statusMessage}
<span class="status">{statusMessage}</span>
{/if}
</div>
<input bind:this={videoInput} type="file" accept="video/mp4,video/*" hidden onchange={handleVideoFile} />
<input bind:this={projectInput} type="file" accept="application/json,.json" hidden onchange={handleProjectFile} />
</header>
<section class="player-section">
{#if app.videoUrl}
<!-- svelte-ignore a11y_media_has_caption -->
<video
bind:this={videoEl}
src={app.videoUrl}
controls={false}
onplay={rescheduleAudio}
onpause={rescheduleAudio}
onseeked={rescheduleAudio}
></video>
{:else}
<div class="placeholder">Open an MP4 video to begin annotating key presses</div>
{/if}
</section>
<section class="transport">
<button
type="button"
class="play-btn"
disabled={!app.videoUrl}
onclick={() => {
controller?.togglePlayPause();
rescheduleAudio();
}}
>
{app.isPlaying ? 'Pause' : 'Play'}
</button>
<span class="shortcut-hint">Ctrl+Space</span>
<label class="switch-select">
Switch
<select
value={app.activeSwitch}
onchange={(event) => {
setActiveSwitch(event.currentTarget.value as typeof app.activeSwitch);
rescheduleAudio();
}}
>
{#each SWITCH_OPTIONS as option}
<option value={option.id}>{option.label}</option>
{/each}
</select>
</label>
</section>
<Timeline
onSeek={(time) => {
controller?.seek(time);
rescheduleAudio();
}}
onReschedule={rescheduleAudio}
/>
</div>
<style>
:global(body) {
margin: 0;
background: #111;
color: #eee;
font-family: Inter, system-ui, sans-serif;
}
.app {
min-height: 100vh;
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
box-sizing: border-box;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
align-items: center;
justify-content: space-between;
}
.file-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
button,
select {
background: #2d3748;
color: #edf2f7;
border: 1px solid #4a5568;
border-radius: 6px;
padding: 0.45rem 0.75rem;
cursor: pointer;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.meta {
display: flex;
gap: 1rem;
font-size: 0.85rem;
color: #a0aec0;
}
.status {
color: #68d391;
}
.player-section {
background: #000;
border-radius: 8px;
overflow: hidden;
min-height: 320px;
display: flex;
align-items: center;
justify-content: center;
}
video {
width: 100%;
max-height: 60vh;
display: block;
background: #000;
}
.placeholder {
color: #718096;
padding: 3rem;
text-align: center;
}
.transport {
display: flex;
align-items: center;
gap: 0.75rem;
}
.play-btn {
min-width: 5rem;
}
.shortcut-hint {
font-size: 0.8rem;
color: #a0aec0;
padding: 0.2rem 0.5rem;
border: 1px dashed #4a5568;
border-radius: 4px;
}
.switch-select {
margin-left: auto;
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
}
.fps-control {
display: flex;
align-items: center;
gap: 0.35rem;
}
.fps-control input {
width: 4rem;
background: #2d3748;
color: #edf2f7;
border: 1px solid #4a5568;
border-radius: 4px;
padding: 0.2rem 0.35rem;
}
</style>
+14
View File
@@ -0,0 +1,14 @@
* {
box-sizing: border-box;
}
body {
margin: 0;
}
button:focus-visible,
input:focus-visible,
select:focus-visible {
outline: 2px solid #63b3ed;
outline-offset: 2px;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>

After

Width:  |  Height:  |  Size: 1.9 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

+10
View File
@@ -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>
+99
View File
@@ -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();
},
};
}
+47
View File
@@ -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;
}
+56
View File
@@ -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);
}
+132
View File
@@ -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);
}
+353
View File
@@ -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>
+30
View File
@@ -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;
}
+23
View File
@@ -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;
+103
View File
@@ -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);
},
};
}
+9
View File
@@ -0,0 +1,9 @@
import { mount } from 'svelte'
import './app.css'
import App from './App.svelte'
const app = mount(App, {
target: document.getElementById('app')!,
})
export default app