mirror of
https://github.com/remorses/gpuix.git
synced 2026-10-29 03:04:52 +08:00
```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
180 lines
5.2 KiB
TypeScript
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]
|
|
}
|