# Remotion

> Render handwriting animations to video with Tegaki and Remotion: frame-accurate timing, font loading and your own fonts.

Source: https://tegaki.ink/frameworks/remotion/

Tegaki integrates with [Remotion](https://remotion.dev) for programmatic video rendering. Because Remotion drives time via `useCurrentFrame()`, the trick is to put Tegaki in **controlled progress mode** and map `frame / durationInFrames` to a `0–1` progress value.

## Installation

```sh
npm install tegaki remotion @remotion/cli react react-dom
```

## Basic example

```tsx
import { Composition, useCurrentFrame, useVideoConfig } from 'remotion';
import { TegakiRenderer } from 'tegaki';
import caveat from 'tegaki/fonts/caveat';

const Handwriting: React.FC = () => {
  const frame = useCurrentFrame();
  const { durationInFrames } = useVideoConfig();
  const progress = frame / durationInFrames;

  return (
    <div style={{ flex: 1, backgroundColor: 'white', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
      <TegakiRenderer
        font={caveat}
        text="What a wonderful world"
        style={{ fontSize: 120 }}
        time={{ mode: 'controlled', value: progress, unit: 'progress' }}
      />
    </div>
  );
};

export const RemotionRoot: React.FC = () => {
  return (
    <Composition
      id="Handwriting"
      component={Handwriting}
      durationInFrames={180}
      fps={60}
      width={1920}
      height={1080}
    />
  );
};
```

The `unit: 'progress'` option interprets `value` as a `0–1` ratio of the total animation duration, so you don't need to know Tegaki's computed timeline length: Remotion's composition length drives the mapping directly.

## Loading fonts

Tegaki's font bundles (`tegaki/fonts/caveat`, `tegaki/fonts/italianno`, etc.) load their own font: the engine creates a `FontFace` and registers it with `document.fonts`. Remotion waits on `document.fonts.ready` before capturing each frame, so **no `loadFont` call is required**. Pass the bundle to `TegakiRenderer`.

## Webpack override for Remotion Studio

Rendering with `remotion render` needs no setup, but **Remotion Studio's dev server serves the TTF URL incorrectly** because the bundled path escapes the studio's root (webpack emits `../../node_modules/tegaki/fonts/caveat/caveat.ttf`, which the SPA catch-all intercepts and returns HTML).

The fix is to tell webpack to inline `.ttf` files as data URLs. Add a `remotion.config.ts` at the root of your Remotion project:

```ts
// remotion.config.ts
import { Config } from '@remotion/cli/config';

Config.overrideWebpackConfig((config) => {
  return {
    ...config,
    module: {
      ...config.module,
      rules: [
        ...(config.module?.rules ?? []).filter((rule) => {
          if (typeof rule !== 'object' || !rule || !rule.test) return true;
          const test = rule.test;
          return !(test instanceof RegExp && test.test('a.ttf'));
        }),
        {
          test: /\.ttf$/,
          type: 'asset/inline',
        },
      ],
    },
  };
});
```

This works in both the studio and in render/build mode. The bundled font is ~60–250 kB depending on the family, which is negligible for a video bundle.

## Controlling timing

By default, `progress = frame / durationInFrames` spreads the handwriting animation across the entire composition. Two common variations:

**Finish drawing before the end of the composition**, e.g. to hold the finished text for the last second:

```tsx
const { fps, durationInFrames } = useVideoConfig();
const drawFrames = durationInFrames - fps; // leave 1s of hold
const progress = Math.min(1, frame / drawFrames);
```

**Start drawing after a delay**:

```tsx
const { fps } = useVideoConfig();
const delayFrames = fps; // 1s delay
const drawFrames = 120; // 2s to draw
const progress = Math.max(0, Math.min(1, (frame - delayFrames) / drawFrames));
```

## Effects, plugins and the seed

Wobble, gradients and plugins such as `variationPlugin` draw their randomness from the renderer's [`seed`](https://tegaki.ink/api/renderer/#seed). It defaults to `0`, so every frame matches, including frames rendered in parallel tabs. To give a video a different look, pass another fixed number (`seed={7}`). Never pass `seed="random"` in a composition, because each rendering tab would pick its own seed and the handwriting would jump between frames.

`boilPlugin` picks its drawing from the time you pass, so it renders the same every time too. It follows the timeline's seconds, so pass the time in seconds rather than as progress if the boil should run at video speed and go on after the text is written:

```tsx
const { fps } = useVideoConfig();
<TegakiRenderer font={caveat} plugins={plugins} time={{ mode: 'controlled', value: frame / fps }} />;
```

Time past the end keeps the text finished, and the boil keeps cycling.

## Natural pacing

`unit: 'progress'` stretches or squeezes the writing to fit the composition. To keep Tegaki's own pace (its per-stroke timing, as on a web page), pass the time in **seconds** and size the composition from the timeline with `calculateMetadata`:

```tsx
import { Composition, type CalculateMetadataFunction, useCurrentFrame, useVideoConfig } from 'remotion';
import { computeTimeline, TegakiRenderer } from 'tegaki';
import caveat from 'tegaki/fonts/caveat';

const TEXT = 'What a wonderful world';
const HOLD_SECONDS = 1;

const Handwriting: React.FC = () => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();
  // Past the end, the time clamps: the finished text holds.
  return <TegakiRenderer font={caveat} text={TEXT} style={{ fontSize: 120 }} time={{ mode: 'controlled', value: frame / fps }} />;
};

const calculateMetadata: CalculateMetadataFunction<Record<string, unknown>> = ({ props }) => {
  const { totalDuration } = computeTimeline(TEXT, caveat);
  return { durationInFrames: Math.ceil((totalDuration + HOLD_SECONDS) * 30), props };
};

export const RemotionRoot: React.FC = () => (
  <Composition
    id="Handwriting"
    component={Handwriting}
    calculateMetadata={calculateMetadata}
    durationInFrames={1}
    fps={30}
    width={1920}
    height={1080}
  />
);
```

Pass `computeTimeline` the same `timing` you give the renderer (as its third argument), or the durations won't match. The timeline's `entries` also give each character's start and duration, and [`strokeInstances`](https://tegaki.ink/api/renderer/#strokeinstancestimeline-font) each stroke's, for syncing sound effects or cuts to the pen.

## Arabic, Devanagari and other complex scripts

Shaping is opt-in. Fonts that need it (joining, ligatures, reordering: `amiri`, `tillana`, `atma`, `suez-one`) need the harfbuzz shaper registered once, and a composition sized from the **shaped** timeline, which can differ from the unshaped one by a third:

```tsx
import { computeTimeline, TegakiEngine } from 'tegaki';
import amiri from 'tegaki/fonts/amiri';
import harfbuzzShaper, { createHarfbuzzShaper } from 'tegaki/shaper-harfbuzz';

TegakiEngine.registerShaper(harfbuzzShaper);

const calculateMetadata = async () => {
  const shaper = await createHarfbuzzShaper(amiri);
  const { totalDuration } = computeTimeline('مرحبا بالعالم', amiri, undefined, shaper);
  return { durationInFrames: Math.ceil((totalDuration + 1) * 30) };
};
```

The shaper loads asynchronously. To be sure no frame is captured before it (and the font) are ready, hold the render on `TegakiEngine.preload(bundle)` with Remotion's `delayRender` / `continueRender`.

## Using your own font

The pre-built bundles (`tegaki/fonts/*`) are convenient but you can use any font generated by the Tegaki generator. See [Generating Font Data](https://tegaki.ink/guides/generating/).
