Scripting

Use addFrameTask to animate elements programmatically on a per-frame basis.

addFrameTask

addFrameTask is an instance method on every ef-timegroup element. Register a callback and it runs when that group presents a frame during playback, seeking, or export.

Signature

ts
timegroupElement.addFrameTask(callback: FrameTaskCallback): () => void

Returns a cleanup function that removes the callback when called.

ts
interface FrameTaskCallback {
  (info: {
    ownCurrentTime: number;    // seconds elapsed since this group's start
    currentTime: number;       // same local seconds as ownCurrentTime
    duration: number;          // total duration of this group in seconds
    percentComplete: number;   // ownCurrentTime / duration
    element: EFTimegroupElement;
  }): void | Promise<void>;
}

The legacy ownCurrentTimeMs, currentTimeMs, and durationMs fields are also available in milliseconds. New code should use the seconds fields.

initializer

When rendering, Editframe creates isolated render clones of the root ef-timegroup. Any addFrameTask call in an inline <script> runs once on the live preview but never reaches those clones.

The initializer property solves this. Assign a synchronous callback to it and it runs on both the live timeline and every render clone. Put imperative addFrameTask setup inside an initializer so cloned compositions receive it too. In React, a TimelineRoot also remounts hooks such as useTimingInfo for each clone.

html
<ef-timegroup id="root" duration="3s" class="w-[1920px] h-[1080px] bg-black flex items-center justify-center">
  <ef-text id="headline" class="text-white text-8xl font-bold" style="opacity: 0;">
    Fade In
  </ef-text>
</ef-timegroup>

<script type="module">
  const root = document.getElementById("root");

  root.initializer = (instance) => {
    const headline = instance.querySelector("#headline");

    instance.addFrameTask(({ percentComplete }) => {
      headline.style.opacity = String(percentComplete);
    });
  };
</script>

Query elements via the instance argument, rather than document.getElementById: render clones have their own element trees and can contain the same IDs as the live composition.

The initializer must be synchronous. Async initializers throw at runtime. Keep it brief: it registers work rather than running an animation loop.

Example: typewriter effect

html
<ef-timegroup id="root" duration="3s" class="w-[1920px] h-[1080px] bg-black flex items-center justify-center">
  <ef-text id="typewriter" class="text-white text-6xl font-mono"></ef-text>
</ef-timegroup>

<script type="module">
  const root = document.getElementById("root");
  const fullText = "Hello, world.";

  root.initializer = (instance) => {
    const el = instance.querySelector("#typewriter");

    instance.addFrameTask(({ percentComplete }) => {
      el.textContent = fullText.slice(0, Math.floor(percentComplete * fullText.length));
    });
  };
</script>

Example: counter

html
<ef-timegroup id="root" duration="2s" class="w-[1920px] h-[1080px] bg-black flex items-center justify-center">
  <ef-text id="counter" class="text-white text-9xl font-bold tabular-nums">0</ef-text>
</ef-timegroup>

<script type="module">
  const root = document.getElementById("root");
  const target = 1000000;

  function easeInOut(t) {
    return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
  }

  root.initializer = (instance) => {
    const counter = instance.querySelector("#counter");

    instance.addFrameTask(({ percentComplete }) => {
      counter.textContent = Math.round(easeInOut(percentComplete) * target).toLocaleString();
    });
  };
</script>

Targeting individual elements

ownCurrentTime and currentTime are both relative to the group's own start. A group starting at 2s in the composition reports 0 at that moment. The corresponding *Ms fields use the same local clock in milliseconds. Register a task on the root timegroup when you need root composition time.

Frame task callbacks can be synchronous or async. Presentation waits for their promises before capturing a frame. Keep tasks short for responsive playback, and derive their output from the supplied composition time so seeking and export reproduce the same result.

React

In React, pass initializer as a function prop. Use TimelineRoot around the composition when exporting so its setup is remounted in each render clone:

tsx
import { TimelineRoot, Timegroup, Text } from "@editframe/react";
import type { EFTimegroupElement } from "@editframe/elements";

const initializeFade = (instance: EFTimegroupElement) => {
  const headline = instance.querySelector<HTMLElement>("#headline");
  instance.addFrameTask(({ percentComplete }) => {
    if (headline) headline.style.opacity = String(percentComplete);
  });
};

function FadeIn({ id }: { id: string }) {
  return (
    <Timegroup id={id} initializer={initializeFade} duration="3s" className="w-[1920px] h-[1080px] bg-black flex items-center justify-center">
      <Text id="headline" className="text-white text-8xl font-bold" style={{ opacity: 0 }}>
        Fade In
      </Text>
    </Timegroup>
  );
}

export default function App() {
  return <TimelineRoot id="root" component={FadeIn} />;
}