# `PhxDemo.Render`
[🔗](https://github.com/thenatespack/phoenix_demo/blob/main/lib/phx_demo/render.ex#L1)

Turns a demo's raw recording into shareable files with ffmpeg.

Input: a demo directory containing Playwright's `page@<hash>.webm` (or a
previously renamed `raw.webm`) and `timeline.json`.

Output, named after the directory (the demo slug):

  * `<slug>.mp4`: H.264, trimmed and auto-zoomed (see `PhxDemo.Plan` and
    `PhxDemo.Zoom`), with a soft caption track (mov_text) and chapters from
    scenes. Narration audio is mixed in when `say` events carry an `audio`
    file (see `PhxDemo.TTS`), and so is background music when the `:music`
    setting is a path or a generation prompt (see `music_filter/3` and
    `PhxDemo.MusicGen`). Input audio (`upload_audio` and
    `use_microphone` with `audible: true`, see `PhxDemo.Audio`) is mixed in at
    its timeline offset, and the music ducks under it like under narration.
  * `<slug>.srt`, `<slug>.vtt`: sidecar captions from `say` events
  * `<slug>.gif`: when `:gif` is in `:formats`

Requires `ffmpeg` and `ffprobe` on PATH.

# `result`

```elixir
@type result() :: %{optional(atom()) =&gt; Path.t()}
```

# `audio_clips`

Narration clips as `{path, at_ms}` from `say` events that have audio.

# `chapter_args`

Second pass: copy all streams and add chapters/title from FFMETADATA.

# `detect_sync`

The magenta sync flash in the raw video as `{start_ms, clear_ms}`: its first
frame, and the first frame after it that isn't magenta (nil if the flash
outlasts the first 5 seconds). nil when there's no flash.

The page paints a frame or two after the step that follows the flash is
recorded, so cutting at a timeline time alone can leave magenta frames at
the start; `PhxDemo.Plan` never starts the cut before `clear_ms`.

# `detect_sync_ms`

Where the magenta sync flash first appears in the raw video, in ms, or nil.
Looks at the center of the first 5 seconds.

# `input_windows`

Audible input audio (`audio_input` events with `audible: true`) as
`{path, at_ms, duration_ms, loop?}`, for a video of `total_ms`.

An upload plays for the file's length. A microphone plays from its `play`
event until the file ends (never, if it loops), the next `play` or `stop`
event, or the end of the video. `loop?` says the input needs `-stream_loop`.

# `mp4_args`

ffmpeg arguments for the MP4 encode pass. Public for testing.

Inputs, in order: the raw video (trimmed to the plan), one input per
narration clip, then captions (if any).

# `music_filter`

Filter chain for the background music input, ending in `[out]`.

Input `index` is opened with `-stream_loop -1`, so it loops; the chain
trims it to `duration_ms`, sets the level, ducks 10 dB (with short ramps)
around each `{at_ms, duration_ms}` in `:windows`, then fades in (1s) and
out (2s). Fades shrink on very short videos.

Options: `:windows`, `:volume_db` (default -18), `:out` (default "aout").

# `overlaps`

Pairs of `{input_file, say_text}` where an audible input overlaps a narration
line. Narration and input audio would talk over each other.

# `raw_video`

Finds the raw recording and renames it to `raw.webm`. Playwright names it
`page@<hash>.webm`.

# `render`

```elixir
@spec render(Path.t(), keyword()) :: {:ok, result()} | {:error, String.t()}
```

Renders one demo directory.

Options:
  * `:formats`: list of `:mp4` and/or `:gif` (default `[:mp4]`). A GIF is
    made from the finished MP4.
  * `:gif_width`: default 800
  * `:gif_fps`: default 12
  * `:crf`: H.264 quality, lower is better (default 20)

# `say_windows`

Narration windows as `{at_ms, duration_ms}` for clips that carry audio.

---

*Consult [api-reference.md](api-reference.md) for complete listing*
