# hyperframes-studio — Agent API

Signed in as: **not signed in — send your token to get personalized docs**

A per-user, token-protected HTTP API that lets any agent (or any HTTP client) create
HyperFrames video projects, write HTML and asset files into them, lint them, take PNG
snapshots, open the HyperFrames studio preview, render to MP4, and download the result.

Base URL: `https://animations.usectl.com`

## Authentication

1. A human signs in at `https://animations.usectl.com/` with Google or X. The dashboard shows their personal token
   and this exact command:

   ```bash
   curl -H "Authorization: Bearer <your-token>" https://animations.usectl.com/api/docs
   ```

2. Every request except `GET /api/health` and `GET /api/docs` needs that token, as one of:

   ```
   Authorization: Bearer <your-token>      # preferred
   X-Studio-Token: <your-token>
   ?token=<your-token>                     # query param, for plain links / EventSource
   ```

Missing or wrong token → `401 {"error":"unauthorized: ..."}`. The token identifies the user:
you only ever see and touch that user's projects and renders. Rotate it from the dashboard
or with `POST /api/me/token/rotate`.

Set these once and copy the rest of the examples verbatim:

```bash
export HFS=https://animations.usectl.com
export HFS_TOKEN=<your-token>
export A="Authorization: Bearer $HFS_TOKEN"
curl -s -H "$A" $HFS/api/me          # who am I, plus this token and the docs curl
```

## Conventions

- Project slugs: `^[a-z0-9][a-z0-9-]*$`. Names are slugified on create (`"My Promo"` → `my-promo`).
- All responses are JSON unless the endpoint serves a file. Errors are `{ "error": "..." }`
  with a 4xx/5xx status.
- File paths are project-relative (`index.html`, `compositions/scene1.html`, `assets/music.mp3`).
  `..` is rejected. Hidden cache dirs (`.thumbnails`, `.waveform-cache`) are excluded from listings.
- CORS is open (`*`) so browser-based tools can call the API directly with the token.

## Typical agent workflow

```bash
# 1. create a project (a starter index.html is written if you don't pass html)
curl -s -X POST $HFS/api/projects -H "$A" -H "Content-Type: application/json" \
  -d '{"name":"launch-teaser"}'

# 2. write the composition
curl -s -X PUT $HFS/api/projects/launch-teaser/files/index.html -H "$A" --data-binary @index.html

# 3. add assets (binary is fine) and sub-compositions
curl -s -X PUT $HFS/api/projects/launch-teaser/files/assets/music.mp3 -H "$A" --data-binary @music.mp3
curl -s -X PUT $HFS/api/projects/launch-teaser/files/compositions/intro.html -H "$A" --data-binary @intro.html

# 4. lint, then look at frames
curl -s -X POST $HFS/api/projects/launch-teaser/lint -H "$A"
curl -s -X POST $HFS/api/projects/launch-teaser/snapshot -H "$A" \
  -H "Content-Type: application/json" -d '{"at":[0.5,2,5]}'
#    → snapshots[].url — GET each PNG with the token to inspect it

# 5. render and wait for the file, then download it
curl -s -X POST "$HFS/api/projects/launch-teaser/render?wait=1" -H "$A" \
  -H "Content-Type: application/json" -d '{"quality":"high"}'
#    → { "status": "done", "downloadUrl": "/renders/launch-teaser_2026-09-07_18-40-01.mp4", ... }
curl -s -L -o out.mp4 -H "$A" "$HFS/renders/launch-teaser_2026-09-07_18-40-01.mp4"

# 6. change something and render again — every render is a new file
curl -s -X PUT $HFS/api/projects/launch-teaser/files/index.html -H "$A" --data-binary @index.html
curl -s -X POST "$HFS/api/projects/launch-teaser/render?wait=1" -H "$A" -d '{}'
```

## Endpoints

### Health, docs, identity

