# VOD assets: upload, transcode, poster and playback

> The lifecycle of a stored asset: how it gets created, what auto-transcode produces, how poster frames are picked, which playback URLs exist, and what the player reports back.

A **VOD asset** is a stored recording or upload in the ClutchCall Stream
library. Every asset row carries a status, an optional playback id, probe
metadata, a poster position, and a signed-URL flag. The Assets screen reads
these rows through `streams.assets.list({ orgId, page, perPage })`, which joins
each `stream_asset` row with its 24-hour play count from the
`clutchcall.stream_asset_play_1m_q` rollup in ClickHouse. The screen
re-queries the list every 30 s, so state changes appear without a reload.

This page explains what happens to an asset between upload and playback, and
which URL to hand to which consumer.

## Asset states

| Status       | Meaning                                                        | Playback id |
| ------------ | -------------------------------------------------------------- | ----------- |
| `uploading`  | Source bytes are still arriving.                               | Not yet     |
| `processing` | The source has landed; probe and (if enabled) transcode are running. | Not yet |
| `ready`      | The asset is playable.                                         | Yes         |
| `errored`    | Processing did not complete. The asset stays in the library.    | No          |

The playback endpoints modal is only meaningful for a `ready` asset. Open an
asset that has no `playback_id` and the modal says so explicitly — *"No playback
id yet — the asset is still `<status>`"* — instead of rendering URLs that would
resolve to nothing.

## How an asset is created

The library holds both recordings and direct uploads.

Uploads go through a **resumable TUS2 client** against the control-plane
endpoint `/tus/uploads`. Because the upload is tracked server-side rather than
in the browser tab, an upload started elsewhere still shows up here:
`streams.tusUploads.inflight({ orgId })` returns the in-flight rows and the
screen polls it every 5 s while uploads are moving.

Each in-flight row exposes:

| Field          | Meaning                                            |
| -------------- | -------------------------------------------------- |
| `id`           | Upload id. Used to resume the transfer.            |
| `progress_pct` | Server-side received fraction.                     |
| `status`       | Upload state.                                      |
| `metadata`     | The TUS metadata map sent by the client (filename and friends). |

When the transfer completes, the upload becomes a `stream_asset` row and moves
to `processing`. Org-level VOD defaults — the default poster position and the
auto-transcode flag — are applied at that point, so change them *before* you
upload if you want them to take effect on the new asset.

`streams.assets.remove({ orgId, id })` deletes an asset from the library.

## Passthrough versus auto-transcode (ABR)

Auto-transcode is an org-wide opt-in, stored in `stream_org_settings` and read
and written through `streams.settings.get` / `streams.settings.update`:

```ts
await streams.settings.update({ orgId, autoTranscode: true });
```

| `autoTranscode` | What ingest does                                                        | What the asset gets |
| --------------- | ----------------------------------------------------------------------- | ------------------- |
| `false` (passthrough) | Nothing. The source file is stored as-is and is immediately playable. | Progressive MP4 only. The player range-requests the single file. |
| `true` (ABR)    | The source is transcoded into several bandwidth renditions on ingest.   | A CMAF ladder, served as both HLS and DASH, plus the progressive source. |

Passthrough is the fast path: there is no transcode to wait for. ABR costs
processing time on ingest and buys adaptive delivery for viewers on variable
networks.

The console decides which protocols to advertise from the asset's probe
dimensions: an asset with a `width` or `height` has been probed and carries a
ladder, so HLS and DASH links render. A passthrough upload has neither, so those
links are suppressed and the modal tells you why rather than offering URLs that
would 404.

## Poster frames: org default and per-asset override

A poster position is stored as a **fraction of the asset's duration**, not as an
absolute timestamp, so it stays valid regardless of how long the asset is.

- **Org default** — `defaultThumbnailTimestampPct` in the VOD defaults bar. It
  applies to every new upload. Saved with
  `streams.settings.update({ orgId, defaultThumbnailTimestampPct })`.
- **Per-asset override** — the slider on each asset card, in steps of 0.01 over
  the range 0–1. Saved with
  `streams.assets.setThumbnail({ orgId, id, thumbnailTimestampPct })`, which
  persists `thumbnail_timestamp_pct` on the row.

`setThumbnail` returns a `requeued` flag:

| Response            | What happened                                                       |
| ------------------- | ------------------------------------------------------------------- |
| `{ requeued: true }`  | The asset was already `ready`, so the transcode was re-run to regenerate the still. The console shows *"Regenerating poster…"*. |
| `{ requeued: false }` | The position was recorded only. The console shows *"Poster position saved."* |

An asset renders a poster on its player only once `thumbnail_key` is set on the
row. Until then the player starts with a black frame.

## The playback endpoints

Every URL for a ready asset hangs off its `playback_id`. With
`base = https://streams.clutchcall.dev/api/streams/playback/<playback_id>`:

| Endpoint                | Protocol                    | Availability                  |
| ----------------------- | --------------------------- | ----------------------------- |
| `moq://relay.clutchcall.dev/playback/<playback_id>` | MoQ — the low-latency, first-class egress | Any ready asset |
| `<base>/catalog`        | MoQ catalog                 | Any ready asset               |
| `<base>/vod/master.m3u8` | HLS, adaptive              | Transcoded assets only        |
| `<base>/vod/manifest.mpd` | DASH, adaptive             | Transcoded assets only        |
| `<base>/source`         | Progressive MP4             | Any ready asset. Also the download target. |
| `<base>/poster`         | JPEG still                  | Once `thumbnail_key` is set   |
| `<base>/events`         | Play-telemetry ingest (POST) | Any ready asset              |

