<!-- status: CURRENT_REFERENCE | updated: 2026-07-29 -->

# Clip player — standalone reference folder

**What this is.** A copyable folder that plays a published Colloquy clip on any
page, with the Clip Editor's transport deck and a scroll-driven variant. Copy
the four files onto a site, open `index.html`, done — no build step, no import
map, no CDN, no `/api`.

```
player-sample/
  index.html                deck version (the reference page)
  scroller.html             scroll-driven explainer version
  player.css                styling for both pages (yours to replace)
  colloquy-clip-player.js   the module: player + deck + scroll binding (three.js inlined)
  favicon.svg               tab icon
  colloquy-icon.svg         header mark
  llms.txt                  the same API, written for an AI agent
  README.md
```

Served here at **`/player-sample/`** — keep the trailing slash: the pages
reference their bundle relatively so the folder stays portable, which means the
slash-less form would resolve `./colloquy-clip-player.js` against the site root.
A redirect in `vercel.json` (and in both dev servers) sends `/player-sample` to
`/player-sample/` so either form works. Don't add a `comment` key beside that
redirect — Vercel's schema rejects unknown properties on a redirect rule.

## Putting one on your own site

You do not need this repository, this folder, or a build step. The module is
served with `Access-Control-Allow-Origin: *`, so any page can import it:

1. Add a container element with a real size (it has no intrinsic height):
   `<div id="stage" style="aspect-ratio:16/9"></div>`.
2. `import` the module from
   `https://colloquyscenestudio.aroughidea.com/player-sample/colloquy-clip-player.js`.
3. `await createClipPlayer({ container, clipUrl })` — see "Embedding it" below
   for the eight lines in full.
4. Optional: `mountClipPlayerDeck` for the transport deck,
   `bindScrollScrubber` for a scroll-driven page, model files for the photoreal
   bodies.

