Files
gpuix/examples/timeline-data.ts
T
Tommy D. Rossi 813ece49e0 examples: a video-editor timeline
```bash
cd examples && bun --hot timeline.tsx
```

The question was whether GPUIX can carry a real editing surface, so the
example drags clips between tracks, trims both edges with snapping,
scrubs a playhead, marquee-selects, zooms under the pointer, and pans on
both axes with a frozen ruler and a frozen track column. Data is mocked
from a seeded generator, so every run paints the same project.

```
┌──────────────────────┬─────────────────────────────────────────────┐
│ tools    00:03:50    │ Ruler   0    5    10   15   20   25         │ ◄ pans X
├──────────────────────┼─────────────────────────────────────────────┤
│ Caption              │ ▓▓▓▓  ▓▓▓      ▓▓▓▓▓▓▓                      │
│ ▸ Track 10           │ █████████████ ▎playhead                     │ ◄ pans X and Y
│ ▸ Track 9            │        ▓▓▓▓   ▓▓                            │
│ Audio 1              │ ∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿∿                      │
│        ▲ pans Y      │                                             │
└──────────────────────┴─────────────────────────────────────────────┘
```

Two patterns in it are worth copying.

**React owns the scroll offset.** A native `overflow: "scroll"` grid
cannot drive a frozen header: GPUI moves the grid on the wheel frame,
and the `onScroll` callback that would move the ruler lands a frame
later, so the two tear apart during a fast pan. One `onScroll` listener
on a non-scrolling parent keeps `scrollX` and `scrollY` in state, and
all three panes translate from the same numbers. Zed does the same. A
media-bin panel beside the preview stays on the native two-axis path for
comparison.

Each translating wrapper sets `pointerEvents: "none"`, because a
positioned box takes hits with no fill and would otherwise swallow every
press meant for the ruler or the empty grid behind it.

**A drag needs no overlay.** Every clip, trim handle, the ruler, the grid
and the zoom slider carry `onMouseDown`, `onMouseMove` and `onMouseUp`,
which arms GPUI's pointer capture, so a release past the window edge
still ends the gesture. A full-window overlay mounted on the press cannot
do that: capture is armed by the press, and the overlay does not exist
yet at that moment. I built the overlay first and it failed exactly that
test.

`timeline.test.tsx` drives all of it through the automation API and
asserts exact numbers read from three `<text testId>` readouts, not
screenshots. `timeline.perf.test.tsx` times mount, pan and drag on a
3,259-clip, 26-track project:

| Sample | p95 |
|---|---|
| mount | 131ms |
| pan, culling on | 1.90ms |
| pan, culling off | 0.64ms |
| drag move | 11.7ms |

Drag samples are not comparable to pan samples.
`nativeSimulateMouseMove` flushes before and after the event, so each one
contains two complete GPUI paints.

Session: ses_fc1c6df4bffewVGSCAfO0JZ4nT
2026-08-26 16:40:21 +02:00

180 lines
5.2 KiB
TypeScript

