# `*.mvideo.json` — schema `mvideo/v1`

One JSON file describes the whole project. Times are in **frames** (`frame = seconds × fps`).

## Top level

| Field | Type | Notes |
|---|---|---|
| `$schema` | string | Must be `"mvideo/v1"`. |
| `fps` | number | Frames per second (e.g. 30). |
| `width`, `height` | number | Pixel dimensions. 1080×1920 = vertical 9:16; 1920×1080 = 16:9; 1080×1080 = 1:1. |
| `duration` | number | Total frames. Maintained automatically by `timeline_edit`. |
| `branding` | object | `{ "$ref": "auto" }` to apply the nearest `branding.json`, or an inline brand object. |
| `assets` | object | `id → asset`. The reusable media/entity library. |
| `tracks` | array | Ordered lanes. Earlier video/image tracks paint first (lower); later ones on top. Text renders above video; audio tracks are heard, not seen. |
| `selection` | object/null | The last UI selection, so "this clip" is unambiguous. |

## Asset

```jsonc
{
  "kind": "video" | "image" | "audio" | "character" | "place",
  "src": "files/espresso.mp4",   // workspace-relative (media assets)
  "prompt": "espresso pour, macro, golden light",  // keep it — enables regeneration
  "model": "wan-2.2",            // the model that made it
  "params": { "seed": 12 },      // extra generation params to reproduce it
  "hasAudio": true               // a video whose audio can be split out
  // character/place entities instead use: name, refImages[], descriptor, seedLock (see consistency.md)
}
```

## Track

```jsonc
{
  "id": "v1",
  "type": "video" | "image" | "text" | "audio",
  "role": "music" | "sfx" | "voice" | "source",  // audio tracks only
  "linkedTo": "c2",              // a split source-audio track: the video clip it follows
  "clips": [ /* Clip */ ]
}
```

## Clip

```jsonc
{
  "id": "c1",
  "asset": "a1",                 // asset id (text clips omit this)
  "from": 0, "to": 120,          // start (inclusive) / end (exclusive) frame on the timeline
  "trimIn": 5, "trimOut": 125,   // source in/out points (frames), for trimming
  "transform": { "scale": 1.05, "x": 0, "y": 0, "opacity": 1 },
  "transition": { "in": "fade", "out": "cut" },
  // text clips:
  "text": "Fresh, daily.",
  "style": "brand/title",        // a brand style name, or an inline object { size, color, font, weight, align }
  "animation": { "preset": "slide-up" },
  // audio clips:
  "gain": -8,                    // dB
  "fadeIn": 0, "fadeOut": 30,    // frames
  "audioOnly": true              // a split source-audio clip: play only the asset's audio
}
```

## Worked example — a 10s vertical spot

```json
{
  "$schema": "mvideo/v1",
  "fps": 30,
  "width": 1080,
  "height": 1920,
  "duration": 300,
  "branding": { "$ref": "auto" },
  "assets": {
    "hero": { "kind": "image", "src": "files/storefront.png", "prompt": "café storefront at dawn", "model": "flux2-dev" },
    "pour": { "kind": "video", "src": "files/espresso.mp4", "prompt": "espresso pour, macro, golden light", "model": "wan-2.2", "hasAudio": true },
    "latte": { "kind": "video", "src": "files/latte.mp4", "prompt": "latte art rosetta", "model": "wan-2.7" },
    "bed": { "kind": "audio", "src": "files/track.mp3", "prompt": "warm acoustic loop", "model": "ace-step" },
    "pourSfx": { "kind": "audio", "src": "files/pour.mp3", "prompt": "espresso pour foley", "model": "stable-audio-3" }
  },
  "tracks": [
    { "id": "v1", "type": "video", "clips": [
      { "id": "c1", "asset": "hero", "from": 0, "to": 60, "transition": { "in": "fade" } },
      { "id": "c2", "asset": "pour", "from": 60, "to": 180 },
      { "id": "c3", "asset": "latte", "from": 180, "to": 300, "transition": { "out": "fade" } }
    ]},
    { "id": "t1", "type": "text", "clips": [
      { "id": "c4", "from": 10, "to": 90, "text": "Fresh, daily.", "style": "brand/title", "animation": { "preset": "slide-up" } }
    ]},
    { "id": "au1", "type": "audio", "role": "music", "clips": [
      { "id": "c5", "asset": "bed", "from": 0, "to": 300, "gain": -8, "fadeOut": 30 }
    ]},
    { "id": "au2", "type": "audio", "role": "sfx", "clips": [
      { "id": "c6", "asset": "pourSfx", "from": 60, "to": 120 }
    ]}
  ]
}
```