**Handing it to an AI agent instead?** Point it at
[`/player-sample/llms.txt`](https://colloquyscenestudio.aroughidea.com/player-sample/llms.txt),
which carries the same API written for one, plus a step-by-step and a
ready-to-paste prompt. Copying the folder is still supported — it is just no
longer necessary.

## What a clip carries, and what it does not

| | In the clip? | Where it comes from |
|---|---|---|
| Scene graph | **Yes** — line 2, the opening `snapshot` | nothing to fetch; a clip renders years later even after the published channel moves on |
| Per-tick state (poses, drives, engagement, beam) | **Yes** — keyframes + deltas | the frames you are playing |
| Models / materials / textures | **No** — referenced by logical path | you supply them (below) |
| View, environment, exhibit look | **No**, deliberately | the player picks; a frame is look-independent |

So: **no scene graph needed, but models are yours to bring.** A clip of the
flagship scene names 25 OBJs (~13 MB) plus their MTLs and texture maps.

**No meshes is a valid state.** The instrument layers — mobile diagrams, drive
gauges, orientation arrows, beam ring — are projections of the state frame, not
of the geometry. The clip plays and reads with zero assets loaded; meshes are
the photoreal upgrade, not the price of entry.

## Supplying models

Four ways, in the order the resolver tries them:

1. **Drop files on the stage** — a clip lands as a clip, everything else as
   assets. A **`.zip`** is unpacked first, so the archive the Hosted File
   Catalog gives you for a model group can be dropped straight on.
2. **Load Models… / Load Model Folder…** — a multi-select or a whole directory.
   (**Load Scene Graph…** and **Load View…** take a scene-graph JSON and a
   published `view/*` camera the same way.)
3. **A manifest** — `{ "models/female-shell-body-260316.obj": "https://…" }`,
   passed as `assets`, or fetched from `assetManifestUrl` / `?assets=<url>`.
4. **A base URL** — `assetBaseUrl: "https://cdn.example/colloquy"` serves
   unresolved paths from `<base>/<logical path>`.

Matching is by exact logical path first, then by **basename** — a plain folder
of OBJs off someone's disk resolves against `models/…` paths without anyone
rebuilding a directory tree. A texture an MTL names but your folder lacks 404s
in the console and the material renders untextured; nothing else breaks.

## Embedding it

```html
<div id="stage" style="aspect-ratio:16/9"></div>
<div id="deck"></div>
<script type="module">
  import { createClipPlayer, mountClipPlayerDeck } from "./colloquy-clip-player.js";

  const player = await createClipPlayer({
    container: document.getElementById("stage"),
    clipUrl: "https://…/display-capture-….colloquy-rec.ndjson.gz",
    autoplay: true,
    loop: true,
    onState: (status) => deck.update(status),
  });

  const deck = mountClipPlayerDeck({ player, container: document.getElementById("deck") });
</script>
```

Skip `mountClipPlayerDeck` entirely if you want your own chrome — the handle is
the whole API.

### Scrollytelling

```html
<section class="step" data-clip-from="4842881" data-clip-to="4843200">…</section>
<section class="step" data-clip-from="4843200" data-clip-to="4844094">…</section>
<script type="module">
  import { bindScrollScrubber } from "./colloquy-clip-player.js";
  bindScrollScrubber({
    player,
    sections: [...document.querySelectorAll(".step")],
    onSectionChange: (index, el) => { /* highlight */ },
  });
</script>
```

Better still, build the sections from the clip: `player.getEvents()` returns the
state changes detected in the recorded frames — engagement transitions, the beam
appearing, words exchanged — each with its tick. Give every event a section whose
span *ends* on it and the reader scrolls to the moment the prose describes.
`player.getUnitLabel("female_1")` returns `"Female A"`, the label the viewer
draws on that mobile, so the copy names the ring on screen. `scroller.html`
does this, falling back to even spans for a clip with nothing detectable.

Each section's scroll progress maps onto its tick span; scrolling back plays the
recorded states backwards. Playback pauses while scroll drives (scroll is the
clock). With no `sections`, whole-page scroll maps onto the whole clip.
`scroller.html` authors the spans as **fractions** and converts them to the
loaded clip's real ticks at boot, so the page survives `?clip=<other clip>`.

This works because every emitted tick is a self-contained frame materialized
from the nearest previous keyframe — seeking anywhere is an ordinary operation,
not a special mode.

## The deck

`mountClipPlayerDeck({player, container})` renders the transport — Restart,
single-tick step, play/pause, reverse, scrub, speed, loop, trim, marker chips —
plus layer switches in three registers: what the piece **is** (Models, Exhibit
Look), what it is **doing** (Instruments — mobile diagrams, drive gauges,
orientation arrows, beam ring), and what it is **made of** (Scene graph,
Sensors & joints, Grid — the authored structure the clip carries). **Commentary**
sits alongside them: the narrator's own lines in English, drawn down the right of
the view from the events the recording carries. Loading a
clip, models, a scene graph or a camera sits behind a closed disclosure, because
watching is the common case.

Wire it with `onState: (s) => deck.update(s)`; the deck holds no playback state
of its own. It ships no CSS — link `player.css` or style the `.clip-deck-*`
classes to match your page.

Both sample pages also inline a default camera. A clip pins none, and the
viewer's bounding-box auto-fit is dominated by the wide diagram plane, which
leaves the piece small — so they pass a chosen three-quarter `viewConfig`.

## API

`createClipPlayer(options) → Promise<ClipPlayerHandle>`

| Option | Meaning |
|---|---|
| `container` | element the canvas mounts into (size it with CSS) |
| `clipUrl` | clip to fetch at boot |
| `assets` / `assetBaseUrl` / `assetManifestUrl` | mesh sources (above) |
| `autoplay`, `loop`, `speed` | initial transport posture |
| `viewConfig` | a published `view/*` config — the camera a clip does not carry (without one the viewer auto-fits) |
| `renderOptions` | viewer layer overrides |
| `onState`, `onAssets`, `onDiagnostic`, `onStatus` | callbacks |

Handle: `getEvents` · `getUnitLabel` · `setCommentaryVisible` · `getCommentaryLines` · `loadClipFromUrl` · `loadClipFromFile` · `loadClipFromText` ·
`addAssetFiles` · `addAssetUrls` · `loadAssetManifest` · `setSceneGraph` ·
`applyViewConfig` ·
`play` · `pause` · `togglePlay` · `stop` · `seekTick` · `seekFraction` ·
`stepTicks` · `setSpeed` · `setDirection` · `setLoop` · `setRange` ·
`clearRange` · `setRenderOptions` · `getStatus` · `getRecording` · `viewer` ·
`assets` · `destroy`.

`viewer` is the shared Scene Graph viewer, so camera work, layer toggles and
Exhibit Look are all available from the host page.

Page URL params (both pages): `?clip=`, `?assets=`, `?assetBase=`, `?view=`,
`?autoplay=0`, `?loop=0`.

## Regenerating the bundle

The bundle is checked in so the folder is drop-in. Sources live in
`src/player-sample/` — edit those, never the bundle:

```
npm run build:player-sample      # → player-sample/colloquy-clip-player.js
node scripts/build-player-sample.mjs --watch
```

three.js is inlined (no import map, no runtime CDN — the 2026-07-05 esm.sh
outage is why nothing here imports from a network at runtime).

## Related

- `docs/infrastructure/SIMULATION_RECORDING_PLAYBACK.md` — the clip format, the
  transport controls, the Clip Editor, and the replay server.
- `docs/infrastructure/DISPLAY_CLIENT_ARCHITECTURE.md` — the one-stream /
  projections contract this player is a consumer of.
- `/clip-editor/` — record, trim, mark up and publish the clips this plays.
- `/display/?recording=<url>` — the full display client playing the same clip.