| Method | Path | Auth | Returns |
| --- | --- | --- | --- |
| GET | `/api/health` | optional | `{ ok, version, baseUrl, login: {google, x, dev}, authorized, user }` |
| GET | `/api/docs` | optional | this document; with a valid token the examples contain your real token and base URL |
| GET | `/api/me` | required | `{ user, token, baseUrl, curl, docsUrl }` |
| POST | `/api/me/token/rotate` | required | `{ rotated, token, curl }` — old token stops working immediately |

### Projects

| Method | Path | Body | Returns |
| --- | --- | --- | --- |
| GET | `/api/projects` | — | `{ projects: [summary] }` — only yours |
| POST | `/api/projects` | `{ name, html?, duration?, files?, createdBy? }` | `201 { created, slug, project, files }` |
| GET | `/api/projects/:slug` | — | `{ project, meta, files }` |
| DELETE | `/api/projects/:slug` | — | `{ deleted, slug }` — removes the whole folder, stops its preview/render |

`POST /api/projects` notes:
- `html` is the full `index.html`. Omit it to get a valid starter composition (`duration` seconds, default 6).
- `files` is an optional object of extra text files to write at once, keyed by path:
  `{ "compositions/intro.html": "<!doctype html>...", "STORYBOARD.md": "..." }`.
- The scaffold always includes `meta.json`, `hyperframes.json`, and an empty `assets/` dir.
- `409` if the slug already exists.

Project summary shape:

```json
{
  "name": "launch-teaser",
  "displayName": "launch-teaser",
  "compositionId": "launch-teaser",
  "duration": 6,
  "createdAt": "2026-09-07T18:39:00.000Z",
  "preview": { "running": false },
  "render": null
}
```

### Files

| Method | Path | Body | Returns |
| --- | --- | --- | --- |
| GET | `/api/projects/:slug/files` | — | `{ slug, files: [{ path, size, mtime }] }` (recursive) |
| GET | `/api/projects/:slug/files/<path>` | — | the raw file with its content type; add `?download` for an attachment. Supports `Range`. |
| PUT | `/api/projects/:slug/files/<path>` | raw bytes | `{ written, slug, path, bytes, url }` — creates parent dirs, overwrites |
| DELETE | `/api/projects/:slug/files/<path>` | — | `{ deleted, slug, path }` (also deletes directories; `index.html` is protected) |

`PUT` writes the request body byte-for-byte, so text and binary both work with
`--data-binary @file`. If your client can only send text, append `?encoding=base64`
and send the base64 string as the body. `POST` is accepted as an alias of `PUT`.

### Validation

| Method | Path | Body | Returns |
| --- | --- | --- | --- |
| POST | `/api/projects/:slug/lint` | `{ verbose? }` | `{ ok, exitCode, result }` — `result` is the CLI's JSON findings (`hyperframes lint --json`) |
| POST | `/api/projects/:slug/check` | `{ at?: [sec], samples?, strict? }` | `{ ok, exitCode, result }` — lint + headless runtime + layout inspection, slower |

Fix every lint error before rendering. Warnings are usually fine.

### Snapshots (look at the video without a browser)

| Method | Path | Body | Returns |
| --- | --- | --- | --- |
| POST | `/api/projects/:slug/snapshot` | `{ at?: [sec], frames?, end? }` | `{ ok, snapshots: [{ path, size, url }], log }` |

Renders PNG frames (plus a contact sheet) into `<project>/snapshots/`, cleared on every call.
Pass `at` for exact timestamps or `frames` for N evenly spaced ones (default 5). `end: false`
skips the extra end-of-timeline frame. Each `url` is a normal files endpoint: GET it with the
token to view or download the image.

### Preview (HyperFrames studio)

| Method | Path | Returns |
| --- | --- | --- |
| POST | `/api/projects/:slug/preview` | `{ running, port, url }` — starts `hyperframes preview` for the project |
| GET | `/api/projects/:slug/preview` | current state |
| DELETE | `/api/projects/:slug/preview` | `{ stopped }` |

The studio is a separate server on its own port (3002+) on the studio host. The `url` uses the
host you called; it is reachable only if that port is exposed the same way the API is. For
agents, `snapshot` is usually the better "preview". Humans use the dashboard's preview button.