## Editing with `timeline_edit`

Pass `path` + an ordered `ops` array. Each op is `{ op, ... }`:

| op | fields | effect |
|---|---|---|
| `set_meta` | `fps? width? height? duration? branding?` | project metadata |
| `add_asset` | `id?` `asset` | add/replace a library asset (id generated if omitted) |
| `update_asset` | `id` `patch` | shallow-merge into an asset |
| `remove_asset` | `id` | delete an asset |
| `add_track` | `track` | append a track (id/clips filled in if missing) |
| `remove_track` | `trackId` | delete a track |
| `add_clip` | `trackId` `clip` | append a clip (needs `to` > `from` ≥ 0; id generated if omitted) |
| `update_clip` | `clipId` `patch` | shallow-merge into a clip (trim, style, transform, swap `asset`…) |
| `move_clip` | `clipId` `from?` `to?` | reposition on the timeline |
| `split_clip` | `clipId` `at` | cut a clip in two at frame `at` (trims carried; `at` strictly inside the clip) |
| `remove_clip` | `clipId` | delete a clip |
| `set_selection` | `selection` | record the current selection |

`duration` is recomputed after every batch. Failed ops are reported per-op without aborting the rest.
Frame bounds are validated: an op that would make `from ≥ to` or a negative frame is rejected (and reported).

## Remote / existing sources

An asset can carry a `source` describing where it came from (its `src` is always the LOCAL cached copy):

```jsonc
"clipFromWeb": {
  "kind": "video",
  "src": "files/dl-abc.mp4",                 // the downloaded copy
  "source": { "type": "youtube", "url": "https://youtu.be/…", "fetchedAt": "2026-…", "title": "…" }
}
```

Don't hand-write remote assets — call `import_video` (or the editor's "add video"), which downloads the
file, adds the asset with its `source`, and appends a segment.

## Side-by-side & picture-in-picture

A clip's `transform` places it either full-frame (default: cover-fit + `scale` + pixel `x`/`y`) or in an
explicit **box** given as frame fractions:

```jsonc
"transform": { "x": 0, "y": 0, "width": 0.5, "height": 1, "fit": "cover" }   // left half
```

Put two boxed clips on different tracks, active over the same frames, for a split screen; a small inset box
over a full-frame clip is picture-in-picture. `fit` is `cover` (default) or `contain`.

## Backgrounds, animation, fonts

- **`clip.fill`** (no asset) paints a background: `{ type: "color"|"gradient"|"pattern"|"image", … }`.
  gradient → `shape` (linear/radial) + `angle` + `stops:[{color,at}]`; pattern → `pattern`
  (dots/grid/stripes/checker) + `fg`/`bg`/`size`. Colors accept `brand/<key>`.
- **`clip.animation`** = `{ in?, out?, emphasis?, preset? }`. `in`/`out` = `{ type: fade|slide|scale|wipe,
  from?: edge, dur?: frames, ease?: linear|in|out|in-out|spring }`. Set it via `update_clip`, or visually
  in the editor's clip inspector.
- **`clip.keyframes`** = `[{ frame, x?, y?, scale?, rotate?, opacity? }]` (clip-local frames) for full
  motion control; combines with `animation`.
- **Fonts:** run `font_ensure` to cache Google fonts into the nearest `branding.json` (`fontFiles`); text
  then renders in that family in both preview and render. Reference via `fonts.<role>` or the family name.
