# Motion

<StatusBadge status="available" /> `format: motion` turns a story into a **film**: every
scene is a **shot**, played by an animated block (title, chapter, statement, stat, list, compare
bars, UI mock, UI scene) in `16:9`, `1:1` or `9:16`.

The Markdown syntax below and its parser are ready (`@recit/motion/source`). A film plays in a page
with `<StoryEmbed slug="…" format="motion" />`, and [motion.recit.dev](https://motion.recit.dev) shows the
gallery in every aspect.

A motion story is written like a deck: front matter, scenes separated by `---`, attributes on the
separator, `:::notes`. There is no second syntax. The Markdown is read through a **closed inference
table**: a scene whose shape matches no row is an error with its line number, never a guess.

## A minimal story

```md title="hello.story.md"
---
title: Hello, Récit
format: motion
aspectRatio: 9:16
theme: warm-editorial
---

--- id=open

Récit

# Films from a JSON file.

--- id=why

One film. One engine. Two render paths that **match**.

:::notes
Voice-over: lorem ipsum dolor sit amet.
:::

--- type=stat id=proof hold=1

# 30s

vertical film

_rendered locally_
```

The first scene is a `title` (a line, then a `#` heading), the second a `statement` (one paragraph,
the **bold** run is accented), the third a `stat`, which is never inferred and so declares
`type=stat`.

## Front matter

| Key              | Value                                         | Default          |
| ---------------- | --------------------------------------------- | ---------------- |
| `title`          | label of the film                             | none             |
| `format`         | `motion`                                      | `deck`           |
| `aspectRatio`    | `16:9`, `1:1` or `9:16` (alias: `aspect`)     | `9:16`           |
| `theme`          | `warm-editorial`, `saas-product` or an import | `warm-editorial` |
| `wordsPerSecond` | narration pace that sets scene durations      | `2.2`            |

Settings are **flat**, like every format. A nested `motion:` block is accepted as well, and the
`motion:` section of `recit.config.ts` gives the defaults of a project. Precedence: flat key, then
the nested `motion:` block, then `recit.config.ts`. A theme can also come from `theme.name`, the
[named theme](/theme/named-themes) a story shares with its deck: `warm-editorial` and `saas-product`
are the same tokens in both formats.

```ts title="recit.config.ts"
export default defineConfig({
  motion: { theme: "saas-product", wordsPerSecond: 2.6, aspect: "9:16" },
});
```

## Scenes and attributes

Each scene starts with a separator. Its attributes are the canonical way to set a scene:

```md
--- type=stat id=proof hold=1
```

| Attribute     | Role                                                                            |
| ------------- | ------------------------------------------------------------------------------- |
| `id`          | stable id of the scene, used by edits. Default: `s1`, `s2`…                     |
| `type`        | block of the scene. Inferred from the content when absent, except `stat`        |
| `dur`         | duration in seconds, instead of the computed reading time                       |
| `hold`        | extra seconds held after the motion settles                                     |
| `layout`      | deck layout: `cover` is a `title`, `section` a `chapter`, `quote` a `statement` |
| any other key | a prop of the block (`kicker`, `number`, `unit`, `caption`…), checked by block  |

A value with spaces is quoted: `caption="One reply, sent and logged"`. An attribute that is not a
prop of the block is an error (`unknown attribute "color"`), and so is a prop set both by an
attribute and by the content.

### HTML comment alias

An HTML comment on the first line of a scene is an alias of the separator attributes. It stays
invisible on GitHub:

```md
---

<!-- type: stat, id: proof, hold: 1 -->

# 30s

vertical film
```

When both set the same key, the separator wins. Rewrites always emit the separator form.

## Inference table

A scene without `type` is read by its shape. The table is closed: a shape that is not listed is an
error.

| Markdown shape                                          | Block          | Props                                                                |
| ------------------------------------------------------- | -------------- | -------------------------------------------------------------------- |
| `# Heading` alone                                       | `title`        | heading → `text`                                                     |
| a line, then `# Heading`                                | `title`        | line → `kicker`, heading → `text`                                    |
| `## Heading` alone                                      | `chapter`      | heading → `title`; `## 01 · Title` → `number` and `title`            |
| one paragraph                                           | `statement`    | text → `text`, **bold** runs → `emphasis`                            |
| optional heading, then a bullet list                    | `list`         | heading → `title`, items → `items`                                   |
| optional heading, then a `label`/`before`/`after` table | `compare-bars` | heading → `title`, rows → `rows`, `unit` as an attribute             |
| `type=stat`, never inferred                             | `stat`         | `#` heading → `value`, line → `label`, _italic_ or quote → `caption` |
| `type=ui-mock`, `type=ui-scene`                         | same           | a ` ```yaml ` block holds the props                                  |
| a `> quote` of one paragraph                            | `statement`    | as one paragraph                                                     |

The heading of a `list` or a `compare-bars` may have any level. With an explicit `type`, a block
also accepts its close variants: `type=title` (or `layout=cover`) takes the kicker before or after
the heading, and `type=chapter` (or `layout=section`) takes a `#` heading.

### Blocks

```md
--- id=chapter

## 01 · How it works

--- id=list

## Three steps

- Reads the thread
- Drafts a reply
- You hit send

--- id=compare unit=m

## Response time

| label  | before | after |
| ------ | -----: | ----: |
| manual |     42 |     3 |

--- type=stat id=stat

# 92%

replies drafted

_before a human opens the thread_
```

Numbers in a `compare-bars` table are numbers: `42`, `3.5`, `-2`. Anything else is an error, since
no metric is ever made up.

### The ` ```yaml ` escape hatch

A scene made of one ` ```yaml ` (or ` ```json `) block takes its props from it, whatever the block.
Everything a `film.json` can say stays possible in Markdown, including the blocks of a third-party
pack. The separator must name the block with `type=`, and scalar props may still be attributes:

