Back to Opencut

Keyframe System

docs/keyframes.md

0.3.07.3 KB
Original Source

Keyframe System

Keyframes allow element properties to change over time. The system is split into three layers: the data model (how keyframes are stored), the registry (which properties support keyframes and how to read/write them), and the UI (hooks and components that wire it all together).

How It Works

Data model

Every BaseTimelineElement has an optional animations?: ElementAnimations field:

typescript
interface ElementAnimations {
    channels: Record<string, AnimationChannel | undefined>;
}

A channel is a typed bucket of keyframes keyed by property path (e.g. "opacity", "background.color"). Three channel types exist: NumberAnimationChannel, ColorAnimationChannel, and DiscreteAnimationChannel.

Registry

src/lib/animation/property-registry.ts defines which property paths are animatable and how to read/write their values on an element. src/types/animation.ts holds the canonical list of valid paths in ANIMATION_PROPERTY_PATHS.

Resolver

src/lib/animation/resolve.ts provides functions that return the effective value of a property at a given local time — falling back to the element's static value when no keyframes exist.

Renderer

Nodes in src/services/renderer/ call the resolve functions before drawing so that animated properties interpolate correctly during export and preview.

UI

Two hooks in src/components/editor/panels/properties/hooks/ handle the keyframe-aware field logic:

  • useKeyframedNumberProperty — for numeric fields (opacity, position, scale, etc.)
  • useKeyframedColorProperty — for color pickers

Both hooks handle the toggle/add/remove keyframe flow and automatically switch between writing to the static property and writing to the animation channel depending on whether keyframes are active.


Adding a New Animatable Property

Using "background.paddingX" as an example.

1. Register the path — src/types/animation.ts

typescript
export const ANIMATION_PROPERTY_PATHS = [
    // ...existing paths
    "background.paddingX",
] as const;

2. Add a registry entry — src/lib/animation/property-registry.ts

typescript
"background.paddingX": {
    valueKind: "number",          // "number" | "color" | "discrete"
    defaultInterpolation: "linear",
    numericRange: { min: 0 },     // optional, only for number properties
    supportsElement: ({ element }) => element.type === "text",
    getValue: ({ element }) =>
        element.type === "text"
            ? (element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX)
            : null,
    setValue: ({ element, value }) =>
        element.type === "text"
            ? { ...element, background: { ...element.background, paddingX: value as number } }
            : element,
},

Notes:

  • getValue must return the effective value including any defaults — this is what gets recorded when a keyframe is added.
  • setValue receives AnimationValue (number | string | boolean). Cast to the correct type since coerceAnimationValueForProperty already validated it upstream.
  • For color properties, use valueKind: "color" and cast value as string.

3. Add a resolve function — src/lib/animation/resolve.ts

For numbers, use the existing generic resolveNumberAtTime:

typescript
import { resolveNumberAtTime } from "@/lib/animation";

const resolvedPaddingX = resolveNumberAtTime({
    baseValue: element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX,
    animations: element.animations,
    propertyPath: "background.paddingX",
    localTime,
});

For colors, use resolveColorAtTime:

typescript
const resolvedColor = resolveColorAtTime({
    baseColor: element.color,
    animations: element.animations,
    propertyPath: "color",
    localTime,
});

If neither fits (new value kind), add a dedicated resolve function following the same pattern as resolveOpacityAtTime and export it from src/lib/animation/index.ts.

4. Wire the renderer

In the relevant node (src/services/renderer/nodes/), call the resolve function before drawing:

typescript
const resolvedPaddingX = resolveNumberAtTime({
    baseValue: this.params.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX,
    animations: this.params.animations,
    propertyPath: "background.paddingX",
    localTime,
});

Use the resolved value (not this.params.*) anywhere that value affects rendering.

5. Wire the UI

In the properties panel, replace usePropertyDraft with the appropriate keyframe hook and add a KeyframeToggle to the field.

For number fields:

typescript
const { localTime, isPlayheadWithinElementRange } = useElementPlayhead({
    startTime: element.startTime,
    duration: element.duration,
});

const resolvedPaddingX = resolveNumberAtTime({
    baseValue: element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX,
    animations: element.animations,
    propertyPath: "background.paddingX",
    localTime,
});

const paddingX = useKeyframedNumberProperty({
    trackId,
    elementId: element.id,
    animations: element.animations,
    propertyPath: "background.paddingX",
    localTime,
    isPlayheadWithinElementRange,
    displayValue: Math.round(resolvedPaddingX).toString(),
    parse: (input) => {
        const parsed = parseFloat(input);
        return Number.isNaN(parsed) ? null : Math.max(0, Math.round(parsed));
    },
    valueAtPlayhead: resolvedPaddingX,
    buildBaseUpdates: ({ value }) => ({
        background: { ...element.background, paddingX: value },
    }),
});

In JSX:

tsx
<SectionField
    label="Width"
    beforeLabel={
        <KeyframeToggle
            isActive={paddingX.isKeyframedAtTime}
            isDisabled={!isPlayheadWithinElementRange}
            title="Toggle background width keyframe"
            onToggle={paddingX.toggleKeyframe}
        />
    }
>
    <NumberField
        value={paddingX.displayValue}
        onFocus={paddingX.onFocus}
        onChange={paddingX.onChange}
        onBlur={paddingX.onBlur}
        onScrub={paddingX.scrubTo}
        onScrubEnd={paddingX.commitScrub}
        onReset={() => paddingX.commitValue({ value: DEFAULT_TEXT_BACKGROUND.paddingX })}
        isDefault={isPropertyAtDefault({
            hasAnimatedKeyframes: paddingX.hasAnimatedKeyframes,
            isPlayheadWithinElementRange,
            resolvedValue: resolvedPaddingX,
            staticValue: element.background.paddingX ?? DEFAULT_TEXT_BACKGROUND.paddingX,
            defaultValue: DEFAULT_TEXT_BACKGROUND.paddingX,
        })}
    />
</SectionField>

For color fields, use useKeyframedColorProperty instead. It returns { onChange, onChangeEnd, toggleKeyframe, isKeyframedAtTime } — wire onChange({ color }) and onChangeEnd directly to the ColorPicker.


Checklist

  • Path added to ANIMATION_PROPERTY_PATHS
  • Registry entry added with correct valueKind, supportsElement, getValue, setValue
  • Resolve call added in the renderer node
  • UI field uses useKeyframedNumberProperty or useKeyframedColorProperty (not usePropertyDraft)
  • KeyframeToggle added to the SectionField
  • onReset calls commitValue (not editor.timeline.updateElements directly)