Text Templates & Motion
One end of an animation can now drive several channels at once. 0.6.0 adds layered text and shape motion, the pure helpers that sample it, and 14 built-in text templates that bundle a look with its motion.
MotionSpec
TextAnimation still has in and out, the one-kind-per-end enum that the properties panel writes (fade, spin, slide-up, slide-down, slide-left, slide-right). It now also has inMotion and outMotion. Each is a MotionSpec and, when present, replaces the enum for that end. The enum is resolved into a MotionSpec internally, so there is one sampling path, not two.
interface MotionSpec {
/** Opacity at the extreme, 0..1. Rest is 1. Omitted = no opacity ramp. */
opacity?: number
/** Horizontal offset from rest at the extreme, normalized 0..1 of stage width. */
offsetX?: number
/** Vertical offset from rest at the extreme, normalized 0..1 of stage height. */
offsetY?: number
/** Scale MULTIPLIER on the authored scale. 0.8 = 80% of whatever size the author chose. */
scale?: number
/** Rotation delta from rest at the extreme, in radians. Positive = clockwise. */
rotation?: number
/** Curve for the geometric channels. Defaults: 'quad-out' on an entry, 'quad-in' on an exit. */
ease?: TextAnimationEasing
/** Curve for opacity. Defaults to 'linear'. */
opacityEase?: TextAnimationEasing
}
interface TextAnimation {
in?: TextAnimationKind
out?: TextAnimationKind
durationFrames: number // ramp length in frames, shared by both ends
inMotion?: MotionSpec // replaces `in` when present
outMotion?: MotionSpec // replaces `out` when present
}A spec stores only the far end of the ramp, because the near end is always the clip's authored resting state. On an entry the clip starts at the extreme and arrives at rest as the ramp closes. On an exit it starts at rest and departs to the extreme. Any channel you omit stays at rest.
// Rise 5% of stage height, grow from 86%, fade in, with a springy overshoot.
// Leave by continuing upward and shrinking a touch.
engine.updateClip(clip.id, trackId, {
textAnimation: {
durationFrames: 12,
inMotion: { opacity: 0, offsetY: 0.05, scale: 0.86, ease: 'back-out' },
outMotion: { opacity: 0, offsetY: -0.04, scale: 0.96, ease: 'quad-in' },
},
})Motion applies to shape clips too. They carry the same structure on Clip.shapeAnimation. One caveat: neither shape renderer applies transform.rotation, so spin is offered for text only (see appliesTo in TEXT_ANIMATION_KINDS) and a rotation channel has no visible effect on a shape.
Easings
TextAnimationEasing is one of ten curves, and TEXT_ANIMATION_EASINGS lists them in declaration order, so a picker can render the list without drifting from the type.
back-in, back-out and elastic-out overshoot, returning values outside 0 to 1 partway through. That is most of what makes motion look designed rather than a linear slide. bounce-out settles with a series of decaying bounces. Because geometry can pass its target and come back, opacity is clamped where it is used, and opacityEase exists so opacity can stay linear while the geometry overshoots.
Sampling
The resolver turns an animation into an opacity and, for kinds that move or rotate the clip, a concrete Transform. Renderers stay animation-unaware, so GPU preview and export agree. The same pure helpers are public, for custom previews and pickers:
sampleTextAnimation(args)returns{ opacity, transform? }for one frame.transformis present only when the animation moves or rotates the clip. When it is absent, leave the clip's own transform alone.resolveRampFrames(durationFrames)clamps a stored ramp length to a whole number of frames of at least 1. Zero, negatives, fractions,NaNandInfinityall collapse safely.motionForKind(kind, 'in' | 'out')returns theMotionSpecthat one of the enum kinds is shorthand for.TEXT_ANIMATION_KINDSis the list of kinds with alabeland anappliesToof'both'or'text-only'.DEFAULT_TEXT_TRANSFORMandDEFAULT_SHAPE_TRANSFORMare the transforms the renderer applies to a clip that has none. Pass the right one asdefaultTransform. They differ: a text clip defaults toscale: 1, a shape toscale: 0.5.SLIDE_TRAVEL_NORMALIZED(0.15) andSPIN_TRAVEL_RADIANS(a quarter turn) are the travel distances the enum kinds use.
import { sampleTextAnimation, DEFAULT_TEXT_TRANSFORM } from '@elah/editor'
const sample = sampleTextAnimation({
animation: clip.textAnimation,
localFrame: currentFrame - clip.startFrame, // frames since the clip started
clipDurationFrames: clip.durationFrames,
transform: clip.transform,
defaultTransform: DEFAULT_TEXT_TRANSFORM, // required, so it cannot be forgotten
})
const opacity = Math.min(clip.opacity ?? 1, sample.opacity)
const placed = sample.transform ?? clip.transformThe entry ramp covers the first durationFrames of the clip and the exit ramp the last. When the two overlap on a short clip, the opacity ramps take the minimum and the travel vectors add, so the clip passes smoothly through rest instead of popping at the crossover.
Built-in Templates
A text template is a named, applyable look: a style, a motion and sometimes a placement. BUILT_IN_TEXT_TEMPLATES holds 14, ordered as a picker should show them (general looks first, positional and decorative after). Look one up by id with findTextTemplate(id), which returns undefined for an id the package does not ship.
template-title-cardtemplate-elegant-revealtemplate-kinetic-poptemplate-quotetemplate-lower-thirdtemplate-subtitletemplate-callouttemplate-tickertemplate-editorial-stamptemplate-brand-droptemplate-runway-tickertemplate-price-chiptemplate-split-titletemplate-period-titleEach TextTemplate has an id, name, description, a style (TextTemplateStyle), an animation (TextTemplateAnimation) and optional stagger, tracking and placement. Templates describe their ramp as a fraction of the clip, rampFraction, and never touch content: applying one restyles the words, it does not rewrite them.
stagger (break one line into several clips that enter in turn) and tracking: 'wide' (fake letterspacing by respacing the characters) are part of what makes some looks what they are, but a single clip cannot express either. applyTextTemplate ignores both, and nothing in the packages executes them. A host that builds a timeline from a template has to implement them itself.
Applying a Template
applyTextTemplate(template, clip) is pure. It reads the clip and returns a Partial<Clip> patch. Hand the patch to engine.updateClip and the change lands on the undo stack and in the saved document like any other edit. Playback and export need nothing extra, because the patch is ordinary clip fields.
import { applyTextTemplate, findTextTemplate } from '@elah/editor'
const template = findTextTemplate('template-lower-third')
if (template) {
engine.updateClip(clip.id, trackId, applyTextTemplate(template, clip))
}- Switching is clean, not cumulative. Every optional box field (
backgroundColor,backgroundOpacity,padding,borderRadius,borderWidth,borderColor) is present in the patch, set toundefinedwhen the template does not use it. Quote (a panel) followed by Title Card (no panel) does not leave the panel behind. The author's own edits to those fields are replaced, which is what applying a template means. - Placement merges. A template that sets
placementoverrides only x and y, keeping the clip's scale, rotation and anchor. A clip with no transform starts fromDEFAULT_TEXT_TRANSFORM, so it does not jump. This is correct for text clips only, because shapes default to a different scale. - The ramp is resolved per clip. The patch carries
textAnimationwithinMotion,outMotionand adurationFramescomputed for this clip's length.
resolveTemplateRamp(rampFraction, clipDurationFrames) is that computation, public for hosts that build a timeline from a template instead of applying one to a selected clip. It returns a whole number of frames that is at most half the clip, at most MAX_TEMPLATE_RAMP_FRAMES (30) and at least MIN_TEMPLATE_RAMP_FRAMES (1). Half the clip is the cap because beyond it the entry and exit overlap and the text never reaches full opacity.
Style Presets
A TextStylePreset is smaller than a template: six style fields (fontFamily, fontWeight, fontSize, textAlign, color, opacity) plus an id and name. It cannot express motion, placement or a background chip. The two coexist: presets are the quick “restyle these glyphs” affordance, and templates are the production-ready unit.
BUILT_IN_TEXT_STYLE_PRESETSis a plain array of five looks (Subtitle, Title, Caption, Elegant, Highlight). It is not stored and not removable.textStylePresetsStoreholds the user-saved ones. It starts empty and hasaddPreset(preset), which assigns theid, andremovePreset(id). It does no persistence of its own.- In React, use
useTextStylePresetsStorefrom @elah/react.
import {
BUILT_IN_TEXT_STYLE_PRESETS,
textStylePresetsStore,
} from '@elah/editor'
// Save the clip's current look as a preset.
textStylePresetsStore.getState().addPreset({
name: 'My title',
fontFamily: 'Impact',
fontWeight: 'bold',
fontSize: 72,
textAlign: 'center',
color: '#ffffff',
opacity: 1,
})
// Apply a preset: copy its style fields onto the clip.
const { id, name, ...style } = BUILT_IN_TEXT_STYLE_PRESETS[0]
engine.updateClip(clip.id, trackId, style)