````md
--- type=ui-scene id=scene chrome=app caption="One reply, sent and logged"

```yaml
states:
  - id: skeleton
    hold: 1
  - id: loaded
    hold: 2.5
root:
  kind: col
  children:
    - kind: bar
      w: 50%
```
````

## Voice-over

`:::notes` becomes the `voiceover` of the scene: the text read over the shot, or its caption. It is
never drawn by the blocks. A voice-over longer than the text on screen keeps the scene on screen
longer (its words at `wordsPerSecond`).

```md
--- id=why

One film. **One engine.**

:::notes
Voice-over: lorem ipsum dolor sit amet.
:::
```

## Errors

The parser never guesses. Every problem names the file, the line and the block:

````text
story.md:14 (stat): label is required
story.md:7: a stat is never inferred: add type=stat to the separator
story.md:10 (compare-bars): row 2: before "lots" is not a number
story.md:9 (list): use a bullet list (- item) for a list
story.md:7 (ui-mock): ui-mock takes its props from a ```yaml block
story.md:7: a ```yaml block needs type= on the separator
story.md:7: layout "grid" has no motion block
story.md:9: a code block (ts) has no motion block
````

Code blocks, math, images, raw HTML, directives such as `:::card` and the deck layouts `grid`,
`columns`, `split` and `image-*` have no motion block yet: they are errors, not silently dropped. A
deck written for slides renders as a film when its scenes keep to the table above. Its `:::step`
blocks are shown with the scene, and each one holds the shot one more second (an explicit `hold=`
wins); bullet lists split across steps make one `list`.

## JSON

A motion story has three interchangeable forms. All of them end in the same validated deck.

| Form            | File                                    | Role                                                |
| --------------- | --------------------------------------- | --------------------------------------------------- |
| Markdown        | `hello.story.md`                        | written by people, read on GitHub                   |
| typed JSON twin | `hello.story.json` (`kind: "story"`)    | the Markdown as typed JSON, scene by scene          |
| deck            | `hello.film.json` (`version`, `scenes`) | the render model: one block and its props per scene |

```json title="hello.story.json"
{
  "$schema": "https://recit.dev/schema/v1/motion.json",
  "kind": "story",
  "version": 1,
  "format": "motion",
  "meta": { "title": "Hello, Récit" },
  "settings": { "aspectRatio": "9:16", "theme": "warm-editorial" },
  "scenes": [
    { "id": "proof", "type": "stat", "hold": 1, "body": "# 30s\n\nvertical film" }
  ]
}
```

A scene of the twin has typed fields: `type`, `dur`, `hold`, `props` (the ` ```yaml ` block),
`voiceover` (the `:::notes`), then `attrs` for the other separator attributes and `body` for the
natural Markdown form. `settings` holds `aspectRatio`, `theme` and `wordsPerSecond`.

`recit convert` turns the Markdown into its twin and back. A `film.json` turns into Markdown with
`filmToMarkdown`: the output is canonical, with an `id=` on every separator, the natural form of
each block and a ` ```yaml ` block for props without one. Writing a deck back to a `.md` file
rewrites the whole story in this canonical form (v1): comments, blank lines and front matter keys
other than the deck settings are not kept.

## Playing a film in a page

In a Récit site, a motion story published by `publish:` plays in the page with the browser engine of
`@recit/motion-blocks`: no Remotion, no MP4. Its fullscreen page is `/present/<slug>` (the
`routePrefix`).

```mdx
<StoryEmbed slug="hello" />
<StoryEmbed slug="hello" aspect="1:1" time={2.5} />
<StoryEmbed slug="talk" format="motion" />
<StoryEmbed slug="hello" poster="/hello.png" src="/hello.mp4" />
```

| Prop       | Role                                                                          |
| ---------- | ----------------------------------------------------------------------------- |
| `format`   | `motion` plays a deck story as a film when its scenes fit the table above     |
| `aspect`   | `16:9`, `1:1` or `9:16`, instead of the story's                               |
| `time`     | opens paused at this second; `?t=<seconds>` in the URL does the same          |
| `autoplay` | plays once visible (default), never under reduced motion                      |
| `loop`     | starts over at the end (default)                                              |
| `poster`   | a still, shown by the server-rendered page until the player loads             |
| `src`      | a pre-rendered MP4, played by the server-rendered page until the player loads |

The player is frame-accurate: a paused frame is the frame of the MP4. :kbd[Space] plays and pauses,
← and → seek one second.

## Packages

| Package                | Role                                                                                    |
| ---------------------- | --------------------------------------------------------------------------------------- |
| `@recit/motion`        | deck schema (`parseFilm`), blocks, timing, ops, handover                                |
| `@recit/motion/source` | `parseFilmSource`, `formatOf`, `storyToHandover`, `parseMarkdownFilm`, `filmToMarkdown` |
| `@recit/motion/node`   | `readFilmFile`, `writeFilmFile`: `.json` and `.md` decks on disk                        |
| `@recit/motion-blocks` | the React blocks and themes, played in the browser                                      |
| `@recit/motion-render` | MP4 and stills with Remotion                                                            |
| `@recit/mcp`           | the MCP server: agents build, edit and render decks                                     |

```ts
import { readFilmFile } from "@recit/motion/node";

const result = await readFilmFile("hello.story.md");
if (!result.ok) console.error(result.message); // hello.story.md:14 (stat): label is required
```