/**
* Mocked project data for the video-editor timeline example.
*
* Everything is generated from a seeded PRNG, so a test asserting on a clip
* name, a start time or a waveform bar gets the same answer on every machine.
* `Math.random` here would make every screenshot and every snapshot flaky.
*/
export type ClipKind = 'video' | 'text' | 'shape' | 'audio' | 'caption'
export interface Clip {
id: string
trackId: string
kind: ClipKind
label: string
/** Seconds from the start of the project. */
start: number
/** Seconds. Never below MIN_CLIP_DURATION. */
duration: number
}
export interface Track {
id: string
name: string
kind: ClipKind
/** Audio tracks paint a waveform and are taller. */
tall: boolean
}
export interface Project {
name: string
durationSeconds: number
tracks: Track[]
clips: Clip[]
/** One 0..1 amplitude per 1/8 second of the project, for audio tracks. */
waveform: number[]
}
export const MIN_CLIP_DURATION = 0.4
export const WAVEFORM_HZ = 8
/** Mulberry32. Small, fast, and identical in every JavaScript runtime. */
function seededRandom(seed: number): () => number {
let state = seed >>> 0
return () => {
state = (state + 0x6d2b79f5) >>> 0
let t = state
t = Math.imul(t ^ (t >>> 15), t | 1)
t ^= t + Math.imul(t ^ (t >>> 7), t | 61)
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
const CAPTION_LINES = [
'We are introducing text animations',
'Two weeks ago',
'Word by word, beat by beat.',
'Every word every line. Move them.',
'Let us goooooooooooo!',
'Do it again',
'One more take',
]
const CLIP_LABELS = [
'Rectangle',
'Letterbox',
'Title card',
'Lower third',
'B-roll',
'Transition',
'Overlay',
'Logo sting',
]
const AUDIO_LABELS = [
'ZOOM0211.WAV',
'artlist_warm_strings_loop.WAV',
'room_tone.WAV',
'foley_steps.WAV',
]
export interface ProjectOptions {
/** Video and text tracks. One caption track and one audio track are added. */
trackCount?: number
durationSeconds?: number
seed?: number
}
export function createProject(options: ProjectOptions = {}): Project {
const trackCount = options.trackCount ?? 10
const durationSeconds = options.durationSeconds ?? 230
const random = seededRandom(options.seed ?? 0x5eed)
const tracks: Track[] = [{ id: 'caption', name: 'Caption', kind: 'caption', tall: false }]
for (let index = trackCount; index >= 1; index -= 1) {
tracks.push({
id: `track-${index}`,
name: `Track ${index}`,
kind: index > trackCount - 2 ? 'shape' : index % 3 === 0 ? 'video' : 'text',
tall: false,
})
}
tracks.push({ id: 'audio-1', name: 'Audio 1', kind: 'audio', tall: true })
const clips: Clip[] = []
let nextClipId = 1
for (const track of tracks) {
// Full-width letterbox bars on the two shape tracks, a scattered layout
// everywhere else. Both shapes appear in a real edit and they stress
// different parts of the layout: one very wide clip, or many small ones.
if (track.kind === 'shape') {
clips.push({
id: `clip-${nextClipId++}`,
trackId: track.id,
kind: 'shape',
label: `${CLIP_LABELS[clips.length % CLIP_LABELS.length]} - ${track.name}`,
start: 0,
duration: durationSeconds,
})
continue
}
let cursor = random() * 6
while (cursor < durationSeconds) {
const duration =
track.kind === 'audio'
? 24 + random() * 40
: track.kind === 'caption'
? 1.2 + random() * 2.6
: 1.5 + random() * 7
if (cursor + duration > durationSeconds) break
const labels =
track.kind === 'audio'
? AUDIO_LABELS
: track.kind === 'caption'
? CAPTION_LINES
: CLIP_LABELS
clips.push({
id: `clip-${nextClipId++}`,
trackId: track.id,
kind: track.kind,
label: `${labels[nextClipId % labels.length]}${
track.kind === 'caption' || track.kind === 'audio' ? '' : ` ${nextClipId % 9}`
}`,
start: Number(cursor.toFixed(3)),
duration: Number(duration.toFixed(3)),
})
cursor += duration + random() * 3
}
}
const waveform: number[] = []
for (let index = 0; index < durationSeconds * WAVEFORM_HZ; index += 1) {
const envelope = 0.35 + 0.45 * Math.abs(Math.sin(index / 37))
waveform.push(Math.min(1, envelope * (0.5 + random() * 0.7)))
}
return { name: 'Diffusion Studio Pro', durationSeconds, tracks, clips, waveform }
}
export function formatTimecode(seconds: number): string {
const clamped = Math.max(0, seconds)
const whole = Math.floor(clamped)
const frames = Math.floor((clamped - whole) * 25)
const minutes = Math.floor(whole / 60)
const rest = whole % 60
const pad = (value: number) => String(value).padStart(2, '0')
return `${pad(Math.floor(minutes / 60))}:${pad(minutes % 60)}:${pad(rest)}:${pad(frames)}`
}
/** Ruler label step in seconds, chosen so labels stay about 90px apart. */
export function tickStep(pxPerSecond: number): number {
const candidates = [0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300]
for (const candidate of candidates) {
if (candidate * pxPerSecond >= 90) return candidate
}
return candidates[candidates.length - 1]
}