HLS and DASH ride the same CMAF segments that auto-transcode produces, which is
why they appear and disappear together.

There is also a **hosted player** at
`https://streams.clutchcall.dev/api/streams/embed/<playback_id>`. This is
the "copy a link and it plays anywhere" URL: it negotiates the transport ladder
(MoQ → HLS → MP4) for the viewer's device and network, so it works for
passthrough assets that do not have every protocol. Prefer it over hand-picking
a protocol unless you are integrating a specific player.

The console also offers the matching embed snippet:

```html
<iframe src="https://streams.clutchcall.dev/api/streams/embed/<playback_id>"
        width="640" height="360" frameborder="0"
        allow="autoplay; fullscreen; picture-in-picture" allowfullscreen></iframe>
```

Two identifiers are worth keeping straight. The **asset id** (`external_asset_id`,
falling back to the internal `id`) is what you pass to the `streams.assets.*`
procedures. The **playback id** is what appears in public URLs.

## Signed URLs and token lifetime

Each asset carries a `require_signed_urls` flag, toggled with:

```ts
await streams.assets.setSignedUrls({ orgId, id, require: true });
```

When the flag is on, the tokened surfaces are the HLS manifest, the DASH
manifest, and the progressive MP4. A token is minted per viewer session:

```ts
const { token } = await streams.assets.signPlaybackUrl({
  orgId, id, ttlSeconds: 3600,
});
```

The token is appended to the gated URLs as `?tok=<token>`. The console mints one
with a one-hour TTL when it opens the endpoints modal for a gated asset, and
uses it for both the inline player and the copy-link fields. If the mint fails,
the modal surfaces the error and offers a retry rather than waiting forever.

For a one-off share of the source file, `streams.assets.playbackUrl({ orgId, id })`
returns a complete signed MP4 URL. The console copies it to the clipboard and
notes that it expires in 10 minutes.

Never embed a long-lived control-plane API key in a page in order to fetch these
tokens from the browser — mint the token on your server and pass it down. See
[Authentication](/concepts/authentication).

## Play telemetry: startup, rebuffer and position samples

The in-console player is a real player and reports real watch data. It POSTs
batches of samples to `<base>/events` as `{ "rows": [ … ] }`, which land in the
`stream_asset_play_sample` table in ClickHouse and feed the analytics screens.

Each sample row:

| Field          | Meaning                                                                 |
| -------------- | ----------------------------------------------------------------------- |
| `ts`           | Sample time, ISO-8601.                                                  |
| `session_hash` | A fresh 48-bit id per playback session.                                  |
| `viewer_hash`  | A 48-bit id persisted in the browser's local storage, so repeat views from the same browser correlate. |
| `position_s`   | Current playhead, whole seconds.                                        |
| `startup_ms`   | Time from player attach to the first `playing` event. Set once, then reported on every sample. |
| `rebuffer_ms`  | Accumulated stall time — the sum of every `waiting` → `playing` gap.     |
| `bytes_egress` | Egress attributed to the sample.                                        |
| `pop_code`     | Serving point of presence.                                              |

Emission points:

- **Heartbeat** — one sample every 10 s, but only while the video is genuinely
  playing (not paused, not ended, playhead past zero).
- **Ended** — a final sample when playback reaches the end.
- **Teardown** — a last sample when the player unmounts, if anything happened at
  all (a startup time, accumulated rebuffer, or a non-zero playhead).

The ended and teardown sends use `navigator.sendBeacon` where available, falling
back to a `keepalive` fetch, so the final numbers survive a tab close. Every
send is best-effort: a failed POST is swallowed and never interrupts playback.

## Transport selection in the player

The inline player picks its transport in this order:

1. **Native HLS**, where the browser reports it can play
   `application/vnd.apple.mpegurl` — Safari takes this path.
2. **hls.js**, lazy-imported so it stays out of the initial bundle, loading the
   CMAF `vod/master.m3u8`.
3. **Progressive MP4** at `<base>/source`, used when hls.js is unsupported, when
   the import fails, or when hls.js raises a fatal error mid-playback — in which
   case the player tears down hls.js and re-points the element at the MP4.

A passthrough asset therefore plays through path 3, which is the whole point of
keeping the progressive source available for every asset.

## When playback is unavailable

| Symptom                                            | Cause                                                                 | What to do |
| -------------------------------------------------- | --------------------------------------------------------------------- | ---------- |
| The modal says there is no playback id             | The asset is still `uploading` or `processing`, or it ended `errored`. | Wait for `ready`, or re-upload an `errored` asset. |
| No HLS or DASH links, only MP4                     | The asset is passthrough — no probe dimensions and no CMAF ladder.    | Turn on **Auto-transcode (ABR)** in VOD defaults before uploading. |
| An upload never becomes an asset                   | The TUS2 transfer has not finished.                                   | Check `streams.tusUploads.inflight` for the row and its `progress_pct`; resume the upload. |
| Player and copy-links stay empty on a gated asset  | `signPlaybackUrl` failed, so there is no `?tok=` to append.           | Use the modal's retry, or re-check the caller's scope. |
| No poster on the player                            | `thumbnail_key` is not set on the row yet.                            | Set a poster position; for a `ready` asset the transcode is requeued to generate the still. |

## Related

- [Telemetry](/platform/telemetry) — the other operational data streams
- [Authentication](/concepts/authentication) — API keys, relay tokens, signed playback
- [Modalities overview](/modalities/overview)
