Frame Sequences
An ordered set of images addressed by index: 360 degree orbits, generated sets, storyboards. A frame sequence is frames plus how to move through them, and it can be laid out as a project so it is editable and exportable like anything else.
A sequence is deliberately not tied to what the frames depict. It is pure and React-free, and it lives in @elah/core (re-exported by @elah/editor). There is no viewer component in this repo. A host app renders the frames itself, or turns the sequence into a project with frameSequenceToProject.
Create a Sequence
createFrameSequence(options) normalizes frames into { id, index, src }. Pass bare URLs or partial frames, so a plain manifest works.
import { createFrameSequence, frameAt, frameCount, normalizeFrameIndex } from '@elah/editor'
const orbit = createFrameSequence({
frames: ['orbit-000.webp', 'orbit-001.webp', 'orbit-002.webp' /* ... */],
seamless: true, // the last frame joins the first without a jump
fps: 24, // default 24
label: 'Penthouse, living room',
aspectRatio: 16 / 9,
})
frameCount(orbit) // number of frames
frameAt(orbit, 1) // the Frame at index 1 (normalized first; throws only on an empty sequence)
normalizeFrameIndex(orbit, -1) // -> last index, because loop is 'wrap'The loop mode decides what happens at the ends. FrameLoopMode is 'none' (clamp: the first and last frames are walls), 'wrap' (the last frame is followed by the first) or 'pingpong' (reverse at each end, visiting each endpoint once). loop defaults from seamless: a closed orbit wraps, anything else bounces. That default is the reason seamless exists. A camera arc that stops at 200 degrees looks broken when wrapped, so describe it with seamless: false.
normalizeFrameIndex(sequence, index) is the single place index movement is decided. Drag, scrub, keyboard and the playback clock all go through it, so they cannot disagree about the ends. A Frame may also carry sources, a list of { src, width, type? } candidates (AVIF before WebP, for example) for responsive loading. frame.src is always the fallback.
FrameSequenceController
FrameSequenceController is the transport and pointer-drag layer over a sequence. It is per instance, with nothing module-scoped, so several sequences can run on one page independently. Playback is not reimplemented: it owns a PlaybackEngine, the same anchor-and-integrate clock the editor transport uses.
import { FrameSequenceController } from '@elah/editor'
const controller = new FrameSequenceController({
sequence: orbit,
// optional: fps, loop ('none' | 'wrap' | 'pingpong'), initialIndex, dragSensitivity
})
controller.play()
controller.pause()
controller.toggle()
controller.seek(12) // normalized by the loop mode
controller.next()
controller.prev()
// Drag-to-scrub. Tell it how wide the viewer is, then feed it pointer x values.
controller.setViewportWidth(el.clientWidth)
el.onpointerdown = (e) => controller.beginDrag(e.clientX)
el.onpointermove = (e) => controller.drag(e.clientX)
el.onpointerup = () => controller.endDrag()
// Subscribe. The snapshot is reference-stable between changes.
const unsubscribe = controller.subscribe((s) => {
// s: { index, isPlaying, isDragging, epoch }
})
controller.destroy()- Drag is viewport-independent.
dragSensitivityis the number of full-sequence traversals per drag across the viewer's width (default 1). One full-width drag moves through the whole sequence once, on a phone or a 4K monitor. Before the viewer reports its width, a fixed 8 pixels per frame is used. - Drag pauses playback and only emits when the integer index actually changes, so a 120 Hz pointer stream does not become 120 renders a second.
getSnapshot()is stable between changes, so it binds directly to React'suseSyncExternalStorewith no adapter. Other methods:setLoop,setSequence(sequence, { preserveIndex? }),setDragSensitivity.detach()andreattach()release and restore the clock reversibly. Use them in an effect cleanup, because StrictMode runs setup, cleanup, setup against one memoized controller.destroy()is the irreversible one.
Preloading
A 36-frame orbit is 36 requests. Firing them all at once starves the frame the user is looking at, and loading lazily flashes on every drag. createFramePreloader loads outward from the current index (current first, then plus and minus one, two and so on) under a concurrency cap. Under wrap it measures distance the short way round, so frame 0 treats the last frame as adjacent.
import { createFramePreloader } from '@elah/editor'
const preloader = createFramePreloader(orbit, {
radius: 2, // urgent window either side of the focus (default 2)
concurrency: 6, // simultaneous decodes (default 6)
sizeHint: { widthCss: 960, dpr: window.devicePixelRatio },
onProgress: ({ loaded, total, complete }) => {},
})
preloader.focus(controller.index) // cheap; call it on every frame change
preloader.isLoaded(12)
preloader.status() // { loaded, total, complete }
preloader.dispose()Failures settle too, so a broken frame never wedges the queue. Decoding goes through core's shared image cache, so a frame preloaded here is the same decoded object the GPU renderer picks up if the sequence is later dropped on the timeline.
pickFrameSource(frame, sizeHint?) returns the exact URL a browser would paint for a frame that has sources: the first type the browser supports wins (grouped in array order), then the narrowest candidate that still meets widthCss * dpr, else the widest. It falls back to frame.src. The preloader uses it so it warms the URL that will actually be requested. supportsImageType(type) is the memoized AVIF and WebP support probe behind it, and resolves true for any other type. Under SSR or jsdom it reports AVIF and WebP as unsupported.
To a Project
frameSequenceToProject(sequence, options?) makes a sequence editable media rather than a dead image strip. Every frame becomes an image clip, laid end to end on one video track. The result is an ordinary Project: it loads into engine.loadProject and can be trimmed, split, reordered, transitioned and exported.
import { frameSequenceToProject } from '@elah/editor'
const project = frameSequenceToProject(orbit, {
holdFrames: 6, // timeline frames per sequence frame (default 1)
fps: 30, // default: the sequence's own fps
stage: { width: 1080, height: 1920 }, // default 1920x1080
trackName: 'Orbit',
projectId: 'orbit-demo',
})
engine.loadProject(project)- With
holdFrames: 1, timeline time and sequence index are the same number, which is what a scrubbed 360 degree viewer wants. Raising it holds each angle longer: 6 turns a 36-frame orbit into a 7.2 second slideshow at 30 fps. - The stage is landscape (1920 by 1080) by default, not the engine's portrait default, because orbits and storyboards are usually landscape. It matches the CLI spec default.
- Each clip uses the frame's
src. Responsivesourcesare not carried into the project. - It is pure. It builds a
Projectand touches no engine, store or DOM, and it stamps the result withPROJECT_VERSION.
Export
Because the result is a normal project, there is no special export path. Pass it to exportVideo (or lazyExportVideo) like any other composition, and see the Export page for the options, including how outputHeight is the short edge.
import { exportVideo, frameSequenceToProject } from '@elah/editor'
const project = frameSequenceToProject(orbit, { holdFrames: 2 })
const blob = await exportVideo(project, { outputHeight: 1080 })The same Project is plain data, so the headless runtime can render it too. exportProject in @elah/cli takes a Project directly.