Until now, addTrack('video') has been a polite lie. Call it on a project that already had a video track and you did not get a new lane; you got the old one back, handed over as if it were new. That was a deliberate cap, and it was the right call while the engine was a single-lane editor. It stopped being the right call the moment someone wanted a picture-in-picture, a lower-third video over a b-roll, or a logo sting that sits on top of everything.
0.6.0 is the release that lifts the cap. It is also the release that ports a large body of editor work from our premium product into the open-source packages, so there is more in it than one headline. This post is a tour of the parts we think matter, with the reasons for each. The changelog is the complete list; this is the why.
Multiple video tracks
The behaviour change first, because it is the one that can surprise existing code. addTrack('video') now adds a track. Video tracks composite in track order: the resolver derives each clip's stacking from its track's order, and the topmost lane draws on top. Two clips on separate video lanes that overlap in time no longer conflict. They layer.
Where the new lane lands is a small decision with large consequences. A new video track is placed directly below the last existing video track, which keeps the video lanes grouped together at the top, above audio and elements. Every other kind of track is appended below everything that exists. Tracks can also now be marked protected (the user cannot remove them) or pinned: 'bottom', in which case addTrack keeps a freely added track above that pinned block instead of after it.
import { TimelineEngine } from '@elah/core'
const engine = new TimelineEngine({ fps: 30, stage: { width: 1920, height: 1080 } })
// A fresh engine starts with one video track, 'Track 1'.
// Before 0.6.0 this call returned that same track. Now it adds a second
// lane, placed directly below the last video track.
const overlay = engine.addTrack('video')The build spec that elah build and elah serve consume puts every video clip on a single video track, and it has no field to say which track a clip belongs to. @elah/cli 0.1.2, released alongside this version, depends on @elah/core@^0.6.0 and documents the consequence: overlapping video clips in a spec are a spec error, reported by the build, not something the engine rejects. The engine allows several video tracks; the spec format does not yet have a way to ask for them.
Speed, crop and corner radius
Three new fields on Clip do most of what people reach for when they start composing rather than cutting.
Clip.speed is a playback multiplier for video clips, clamped to the range 0.25 to 4. Changing it changes the clip's length on the timeline: durationFrames follows the speed, while sourceStartFrame and sourceDurationFrames keep describing the trim window into the source at 1x. Only the rate at which that window is consumed changes. Slowing a clip down grows it, and setClipSpeed will not ripple into the next clip; growth is clamped to the gap in front of it. Export honours the speed, so what you preview is what you render.
Clip.crop is a source window, normalized from 0 to 1 against the media's natural size with the origin at the top left. Omit it and you get the full frame. Clip.cornerRadius is a fraction from 0 to 0.5 of the shorter rendered side, so 0.5 on a square draw rectangle is a circle. Both apply to video and image clips, and neither one needs a new kind of layer: they are ordinary clip fields that the preview and the export worker already know how to read.
// Half speed, cropped to the left half of the frame, softly rounded.
engine.setClipSpeed(clip.id, clip.trackId, 0.5)
engine.updateClip(clip.id, clip.trackId, {
crop: { x: 0, y: 0, width: 0.5, height: 1 },
cornerRadius: 0.08,
})Layered text motion and templates
Text animation used to be one kind per end: a clip could fade in, or rise in, or scale in, and you picked one. Real motion design is several channels moving together. TextAnimation now takes inMotion and outMotion, each a MotionSpec that can drive opacity, offsetX and offsetY, scale and rotation at once, with an ease for the geometric channels and a separate opacityEase so opacity can stay linear while the geometry overshoots.
A MotionSpec describes only the far end of a ramp. The near end is always the clip's authored resting state: opacity 1, no offset, a scale multiplier of 1, no rotation delta. We chose that on purpose. If a spec could store both ends, someone would eventually write one whose resting state is not the one the author set in the properties panel, which is a clip that never arrives where you put it. Storing only the extreme makes that bug unrepresentable.
The new easings are back-in, back-out, elastic-out and bounce-out, alongside the existing curves. The first three overshoot: they return values outside 0 to 1 partway through, which is most of what separates deliberate motion from a linear slide. Opacity is clamped at the point of use, so an overshooting curve can pass its target and come back without producing an opacity above 1.
On top of that sit 14 built-in text templates, BUILT_IN_TEXT_TEMPLATES, each bundling a style (font, size, colour, an optional background chip) with an entry and an exit. The part worth explaining is applyTextTemplate(template, clip). It does not mutate the clip, and it does not touch the engine. It returns a Partial<Clip> patch, and you hand that patch to engine.updateClip.
import { findTextTemplate, applyTextTemplate } from '@elah/core'
const template = findTextTemplate('template-title-card')
if (template) {
engine.updateClip(clip.id, clip.trackId, applyTextTemplate(template, clip))
}Returning a patch is what makes a template a normal edit. It lands on the undo stack, it is saved with the project, and the export worker renders it with no template-specific code, because by the time anything downstream sees it, it is just clip fields. The patch also sets every optional box field, to undefined where the template does not use it, so switching from a template with a background panel to one without clears the panel instead of leaving it behind. The ramp length is expressed as a fraction of the clip and resolved into a legal frame count for the specific clip, capped at half the clip so the entry and exit never overlap and leave the text permanently half-faded.
Stored projects and relinking
A Project that has been through JSON.stringify and back is not a Project; it is unknown. It may come from an older build, a newer one, or nothing we ever wrote. readProjectDocument(document) is the single door every stored project should go through. It returns a project the engine and the stores can dereference without guards, or it throws a ProjectDocumentError whose code is unreadable or unsupported-version. It never returns half a project.
The reasoning is about autosave. If a document with a broken track list were opened as an empty project, the first autosave would overwrite the user's real file with that emptiness and make the loss permanent. So the reader repairs what is merely missing (a document from before stages existed gets the default stage) and refuses what is wrong, and it is the caller's job to tell the user why. isReadableProjectDocument is the boolean form for deciding what to show.
Media is the other half of the problem. A clip imported from a local file carries a blob: or data: URL that dies with the session that minted it. relinkProjectMedia(project, assets) points restored clips back at the media library by src, and for clips whose source cannot be fetched again it does the honest thing: it lists them in missing and leaves them exactly where they are, with their name, position and length intact. Dropping a clip the user cannot see or undo is the one outcome we refuse to ship.
import { readProjectDocument, relinkProjectMedia, missingMediaSummary } from '@elah/core'
const project = readProjectDocument(JSON.parse(stored)) // throws ProjectDocumentError
engine.loadProject(project)
// Later, once the media library has been rebuilt:
const { project: relinked, missing } = relinkProjectMedia(engine.getProject(), libraryAssets)
engine.loadProject(relinked, { history: 'keep' }) // do not discard undo
const warning = missingMediaSummary(missing) // e.g. "intro.mp4 and logo.png"loadProject gained options for exactly this flow. history: 'keep' is for the relink pass, which must not throw away the undo stack, and the engine now emits a project:loaded event so the transport can rewind.
Upgrade notes
Three things deserve a line in your own upgrade checklist.
ExportOptions.outputHeight is now the stage's short edge. 1080 means 1920 by 1080 on a landscape stage and 1080 by 1920 on a portrait one; the other edge is derived from the stage's aspect ratio and rounded to an even number. Previously the option scaled against the stage height, which only equals the short edge on a landscape stage. A 9:16 stage asked for 1080 used to come out 608 by 1080. If you passed outputHeight on a portrait project, your output size changes, and it changes to the number you asked for.
Playback preferences (zoom, volume, mute, playback rate, loop and snap) are still saved to localStorage under the same key and the same envelope, so nobody loses their settings. What changed is when: the write now happens when a preference changes. The middleware we used before re-serialised them on every frame of playback and every scrub move.
serializeProject and deserializeProject were briefly removed from @elah/core and the @elah/editor barrel during the port. They are back, as a facade over readProjectDocument, so no consumer break ships. One difference to know about: a document with no version stamp is now read as version 1 instead of rejected, and a document from a newer build throws ProjectDocumentError.
A new capability is a promise about what the old ones do next. Ship the lane, and say out loud what the spec still cannot express.
Building something with browser-native video?
Try the SDK, read the docs, or join the conversation.