### Render

| Method | Path | Body | Returns |
| --- | --- | --- | --- |
| POST | `/api/projects/:slug/render` | `{ quality?, fps?, format?, composition?, resolution?, strict?, wait? }` | render state (see below) |
| GET | `/api/projects/:slug/render` | — | current/last render state incl. `lines` (log), or `{ status: "none", recent }` |
| GET | `/api/projects/:slug/render/events` | — | Server-Sent Events stream: `snapshot`, `start`, `line`, `end` |
| DELETE | `/api/projects/:slug/render` | — | `{ stopped }` cancels a running render |

Options: `quality` = `draft | standard | high` (default standard); `fps` = integer or rational
like `30000/1001`; `format` = `mp4 | webm | mov | gif` (default mp4); `composition` = render a
specific file instead of `index.html`; `resolution` = `landscape | portrait | square |
landscape-4k | portrait-4k`; `strict` fails on lint errors.

`wait: true` (or `?wait=1`) holds the request open until the render finishes and returns the
full log in `lines`. A minute of video can take several minutes; keep the client timeout
generous. Without `wait`, poll `GET .../render` until `status` is `done`, `failed`, or
`cancelled`. Re-running a render after it finished simply POSTs again; each run writes a new
timestamped file and the previous ones stay in `/api/renders`.

Render state:

```json
{
  "slug": "launch-teaser",
  "status": "done",
  "startedAt": 1757270400000,
  "doneAt": 1757270512000,
  "filename": "launch-teaser_2026-09-07_18-40-01.mp4",
  "downloadUrl": "/renders/launch-teaser_2026-09-07_18-40-01.mp4",
  "lastLine": "✓ rendered",
  "eventsUrl": "/api/projects/launch-teaser/render/events"
}
```

One render per project at a time; a second POST while one is running returns the running one.

### Downloads

| Method | Path | Returns |
| --- | --- | --- |
| GET | `/api/renders` | `{ renders: [{ filename, project, size, mtime, url }] }` newest first, only yours |
| GET | `/renders/<filename>` | the video file (supports `Range`; add `?download` for an attachment) |
| DELETE | `/renders/<filename>` | `{ deleted }` |

## Composition rules (what the HTML must look like)

HyperFrames renders HTML by seeking a paused GSAP timeline frame by frame. The essentials:

1. Canvas is 1920×1080: `html, body { width: 1920px; height: 1080px; overflow: hidden; }`.
2. Root element carries `data-composition-id="<slug>" data-start="0" data-duration="<sec>" data-width="1920" data-height="1080"`.
3. Load GSAP from `https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js`.
4. Timelines are `gsap.timeline({ paused: true })` and registered on `window.__timelines["<composition-id>"]`.
5. Deterministic only: no `Math.random`, `Date.now`, `setTimeout`, `setInterval`, `fetch`.
6. Scope CSS under `[data-composition-id="<slug>"]`.
7. Google Fonts via `<link>` with an explicit `display=` value.
8. Audio: `<audio src="assets/x.mp3" data-start="0" data-duration="<sec>" data-track-index="2">`; videos must be `muted`.
9. Sub-compositions: `<div data-composition-id="scene1" data-composition-src="compositions/scene1.html" data-start=".." data-duration=".." data-track-index="1" data-width="1920" data-height="1080"></div>`.

Run `lint` after every change; run `snapshot` to see what you made; `render` when it's clean.

## Watching the result in a browser

`GET /play/<slug>` (signed-in dashboard users) plays the composition in the browser: it loads
`index.html` and every `data-composition-src` sub-composition, then drives all registered
timelines and audio from one clock, with play/pause, scrub and loop. Point a human there;
agents use `snapshot`.

## Public stats

`GET /api/stats` (no auth) returns the counters shown on the landing page: `pageViews`,
`uniqueVisitors`, `returningVisitors` (came back after 30+ minutes), `animationsCreated`
(all time), `animationsNow`, `rendersCompleted`, `users`. Visitors are counted with an anonymous
first-party cookie; no IP addresses or user agents are stored.
