Clips & Tracks
What changed on the clip and track model in 0.6.0: more than one video lane, clip speed, a crop window, rounded corners, and tracks that the user cannot remove or that stay at the bottom.
Multiple Video Tracks
Before 0.6.0 the engine capped a project at one video track and addTrack('video') returned the existing one. It now adds another lane. Overlapping clips on separate video lanes layer instead of conflicting.
Video tracks composite in track order: the resolver derives each clip's z-index from track.order, and a lower order is closer to the top of the timeline, so the topmost lane draws on top. A new video track is placed directly below the last existing video track, which keeps the video lanes grouped above audio and elements. Every other kind of track is appended below all existing tracks.
const base = engine.addTrack('video', { name: 'Base' })
const pip = engine.addTrack('video', { name: 'Picture in picture' })
// pip is placed directly below base, so base draws on top of it.
// To put pip on top, give reorderTracks the COMPLETE list of track ids,
// topmost first. Tracks left out of the list keep a stale order value.
const rest = engine
.getProject()
.tracks.map((t) => t.id)
.filter((id) => id !== pip.id)
engine.reorderTracks([pip.id, ...rest])You can also start with several video lanes by listing them in initialTracks. The headless build spec in the CLI docs is separate: it still places every video clip on one video track, even though the engine itself supports more.
Speed
Clip.speed is a playback-rate multiplier for video clips only. Omitted means 1. The engine clamps it to 0.25 to 4. Change it with engine.setClipSpeed, not by writing the field alone, because the method also recomputes durationFramesso the clip's length on the timeline follows the new rate.
// clip.durationFrames is 150 at 1x
engine.setClipSpeed(clip.id, trackId, 2)
// -> speed 2, durationFrames 75: the same source window, consumed twice as fast
engine.setClipSpeed(clip.id, trackId, 0.5)
// -> slows down; the clip grows, but never into the next clip on the tracksourceStartFrameandsourceDurationFrameskeep describing the trim window into the source at 1x. Only the rate at which that window is consumed changes.- Speeding up always shrinks the clip in place. Slowing down grows it, clamped to the gap before the next clip on the same track. There is no ripple.
- It is a no-op on a locked track and on any clip that is not a video clip. Transitions that no longer make sense after the length change are pruned in the same undo entry.
- The resolver maps a timeline frame to a source frame as
floor(localFrame * speed) + sourceStartFrame, and the export worker honours the same speed, so preview and export agree.
Crop
Clip.cropis a window into the source, normalized 0 to 1 of the media's natural size with the origin at the top left. Omitted means the full frame. It applies to video and image clips.
import { normalizeCrop, FULL_CROP, type CropRect } from '@elah/editor'
// Keep the middle half of the source horizontally.
const crop: CropRect = { x: 0.25, y: 0, width: 0.5, height: 1 }
engine.updateClip(clip.id, trackId, { crop })
// normalizeCrop clamps a window into the unit square and enforces a minimum
// extent of 0.05 on each axis. normalizeCrop(undefined) returns FULL_CROP.
const safe = normalizeCrop({ x: 0.9, y: 0, width: 0.5, height: 1 })
// -> { x: 0.5, y: 0, width: 0.5, height: 1 }Cropping changes what is visible, not the clip's scale. The draw rect is sized from the cropped content, so a crop shrinks the box on stage while transform.scale stays as authored. The interactive crop mode in the preview is covered on the Editor page.
If you build your own placement UI, transformFromContainRect(contentWidth, contentHeight, stageWidth, stageHeight) returns the explicit Transform that is visually identical to the renderer's default contain placement. Bake it in before the first gesture and grabbing a clip that never had a transform does not make it jump. transformFromCoverRect takes the same arguments and fills the stage instead of letterboxing.
Corner Radius
Clip.cornerRadius rounds the corners of a video or image clip. It is a fraction from 0 to 0.5 of the shorter rendered side. 0, or omitted, is square. 0.5 is a full ellipse, which on a square draw rect is a circle.
engine.updateClip(clip.id, trackId, { cornerRadius: 0.12 })Protected & Pinned Tracks
Two optional Track fields describe a fixed layout.
protected: truemeans the track cannot be removed.engine.removeTrackis a no-op for it.pinned: 'bottom'keeps the lane, and any other pinned-bottom lane, below every non-pinned track. A new non-video track slots in just above the pinned block instead of after it, so freely added tracks land in the middle and a fixed audio bar stays at the very bottom.
Both are accepted by addTrack options and by InitialTrackConfig, and both round-trip through readProjectDocument.
import { EditorProvider, type InitialTrackConfig } from '@elah/editor'
const tracks: InitialTrackConfig[] = [
{ kind: 'video', name: 'Video' },
{ kind: 'audio', name: 'Audio', protected: true, pinned: 'bottom' },
]
<EditorProvider fps={30} initialTracks={tracks}>{/* ... */}</EditorProvider>
// Or after construction:
engine.addTrack('audio', { name: 'Music', pinned: 'bottom' })Growing Clips
A remote URL can be placed on the timeline before its real duration is known. beginImportUrl from @elah/core registers the URL as a status: 'pending' asset immediately, with a 5 second placeholder duration for video and audio, and finishes probing in the background. Insert a clip with the fallback length, then call growClipToAssetDuration from @elah/timeline once the asset is ready.
import {
beginImportUrl,
mediaLibraryStore,
insertMediaAsset,
growClipToAssetDuration,
} from '@elah/editor'
const asset = await beginImportUrl(url) // pending: durationSec is a placeholder
const result = await insertMediaAsset(engine, asset.id, { videoOnly: true })
if (!result.ok) return
const clipId = result.clipIds[0]
// Read the length the clip was actually inserted with. It can be shorter than
// the placeholder if the gap on the track was small.
const fallbackFrames = engine.findClip(clipId)!.clip.durationFrames
const unsubscribe = mediaLibraryStore.subscribe((state) => {
const ready = state.assets[asset.id]
if (ready?.status !== 'ready') return
unsubscribe()
growClipToAssetDuration(engine, clipId, fallbackFrames, ready.durationSec)
})- The signature is
growClipToAssetDuration(engine, clipId, expectedFallbackFrames, newDurationSec). It resizes up to the gap before the next clip on the same track, so it never creates an overlap, and it can shrink a clip as well as grow it. - If the clip's length no longer equals
expectedFallbackFrames, the user has already trimmed it and it is left alone. - While the asset is pending, and while the preview's decoder is still opening, a video or image clip shows a loading shimmer. The sweep is the
.elah-clip-shimmerclass in@elah/timeline/styles.css. The decoder-side wait is reported byclipLoadStore, which is renderer state and never part of the project or undo history.