@elah/core

中文文档
1.0.0发布于 2026/10/28Apache-2.0TypeScriptES Module近 30 天下载 4062

Framework-agnostic video timeline engine — playback, frame resolution, WebGL2 renderer, and media management

原始使用文档
文档目录18 节
  1. Install
  2. What's inside
  3. Quick start
  4. Using the engine without a browser
  5. Save and restore
  6. Text templates and animation
  7. Transitions
  8. Motion: keyframes, springs, stagger, paths
  9. New clip types, effects and generators
  10. Scenes and VideoSpec for models (live in 1.0)
  11. Fonts and loading from a CDN
  12. Frame sequences
  13. Clip and track fields
  14. Keeping the media library across a page load
  15. Diagnostics
  16. Clip factories
  17. Export
  18. Links

@elah/core

Framework-agnostic video timeline engine. No React. No renderer. Just the pure logic layer — project state, playback, frame resolution, and media management.

Used internally by @elah/react, @elah/timeline, and @elah/editor, but can be consumed directly for custom rendering pipelines or headless environments.

React hooks are not in this package — they live in @elah/react, which wraps the stores below for component use.

npm license


Install

npm install @elah/core

Package size: about 1.2 MB as an npm tarball (3.0 MB unpacked), of which roughly half is the bundled fonts, which load only if you opt in (see Fonts). The code is tree-shakeable (sideEffects: false); import @elah/core/engine if you do not need the renderer or the codec. Runtime deps: immer, zustand and mediabunny (^1.50.8), which the demuxer and export worker load lazily on first decode or export, except through createDefaultDemuxerFactory, which imports it statically. What a real app downloads (esbuild, minified, gzipped; React, lucide-react and mediabunny external): an editor app (EditorProvider, Preview, Timeline from @elah/editor) is about 112 KiB gz, up from 68 KiB in 0.6.0 (it was 133 KiB before effects, generators, sequence clips, roto masks and audio Enhance became chunks that load on first use; prepareFeatures(project) loads a project's features up front). The engine alone (TimelineEngine, resolveTimeline) is about 45 KiB gz, and engine plus GpuRenderer and exportVideo about 75 KiB gz. The AI-facing layers below cost an app that does not import them nothing. BUNDLE_STRATEGY.md explains how bundle size is measured (node scripts/measure-bundle.mjs) and what is left in startup.

Stability (1.0.0): the exports of the root entry and of @elah/core/engine follow semver. Three AI-facing layers are live in 1.0 and follow semver too. They are not exported from the root; import them from their own entries: @elah/core/spec (VideoSpec, components, systems, specVocabulary), @elah/core/html (HTML/CSS scene conversion), @elah/core/ops (the op vocabulary and describe* helpers) and @elah/core/icons (the vendored icon set). Each is marked where it appears below. A project is written as document version 1 to 5 (see Save and restore); 0.6.x cannot open a document that uses the newer clip types.


What's inside

ModuleDescription
TimelineEngineManages project state — tracks, clips, undo/redo
PlaybackEngineFrame-accurate playback clock
resolveTimelinePure function — project → active scene at a given frame
GpuRendererWebGL2 renderer for video, image, text, shape, and freehand layers
AudioPlaybackControllerMulti-track audio mixer on the playback clock
tracksStoreVanilla Zustand store mirroring project state — bind it with useTracksStore from @elah/react
playbackStoreVanilla Zustand store mirroring playback state — bind it with usePlaybackStore from @elah/react
selectionStoreVanilla Zustand store for clip selection — bind it with useSelectionStore from @elah/react
transitionsStoreVanilla Zustand store mirroring transitions — bind it with useTransitionsStore from @elah/react
textStylePresetsStoreReusable text looks (built-ins plus user presets) — bind it with useTextStylePresetsStore from @elah/react
clipLoadStoreWhich clips the preview cannot draw yet ('loading' / 'error'). Renderer state, never saved or undoable — bind it with useClipLoadStore from @elah/react
mediaLibraryStoreMedia asset registry (vanilla store) — bind it with useMediaLibrary from @elah/react
importFiles / importUrl / importBlobImport local files, remote URLs, or blobs into the media library
beginImportUrlLike importUrl but returns a status: 'pending' asset immediately, so a clip can be placed before the media is probed
determineAssetHasAudio / probeHasAudioWhether a video carries an audio track
serializeProject / deserializeProjectSave the engine's project as JSON and load it back (a facade over readProjectDocument)
readProjectDocument / relinkProjectMediaValidate a stored document into a Project; re-point restored clips at the media library
BUILT_IN_TEXT_TEMPLATES / applyTextTemplate14 text looks with layered entry/exit motion; applyTextTemplate(template, clip) returns an undoable Partial<Clip> patch
sampleTextAnimationPure sampler for text/shape entry-exit animation (TextAnimation, MotionSpec)
createFrameSequence / FrameSequenceControllerOrdered image sets (360° orbits, storyboards) with scrub, loop and preload; frameSequenceToProject makes one editable
snapshotMediaLibrary / hydrateMediaLibraryTurn the media library into storable entries and put them back (ids kept) after a page load; storage is yours
sourceBlobCacheShared cache that de-duplicates video downloads between the demuxer and preview
PerfSummaryOnce-a-second render-loop cost summary; silent unless the PERF trace channel is on
exportVideoExport the timeline via a web worker: MP4 (default), WebM, GIF or a PNG sequence
Clip.keyframes, TEXT_ANIMATION_EASINGS, SPRING_PRESETS, MOTION_PRESETSThe motion layer: keyframes on every transform channel, 31 named eases plus cubic-bezier, steps and springs, stagger, motion paths, text animators, scramble, repeat/yoyo, skew, 3D tilt (see Motion)
createGenerator, GENERATOR_REGISTRYGenerator clips: particles, energy lines, light leak, lens flare, gradient background, counter, bar chart, progress bar
EFFECT_REGISTRY, EFFECT_BUNDLES23 GPU effects, bundles and looks; adjustment clips apply them to everything beneath
TRANSITION_REGISTRYThe nine transitions, in picker order
createCompoundClip, nestClips, createSequenceClipCompound clips (a nested project as one clip) and sequence clips (animated image or numbered frames)
Clip.audio, clipAudioGain, enhancePlanMute, boost, fades and Enhance; one envelope for preview and export
svgToProject, lottieToClipImport SVG and Lottie as editable clips and keys
validateScene, sceneToClip (from @elah/core/html)Live. HTML/CSS scene subset: validate, then convert a measured scene to one compound clip
compileVideoSpec, buildVideo, layProject, specVocabulary (from @elah/core/spec)Live. VideoSpec: a seconds-based JSON document a model writes, compiled to a Project
applyOps, sanitizeOps, EDITOR_OP_NAMES, describeProject (from @elah/core/ops)Live. 57 typed editor ops for a model to edit a project
packageFontsBaseUrlWhere the package's own copies of the 12 bundled fonts live (opt-in)

These stores are vanilla (zustand/vanilla) — no React, subscribable from anywhere (store.getState(), store.subscribe()). They are also module-level singletons: one tracksStore, one playbackStore, etc. per JS realm. @elah/editor's <EditorProvider> wires each TimelineEngine/PlaybackEngine instance it creates into these same shared stores, so only one active project per page is supported today — mounting two <EditorProvider> (or two manually-wired engines) at once will have them overwrite each other's state in the stores. Multiple independent editors on one page need separate tabs/iframes/windows until scoped stores land.


Quick start

import { TimelineEngine, PlaybackEngine, resolveTimeline } from '@elah/core'

const engine = new TimelineEngine({ fps: 30, stage: { width: 1920, height: 1080 } })
const playback = new PlaybackEngine({ fps: 30, getTotalFrames: () => engine.getTotalFrames() })

// Add a track, then a clip onto it. addClip takes a single typed
// options object (a discriminated union keyed on `type`) and returns the Clip.
const track = engine.addTrack('video')
engine.addClip({ trackId: track.id, type: 'video', src: 'video.mp4', startFrame: 0, durationFrames: 90 })

// Resolve the scene at frame 15 — pure (frame, project) → Scene.
const scene = resolveTimeline(15, engine.getProject())

Using the engine without a browser

@elah/core/engine is the browser-free subset: the engine, resolver, PlaybackEngine, the stores, clip factories, project documents, and the pure placement and timeline math. It leaves out the WebGL2 renderer, WebCodecs decode, the media importers and the MP4 export, which is the one module using import.meta. Use it from React Native (Metro and Hermes reject import.meta), workers, or Node:

import { TimelineEngine, resolveTimeline } from '@elah/core/engine'

Both entries share module instances, so tracksStore from either is the same object. A test, src/engine.no-browser.test.ts, fails if anything browser-bound becomes reachable from it.


Save and restore

serializeProject / deserializeProject are the simple pair. Underneath them, readProjectDocument is the total reader: it returns a Project or throws ProjectDocumentError (code: 'unreadable' | 'unsupported-version'), never half a project. A document with no version stamp reads as version 1; one from a newer build (PROJECT_VERSION, now 5) is refused. A project is stamped with the lowest version that can hold what it uses: 2 for adjustment clips; 3 for generators, shape draw-on and the six newer transitions; 4 for compound clips; 5 for sequence clips. A project that uses none of them is still version 1 and opens in 0.6.x.

import {
  serializeProject, deserializeProject,
  readProjectDocument, relinkProjectMedia, missingMediaSummary,
} from '@elah/core'

const json = serializeProject(engine)
deserializeProject(engine, json) // throws, leaving the engine untouched, if unreadable

// Or in two steps, so a refusal can be shown before the editor is cleared:
const project = readProjectDocument(JSON.parse(json))
engine.loadProject(project) // options: { transport?: 'rewind' | 'keep', history?: 'reset' | 'keep' }

// Media imported from disk is a blob: URL and does not survive a reload.
// After the library is rebuilt, repair the clips' assetIds and report what is gone:
const { project: repaired, relinked, missing } = relinkProjectMedia(engine.getProject(), assets)
engine.loadProject(repaired, { transport: 'keep', history: 'keep' })
const note = missingMediaSummary(missing) // e.g. "a.mp4, b.mp4 and 3 more"

The engine emits project:loaded (ProjectLoadedEvent) after every load, carrying the new project and the requested transport.


Text templates and animation

import { BUILT_IN_TEXT_TEMPLATES, findTextTemplate, applyTextTemplate } from '@elah/core'

const template = findTextTemplate(BUILT_IN_TEXT_TEMPLATES[0].id)!
engine.updateClip(clip.id, trackId, applyTextTemplate(template, clip)) // one undo entry

Applying a template is clean, not cumulative: fields the template does not use are cleared. TextAnimation keeps the single-kind in/out enum and adds inMotion / outMotion (a MotionSpec: opacity, offsetX, offsetY, scale, rotation, ease, opacityEase), which replace the enum when present.

Two things a template carries are data only: stagger (split a line into clips that enter one after another) and tracking (fake letterspacing). applyTextTemplate ignores them and nothing in these packages runs them; a host that wants either builds it from the template itself.


Transitions

engine.addTransition({ fromClipId, toClipId, trackId, kind: 'slide', durationFrames: 12, direction: 'left' })

kind is one of nine: 'fade' | 'slide' | 'wipe' | 'zoom' | 'spin' | 'whip' | 'glitchCut' | 'warpDissolve' | 'lumaWipe' (TRANSITION_REGISTRY lists them with a label and hint). slide and whip take a direction ('left' | 'right' | 'up' | 'down', default right); the others ignore it. The resolver keeps both clips in the scene and lists the transition in scene.transitions with its eased progress; the WebGL2 renderer composites the pair with one shader, so both clips keep playing with their effects, in preview and in export. A project that uses only fade, wipe and slide still exports in a runtime whose worker has no WebGL2 (a 2D snapshot path, left/right slide only); a project that uses any other kind needs WebGL2 in the worker, and its export fails fast without it. Using any kind beyond the first three stamps the project as document version 3.


Motion: keyframes, springs, stagger, paths

Animation is data on the clip. Keyframe frames are integers relative to the clip's start, so moving a clip moves its keys; trim, speed and split remap them for you.

engine.updateClip(title.id, trackId, {
  keyframes: {
    channels: {
      x: {
        source: { kind: 'keyframes' },
        keys: [
          { id: 'k0', frame: 0, value: 0.2, easing: 'linear' },
          { id: 'k1', frame: 30, value: 0.8, easing: 'back-out' }, // the curve INTO this key
        ],
      },
    },
  },
})

A key's easing is one of TEXT_ANIMATION_EASINGS (sine, quad, cubic, quart, quint, expo, circ, back, elastic and bounce, each in / out / in-out, and linear). A key can instead carry a raw easingBezier, steps (a staircase), hold, or a closed-form spring (SPRING_PRESETS: gentle, snappy, bouncy, stiff, wobbly). The pure helpers that write keys (setKeysEasingAtFrame, moveKeysAtFrame, repeatKeysPatch for repeat and yoyo, staggerOffsets for stagger from the start, end, centre, edges or at random and across a grid, scrambleText) and the 36 MOTION_PRESETS are public. Colour and path channels are keyable too, and setChannelExpression drives a channel from an expression (wiggle, loop, sin, and so on).

Skew (skewX, skewY) is a keyable channel. 3D tilt (rotateX, rotateY) works on video, image, sequence and compound clips and is refused on text, shapes and freehand. A clip can also travel a path (the motion.progress channel). The one-line data-motion grammar that the HTML scene layer reads is built from the same keys.


New clip types, effects and generators

import { createGenerator } from '@elah/core'

const confetti = engine.addClip({
  trackId, type: 'generator', name: 'Confetti', startFrame: 0, durationFrames: 90,
  generator: createGenerator('particles', { count: 80 }),
})
const grade = engine.addClip({ trackId: overlayTrackId, type: 'adjustment', name: 'Grade', startFrame: 0, durationFrames: 90 })
  • adjustment: no picture of its own; its effects, mask and blend apply to everything composited beneath it.
  • generator: eight built in (GENERATOR_REGISTRY).
  • compound: a nested project played as one clip (createCompoundClip; engine.nestClips / unnestClip turn a selection into one and back; nesting is capped at MAX_COMPOUND_DEPTH).
  • sequence: an animated image or a numbered frame set, picked by frame index so preview and export show the same frame.

Effects are 23 GPU passes (EFFECT_REGISTRY: colour grade, gaussian blur, vignette, grain, RGB split, bloom, glow, lens and wave distortions, VHS, CRT, glitch, pixelate, motion blur and more), with EFFECT_BUNDLES as one-click looks, plus masks, track mattes and blend modes. Using these raises the document version (see Save and restore). @elah/editor plays, saves and exports these clips; it has no editing UI for them yet.


Scenes and VideoSpec for models (live in 1.0)

Two ways for a model to write a video that lands as ordinary, editable clips. Both are live in 1.0, on their own entries so the root stays small.

  • HTML/CSS scenes (import { validateScene, sceneToClip } from '@elah/core/html'): the model writes an HTML fragment and a stylesheet in a whitelisted subset. validateScene({ html, css }) returns violations that name the supported substitute. sceneToClip(measuredScene, { fps }) converts a browser-measured scene (boxes, line breaks and resolved styles) into one compound clip of native clips and keys. Measuring is the host's job; this package ships no DOM measurer.
  • VideoSpec (from '@elah/core/spec'): a seconds-based JSON document. compileVideoSpec(spec) returns { project, issues } and never throws; buildVideo(authoredSpec) also expands components and polishes; layProject(engine, project) lays the result onto a live engine as one undo step; specVocabulary() renders the vocabulary block for a prompt.

Worked, tested examples of both are in docs/ai/ELAH_FOR_AI_AGENTS.md. Edits go through typed ops (from '@elah/core/ops'): sanitizeOps(raw, project) repairs a model's op list and applyOps(engine, ops) applies it as one undo step.


Fonts and loading from a CDN

Text clips name one of 12 bundled families (FONT_REGISTRY: Inter, Montserrat, Poppins, Space Grotesk, Playfair Display, DM Serif Display, Bebas Neue, Anton, Oswald, Archivo Black, Roboto Mono, Caveat; Latin subset only, SIL OFL 1.1). The default is to load them from /fonts/ on your host. The woff2 files ship in this package under fonts/<id>/, so either copy that folder to your public fonts/ directory, or, for an unbundled import (a CDN, a native-ESM dev server), point the loader at the package itself:

import { exportVideo, packageFontsBaseUrl } from '@elah/core'

await exportVideo(project, { fonts: { baseUrl: packageFontsBaseUrl() } })

The export, audio-analysis and capability-probe workers start from a cross-origin module URL through a blob module worker (jsDelivr's /+esm build included); bundled hosts take the ordinary new Worker(new URL(...)) path. Use packageFontsBaseUrl() only for an unbundled import: a bundler rewrites import.meta.url.


Frame sequences

import { createFrameSequence, frameSequenceToProject } from '@elah/core'

const seq = createFrameSequence({ frames: urls.map((src) => ({ src })), fps: 12, loop: 'wrap' })
engine.loadProject(frameSequenceToProject(seq, { holdFrames: 6 })) // edit/export as image clips

FrameSequenceController drives a sequence from a clock or a drag; createFramePreloader warms frames ahead of the index; pickFrameSource chooses the right responsive candidate.


Clip and track fields

FieldApplies toMeaning
Clip.speedvideoPlayback multiplier, clamped to 0.25–4 (engine.setClipSpeed); timeline length follows it
Clip.cropvideo, imageNormalized source window { x, y, width, height }; see normalizeCrop, FULL_CROP
Clip.cornerRadiusvideo, imageRounded-corner mask, 0–0.5 of the shorter side
Track.protectedanyThe user cannot remove the track
Track.pinnedany'bottom' keeps the lane below every non-pinned track; addTrack honours it

Any number of video tracks is allowed; they composite in track order, topmost lane on top.


Keeping the media library across a page load

import {
  mediaLibraryStore, snapshotMediaLibrary, hydrateMediaLibrary, refreshMissingThumbnails,
} from '@elah/core'

// Save: plain entries (assets still being probed are skipped). Where they go is up to you.
const entries = snapshotMediaLibrary(mediaLibraryStore.getState())

// Restore: ids are kept, so restored clips resolve their `assetId` directly.
const { hydrated, needsThumbnail } = hydrateMediaLibrary(entries, {
  referencedSrcs: new Set(/* every media clip's src in the project being opened */),
})
refreshMissingThumbnails(needsThumbnail) // cosmetic, fire-and-forget

This carries asset metadata and thumbnails only. A file imported from disk is a blob: URL that dies with the session; its bytes are not stored, and relinkProjectMedia reports such clips as missing.


Diagnostics

import { PerfSummary } from '@elah/core'
// In the browser console: __trace.on('PERF') turns the once-a-second summary on.

PerfSummary measures the whole render tick (phase timings, budget overruns), not just GPU upload. It allocates nothing while the PERF channel is off.


Clip factories

Standalone builders that return a fully-normalized Clip object (rounded frames, default volume/opacity, generated id) without an engine — useful for headless pipelines that feed resolveTimeline directly. When you have an engine, prefer engine.addClip(options) instead, which builds the clip and records an undo entry.

import {
  createVideoClip,
  createAudioClip,
  createTextClip,
  createImageClip,
  createShapeClip,
  createFreehandClip,
} from '@elah/core'

const clip = createVideoClip({ trackId: 'v1', src: 'video.mp4', startFrame: 0, durationFrames: 90 })
const rect = createShapeClip({
  trackId: 'el1',
  startFrame: 0,
  durationFrames: 90,
  shape: { shapeKind: 'rect', shapeFill: '#22d3ee' }, // 'rect' | 'circle' | 'triangle'
})

Export

import { exportVideo } from '@elah/core'

const blob = await exportVideo(engine.getProject(), {
  videoBitrate: 8_000_000,
  outputHeight: 1080, // short edge — optional, defaults to the stage's own
  onProgress: ({ frame, totalFrames }) => console.log(frame, '/', totalFrames),
})

outputHeight is the stage's short edge (1080 = 1920×1080 or 1080×1920); the other edge follows the aspect ratio and is rounded to an even number, as codecs require.

container picks the output: 'mp4' (default), 'webm' (add transparent: true for an alpha channel, accepted only where the browser's own encode-and-decode check passes), 'gif' (gifFps, default 15, no audio) or 'png-sequence' (a ZIP, or your own frameSink). videoCodec is 'avc' | 'hevc' | 'av1' | 'vp9' | 'vp8' where the browser can encode it, and range: 'inOut' exports only the marked in/out range. Export needs WebCodecs and OffscreenCanvas: in practice Chrome or Edge.


版本与发布记录

@elah/core 共发布 11 个版本,首次发布于 2026/06/09;过去 12 个月发布了 11 个版本。

版本发布日期距今
1.0.0latest2026/10/2816 天前
0.6.02026/10/031 个月前
0.4.12026/08/023 个月前
0.4.02026/07/283 个月前
0.3.22026/07/124 个月前
0.3.12026/07/124 个月前

关于 @elah/core 的常见问题

@elah/core 是什么?

Framework-agnostic video timeline engine — playback, frame resolution, WebGL2 renderer, and media management

如何安装 @elah/core?

在项目目录运行 npm install @elah/core,或使用 pnpm add / yarn add / bun add。

@elah/core 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@elah/core 支持 ESM 还是 CommonJS?

以 ES Module 形式发布,推荐使用 import 引入。在 CommonJS 项目中,Node.js 20.19+ / 22.12+ 可以直接 require() 不含顶层 await 的 ES Module,更早的版本需要改用动态 import()。

@elah/core 可以商用吗?

@elah/core 使用 Apache-2.0 许可。宽松许可:允许商业使用、修改和再分发,并附带明确的专利授权;分发时需保留许可与 NOTICE 文件,修改过的文件要注明改动。以上为概括,不构成法律意见,请以许可原文为准。

@elah/core 的下载量有多少?

根据 npm 官方下载统计,@elah/core 在 2026/10/13 – 2026/11/11 这 30 天内被下载 4,062 次,日均约 135 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@elah/core 还在维护吗?

最新版本 1.0.0 发布于 2026/10/28(16 天前);至今共发布 11 个版本,其中过去 12 个月发布了 11 个;首次发布于 2026/06/09。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。