# LVF — Lyric Video Frame pack

A container for pre-rendered 1-bit animation, sized for microcontrollers. One
file holds every frame of a song; the device does nothing but decode and push
bytes at the panel.

Everything is little-endian except the magic.

## Header (24 bytes)

| Offset | Size | Field | Notes |
|---|---|---|---|
| 0  | 4 | `magic` | `4C 56 46 31` — ASCII `LVF1`, big-endian so it reads as text in a hex dump |
| 4  | 2 | `width` | pixels |
| 6  | 2 | `height` | pixels |
| 8  | 1 | `fps` | frames per second |
| 9  | 1 | `flags` | bit 0: `1` = page-packed, `0` = row-packed |
| 10 | 4 | `frame_count` | number of frame records |
| 14 | 4 | `duration_ms` | song length, may exceed `frame_count / fps` |
| 18 | 4 | `frame_bytes` | decoded size of one frame |
| 22 | 2 | `version` | `1` |

`frame_bytes` is `ceil(height / 8) * width` when page-packed and
`ceil(width / 8) * height` when row-packed. It is stored explicitly so a
decoder never has to recompute it.

## Frame records

Immediately after the header, `frame_count` records, back to back:

```
uint8   kind
uint16  payload_length
uint8   payload[payload_length]
```

| `kind` | Name | Payload |
|---|---|---|
| 0 | `RAW` | the frame verbatim, `frame_bytes` long |
| 1 | `RLE` | the frame, RLE-compressed |
| 2 | `XOR_RLE` | RLE of `frame XOR previous_frame` |
| 3 | `DUP` | empty — the frame is identical to the previous one |

The encoder tries all applicable kinds and keeps the smallest. On typical
material `XOR_RLE` wins most frames and `DUP` covers held lines, which is where
the ~10x saving comes from: a lyric panel changes very little between frames.

**Frames are not independently decodable.** `XOR_RLE` and `DUP` reference the
previous frame, so playback starts at record 0 and runs forward. Seeking
backwards means restarting from the beginning and decoding forward. At these
sizes that is cheap — a full 3-minute pass is well under a second on an ESP32 —
but it is the one thing to know before wiring up a scrub bar.

## RLE

Byte-oriented, tuned for packed 1-bit frames: long runs of `0x00` across empty
panel, long runs of `0xFF` through a knockout slab, literals along glyph edges.

| Control byte | Meaning |
|---|---|
| `0x00`–`0x7F` | repeat the **next** byte `control + 1` times (1–128) |
| `0x80`–`0xFF` | copy the next `control - 0x7F` bytes verbatim (1–128) |

Decoding is a dozen lines and needs no window or table:

```c
size_t i = 0, o = 0;
while (i < src_len && o < dst_len) {
  uint8_t c = src[i++];
  if (c < 0x80) { uint8_t v = src[i++]; for (int k = 0; k <= c && o < dst_len; k++) dst[o++] = v; }
  else          { for (int k = 0; k < c - 0x7F && o < dst_len; k++) dst[o++] = src[i++]; }
}
```

## Pixel packing

**Page-packed** (`flags & 1`) matches SSD1306/SSD1309 GDDRAM exactly: the panel
is divided into 8-pixel-tall pages, one byte per column per page, **bit 0 is the
topmost** row of the page.

```
byte index = page * width + x        where page = y / 8
bit        = y % 8
```

This is the format to use when driving the panel directly, because the decoded
buffer can be handed to the display controller with no transposition.

**Row-packed** is row-major, MSB first — what `Adafruit_GFX::drawBitmap()`
expects.

```
byte index = y * ceil(width / 8) + (x / 8)
bit        = 0x80 >> (x % 8)
```

## Sizes

Measured on the bundled demo (102 s, 128x64, `auto` style):

| fps | Frames | Raw | Packed | Ratio |
|---|---|---|---|---|
| 12 | 1224 | 1224 KB | 156 KB | 12.7% |
| 15 | 1530 | 1530 KB | 185 KB | 12.1% |
| 20 | 2040 | 2040 KB | 230 KB | 11.3% |
| 30 | 3060 | 3060 KB | 312 KB | 10.2% |

Page-packing also compresses substantially better than row-packing on the same
content (189 KB vs 364 KB for the table above), because a horizontal run of lit
pixels becomes a run of identical bytes only in page order. Another reason to
prefer it unless a library forces your hand.

Ratios depend heavily on the material. Busy treatments (`rainFall`,
`starburstZoom`, `glitchSlice`) change most of the panel every frame and
compress far worse than held type. If a song comes out too large, dropping to
15 fps costs very little visually — these treatments were designed around low
frame rates.

15 fps is a good default for flash-resident playback. Past 20 fps you are
mostly paying for motion the panel's own response time will smear anyway.

---

# Timeline header

The other export. Instead of baking pixels, it ships the shot list and lets the
device render:

```c
typedef struct {
  uint32_t      start_ms;
  uint32_t      end_ms;
  LvSegmentKind kind;       // LV_CUE | LV_INTERLUDE
  LvTreatment   treatment;  // enum generated from the treatments actually used
  uint8_t       section;
  const char   *text;
} LvSegment;
```

A few kilobytes for a whole song, and the same file works on any panel size —
but the firmware has to carry the treatment renderers, the font and the layout
code. Use this when you want the device to react live (to Spotify position, to
a button, to a different panel) rather than replay a fixed render.

The two exports are not exclusive. A reasonable split is timeline rendering for
the common treatments and a small frame pack for one or two set-pieces that are
too expensive to draw on-device.
