# Studiowyre web API

Base: `https://studio.postwyre.com/api`. Served also at `/API.md`.

Every call needs a sign-in: a Cloudflare Access sign-in (the browser carries it; scripts send the `Cf-Access-Jwt-Assertion` header Access issues) whose email is on the staff list, or, once it is turned on, a Sign in with Postwyre session cookie from `/auth/postwyre` (README, "Sign in with Postwyre"). Calls other than `GET` must send an `Origin` header equal to the site's own origin. Errors are JSON: `{"error": "<plain sentence>"}`.

Every route is one row of `ROUTES` in `src/worker.js` (the `/auth/` ones in `AUTH_ROUTES`, `src/postwyre-session.js`), and every app action has an MCP tool behind the same role check ([MCP.md](MCP.md); `agent-parity.json` maps each route to its tool).

`?workspace=<id>` picks the workspace on every `/videos` call. It can be left out when the person has only one. A video in a workspace the person cannot use answers `404`, the same as one that does not exist.

| Call | Does | Answers |
|---|---|---|
| `GET /me` | Who is signed in | `{email, signIn, workspaces: [{id, role}]}`; role is `owner`, `admin` or `member`; `signIn` is `access` or `postwyre`. For a Postwyre sign-in, `email` is the email Postwyre verified (or `postwyre:<id>` when it has none) and the workspaces are the Postwyre brands whose plan includes Studiowyre, with the person's role there |
| `GET /prices` | The Postwyre credits each render type costs, and whether this server charges them (`billing`: `on`, `off` or `misconfigured`). Any role | `{billing, creditUsd, saleMultiple, types: {<type>: {label, credits: {<unit>: n}}}, doc}` |
| `GET /usage` | This month's minutes, the limits and the clean-up record | `{month, motionRenders, rendersToday, rendersLastHour, refundsOwed, minutesUsed, minutesLimit, resetsOn, maxUploadBytes, retentionDays, videos: [{id, name, minutes, measured, at}], deletions: [{id, name, reason, deletedAt, createdAt}]}` |
| `GET /videos` | Videos in the workspace, newest first | `{videos: [{id, name, status, progress, createdAt, createdBy, sourceDeletedAt}]}` |
| `POST /videos` | Starts an upload. Body `{name, size, type, onCamera, seconds, idempotencyKey}`; `seconds` is the video's length as the browser reads it (optional, see Limits); `type` is `video/mp4`, `video/quicktime`, `video/webm` or `video/x-m4v`; `onCamera` is `staff-only`, `patients`, `clients` or `other`, and only `staff-only` is accepted; `idempotencyKey` (optional, 8 to 128 letters, digits, dots, colons, dashes or underscores) makes a repeat return the same video | `201 {video}` with `partSize` and `partCount`, or `200 {video, reused: true}` for a repeated `idempotencyKey` |
| `PUT /videos/{id}/parts/{n}` | Sends part `n` (1-based) as the raw request body. Every part is `partSize` bytes except the last | `{part: n}` |
| `GET /videos/{id}` | One video. While uploading, `uploadedParts` lists the parts already in, so an upload can resume | `{video}` |
| `POST /videos/{id}/complete` | Finishes the upload and starts transcription. Calling it again changes nothing | `{video}` with `status: "processing"` |
| `GET /videos/{id}/moments` | Suggested moments once `status` is `ready`. `410` once the clean-up deleted them with the transcript (`transcriptDeletedAt` on the video) | `{ranked, model, words, moments: [{id, start, end, seconds, hook, text, fillers, longPauses, longestPause, rank, why}]}` |
| `GET /videos/{id}/source` | The original video. Honours `Range`, so a player can seek. `410` once the 30-day clean-up deleted it | video bytes, `206` for a range |
| `POST /videos/{id}/clips` | Makes a captioned 9:16 clip of each moment. Body `{moments: ["m0312", ...]}`, 1 to 5 ids from this video's moments. Any role on the account | `202 {clips}` |
| `POST /videos/{id}/oneshot` | Makes one-shot drafts with review rounds. Body `{mode, count, rounds, brand, idempotencyKey}`, see [One-shot drafts](#one-shot-drafts). Any role on the account | `202 {request, mode, count, rounds, brand, reused: false, clips}`, or `200` with `reused: true` for a repeat |
| `GET /videos/{id}/clips` | The clips and one-shot drafts asked for, oldest request first (a request's drafts best first), and the video's audit record | `{clips: [{id, start, end, hook, status, progress, pct, error, duration, size, requestedAt, requestedBy, expiresAt, expiredAt}], events: [{at, by, action, detail}]}`; a one-shot draft also has the fields below |
| `GET /videos/{id}/clips/{clipId}/file` | A ready clip's or draft's mp4. Honours `Range`. Add `download=1` to get it as a download. `410` with `{error, expired: true, expiredAt}` once the clip expired; `error` says how to make it again | video bytes, `206` for a range |
| `DELETE /videos/{id}` | Deletes the video, its transcript, moments and clips, and records who did it on the usage page. Owner or admin only | `{deleted: id}` |

`status` moves `uploading` → `processing` → `ready`, or `failed` with `error` set. `progress` is a short sentence for the screen ("Transcribing, part 2 of 6").

Moments come from `findMoments` (`scripts/lib/find-moments.mjs`, the same code as `npm run moments`), up to 15, 15 to 60 seconds each, never overlapping. When `ranked` is `true` a language model put them in order and wrote `why`; it cannot add, drop or move a moment. When `ranked` is `false` they are in time order and `why` is `null`.

## Motion graphics

Motion-graphics videos made from scenes, a script, a social post or a four-layer story, in 9:16, 16:9 or both. The same calls are MCP tools for agents: see [MCP.md](MCP.md), which also describes the spec, the references and the revision notes.

| Call | Does | Answers |
|---|---|---|
| `GET /motion` | Jobs in the workspace, newest first | `{jobs: [{id, title, status, latestVersion, createdAt, createdBy}]}` |
| `POST /motion` | Starts a job. Body: one of `scenes`, `script`, `post` or `story` (see [MCP.md](MCP.md), Stories), plus `formats`, `references`, `style`, `music`, `title`, `captions`, `sting` (`null` ends a `script`, `post` or `story` job on its last card instead of the Postwyre logo), `idempotencyKey`. Any role | `201 {job}`, or `200 {job}` with `reused: true` for a repeated `idempotencyKey` |
| `GET /motion/{id}` | One job: versions with their specs, notes and changes, renders with status and file path, and who did what | `{job}` |
| `POST /motion/{id}/revisions` | A plain-English note on the latest version. Body `{note, baseVersion}`. `422` when the note asks for something it does not understand | `201 {job}` with `version` and `changes`, or `200` with `reused: true` for a repeat |
| `GET /motion/{id}/renders/{version}/{9x16 or 16x9}/file` | A finished MP4. Honours `Range`; `download=1` saves it. `410` with `{error, expired: true, expiredAt}` once the render expired; `error` says what to do next | video bytes |
| `DELETE /motion/{id}` | Deletes the job and its renders, and records who did it on the usage page. Owner or admin only | `{deleted: id}` |

A render's `status` moves `queued` → `rendering` → `ready` (or `failed`), and a `ready` render becomes `expired` once the clean-up deletes its MP4, the account's `renderDays` after it was made (14 by default). `expiresAt` on a ready render is when that happens and `expiredAt` on an expired one is when it did; an expired render has `file: null`. The job, its versions and their specs stay, so a revision renders a new version. A job's `status` in the list is `expired` when none of its renders is left to download.

Each format of each version is one render, counted against the workspace's month before it is queued (100, or `motionRendersMonthly` in `WORKSPACE_LIMITS`; `403` past it). `POST /motion` and revisions share a limit of 10 a minute per person (`429`).

## Clips

A clip's id is its moment's id. `status` moves `queued` → `rendering` → `ready`, or `failed` with `error` set, and a `ready` clip becomes `expired` once the clean-up deletes its file. `expiresAt` on a ready clip is when that happens (the account's `renderDays` after it was made, 14 by default; the daily clean-up after that time deletes it), and `expiredAt` on an expired one is when it did; `progress` says what is happening ("Waiting, 1 clip ahead", "Cutting pauses and filler words", "Adding captions", "Rendering the video", "Leveling the sound") and `pct` is a rough share of the work done, 0 to 100. Poll `GET .../clips` every few seconds while any clip is `queued` or `rendering`.

Clips render one at a time per video, in the order they were asked for. Each is the local Day 4 talking-head edit (`scripts/lib/day4-compose.mjs`, rendered with HyperFrames) with the Day 4 defaults: pauses over 0.5 seconds and filler words cut, `punchy` zooms, `bold-shortform` captions, the default brand kit, no title, no B-roll, no music, mastered to -14 LUFS. The moment's times and words come from this video's own moments file and transcript, never from the request. A failed render is tried twice more; `POST` the same id again to retry one that failed, or to render an expired one again. Rendering again is a new render: it counts against the hour and the day like the first, and with Postwyre credits on it is charged again. It works while the original is kept (30 days); after that the call is `409` and the video has to be uploaded again.

## One-shot drafts

`POST /videos/{id}/oneshot` edits a video with no picking: the video must be `ready`. The body:

| Field | Values | Default |
|---|---|---|
| `mode` | `clips`: rank the transcript and make a draft of each of the best stretches. `talking-head`: one draft of the whole video | required |
| `count` | `clips` only, 5 to 10. Fewer come back when the video does not hold that many. Sending it with `talking-head` is a `400` | 5 |
| `rounds` | 1 to 3 review rounds per draft. Each round is one render | 3 |
| `brand` | A brand kit: `default`, `mastro` or `postwyre` | `default` |
| `idempotencyKey` | 8 to 128 letters, digits, dots, colons, dashes or underscores | none |

In `clips` mode the words of this video's own transcript are scored by `rankClips` (`scripts/lib/clip-score.mjs`, the same code as `npm run clip`), on the words only: every run of whole sentences of 15 to 59 seconds is scored out of 100 on its hook (the first 2 seconds), whether it stands on its own and ends on a finished sentence, how steadily it is spoken, and its length. The best ones that do not overlap become drafts, each titled with its own first sentence (up to 7 words). A video with no such stretch answers `409`. In `talking-head` mode the whole video is one draft with no title card unless the critic adds one for a weak opening; a video longer than 120 seconds answers `400`.

Each draft is rendered in the video's container: the moment is copied once, the speaker's face is tracked (OpenCV's YuNet detector; without it the crop stays centred), retakes are cut (the last take is kept), pauses over 0.5 seconds and filler words are cut, the 9:16 crop follows the face, the Day 4 composition is built with punch-ins on stressed words, `punchy` zooms, `bold-shortform` captions, the title card and the chosen brand kit, and the HyperFrames render is mastered to -14 LUFS. Then the critic (`scripts/lib/critic.mjs`) measures the render against `config/rubric.json`, changes the spec for each failed check it knows how to fix, and renders again, until the draft passes, nothing is left to fix, or `rounds` renders are done. One try never renders more than `rounds` times. The last round's render is the draft. A draft that stops on an error (not a failed check) is marked `failed` and is not tried again on its own, because a new try would run its rounds again and only one set was counted. Ask again with a new `idempotencyKey` (or, without a key, the same settings once every draft of the request has failed); that counts a new set of rounds.

The rubric's checks:

| Check | Severity | Passes when |
|---|---|---|
| `hook` | fail | Someone speaks within 0.5 seconds, and the first 2 seconds score at least 0.35 as a hook, or a title card is on screen at the start |
| `deadAir` | fail | No stretch over 0.8 seconds below -40 dBFS, and under 8% of the video silent |
| `captions` | fail | Caption text at least 18 px tall when the 1080-wide video is shown 360 px wide, with a contrast of 4.5:1 or better against its box |
| `framing` | fail | In no more than 5% of the sampled frames with a face is the face touching the frame's edge or missing the top of the head |
| `audio` | fail | -14 LUFS plus or minus 1, true peak at or under -1 dBTP, no clipped samples |
| `pacing` | warn | Something changes on screen at least every 7 seconds |
| `brand` | fail | The composition loads the kit asked for and the captions and title take their colours from its tokens |

What the critic can change for the next round: start 0.1 seconds before the first word or add a title card (`hook`), cut shorter pauses or keep less around each cut (`deadAir`), a bigger caption preset (`captions`), follow the face more closely (`framing`), lower the voice before the master (`audio`, for clipping or true peak) and the asked-for kit (`brand`). `pacing` is a warning: it never causes a render on its own, and the one-shot edit already starts with punch-ins on stressed words.

A one-shot draft in `GET .../clips` has these fields as well:

| Field | What it is |
|---|---|
| `oneshot` | `true` |
| `mode` | `clips` or `talking-head` |
| `rank`, `score`, `reason` | Its place in the ranking (1 is best), its score out of 100 and a plain sentence on why. `null` in `talking-head` mode |
| `title` | The title card's text, or `null` |
| `brand`, `maxRounds` | The kit and the rounds asked for |
| `rounds` | Once it is ready, one entry per round rendered: `{round, passed, failed, notRun, changes, checks}`. `failed` lists the checks that failed (warnings included), `notRun` the checks that could not be measured, `changes` what the critic changed for the next round, and `checks` every check with its `severity`, `status` (`pass`, `fail` or `not run`) and `why` |
| `passed` | `true` when the last round had no fail-severity check that failed or went unmeasured. `null` until it is ready. A draft that did not pass is still delivered, with `passed: false` |

While a draft renders, `progress` also says "Finding the speaker" (once, before the first round) and "Checking the draft against the rubric" (after each round's render).

Repeating a call: the same `idempotencyKey` on the same video returns the same drafts, whatever their status, and queues and counts nothing. The same key with other settings is a `409`. Without a key, the same `mode`, `count`, `rounds` and `brand` on the same video returns the same drafts while any of them is `queued`, `rendering` or `ready`; when every one of them failed or expired, the same call queues them again and counts their rounds again.

Counting against the month: every round is one render against the account's monthly renders, the same count motion graphics use (100, or `motionRendersMonthly` in `WORKSPACE_LIMITS`; the usage page's `motionRenders` shows both). Before anything is queued, the call counts `rounds` renders for every draft in one step; past the cap it answers `403` and nothing is queued or counted. The count is then corrected:

- A draft that is ready gives back the rounds it did not render (passed in one round of three: two go back).
- A draft that fails keeps its rounds counted, since some of them may have rendered.
- Deleting the video gives back the rounds of drafts that never started rendering; a draft that started keeps its count.
- Asking again after every draft of a request failed counts a new set of rounds.

Who can do what (the same on the MCP tools, see [MCP.md](MCP.md)):

| Action | Owner | Admin | Member | Service token | Someone without this account |
|---|---|---|---|---|---|
| `POST /videos/{id}/oneshot` (`clip_video`, `edit_talking_head`) | Yes | Yes | Yes | Yes, in its workspaces | `404` |
| `GET /videos/{id}/clips` (`get_video_drafts`) | Yes | Yes | Yes | Yes, in its workspaces | `404` |
| `GET /videos/{id}/clips/{clipId}/file` | Yes | Yes | Yes | Yes, in its workspaces | `404` |
| `DELETE /videos/{id}` (takes its drafts too) | Yes | Yes | `403` | `403` (a token is always a member) | `404` |

A service token not listed in `SERVICE_TOKENS` is refused with `403` on every call.

## Limits

| What | Limit |
|---|---|
| `POST /videos` | 10 a minute per person (`429` past it) |
| `PUT .../parts/{n}` | 600 a minute per person |
| `POST .../clips` | 10 a minute per person; 5 moments a call; 15 clips a video |
| `POST .../oneshot` | Shares the 10 a minute per person of `POST .../clips` (`429`). 20 one-shot drafts a video (`409`), on top of the 15 moment clips. `talking-head`: a video of up to 120 seconds |
| Edit jobs at once | Per account: `editJobsConcurrent` in `WORKSPACE_LIMITS`, else 3. A one-shot request (this route, the MCP tools `clip_video`, `edit_talking_head`, `create_edit_job` and `make_day`) holds one slot while any of its drafts is waiting or rendering; a new request past the cap answers `429` and nothing is counted or charged. A repeat of a request already made is not refused. The slot comes back when its last draft is ready or failed or its video is deleted, and expires on its own after its worst-case render time plus an hour |
| Renders a month | Per account: `motionRendersMonthly` in `WORKSPACE_LIMITS`, else 100. Each format of each motion version is one, and so is each review round of a one-shot draft (`403` past it) |
| Renders an hour and a day | Per account: `rendersPerHour` (20 in any 60 minutes) and `rendersDaily` (60 a UTC day) in `WORKSPACE_LIMITS`. Every moment clip, one-shot review round and motion format counts. Past either: `429` with a `Retry-After` header and `retryAfter` (seconds) in the body; nothing is queued or charged. A render already counted (a repeated request) is not counted again |
| Render time | The container stops a moment clip's render after 30 minutes, and a one-shot draft's after 30 minutes per round |
| Video length | Per account: `maxVideoSeconds` in `WORKSPACE_LIMITS`, else 8640 (144 minutes). The `seconds` sent to `POST /videos` is checked there (`400`); the length ffprobe reads is checked again before transcription (the video fails, minutes given back) |
| Not a video | A file with no video stream, or one ffprobe cannot read as a phone video format, fails at its first read with `status: "failed"` and is not tried again; its minutes are given back |
| Video size | Per account: `maxUploadBytes` in `WORKSPACE_LIMITS`, else `MAX_UPLOAD_BYTES`, else 20 GB. Checked by `POST /videos` (`400`) |
| Minutes a month | Per account: `monthlyMinutes` in `WORKSPACE_LIMITS`, else 600. Checked by `POST /videos` (`403` with the minutes left) before any part is sent |
| Originals kept | 30 days, then deleted by the daily clean-up. An upload not finished in 2 days is deleted whole |
| Clips kept | Per account: `renderDays` in `WORKSPACE_LIMITS`, else 14 days after each clip or draft is made. Then its file is deleted and it shows `status: "expired"`; `POST` it again to render it again while the original is kept |
| Motion renders kept | The same `renderDays`, 14 days after each render is made. Then its MP4 is deleted and it shows `status: "expired"`; the job and its specs stay, and a revision renders a new version |
| Transcripts kept | Per account: `transcriptDays` in `WORKSPACE_LIMITS`, else 30 days after upload, and never before the original is deleted, since a new clip needs the transcript. The moments list goes with it |
| Extracted audio | Never stored. The audio of each part goes from memory to the transcriber, and only its words are kept |

Minutes are whole minutes of source video, rounded up, counted in the calendar month (UTC) the upload starts. `POST /videos` counts `seconds` from the request, or, without it, the size at 20 Mbps. Once the upload is in, ffprobe reads the real length and the count moves to it. If the real length takes the month past the limit, the video stops with `status: "failed"` before it is transcribed and its minutes are given back. Deleting an upload that never finished gives its minutes back; deleting a finished video does not, because the work was done.

## Credits

When the server sells Studiowyre on its own (README, "Paying with Postwyre credits"), a brand needs the `studio` product in its Postwyre plan, and each render is paid from its Postwyre credit balance before it starts: an upload once its real length is read, each clip, each one-shot draft (`POST /videos/{id}/oneshot`, all of a request's drafts or none), each format of each motion version. Prices: [docs/pricing/cost-model.md](../docs/pricing/cost-model.md).

- `403` with "This brand does not have Studiowyre in its Postwyre plan": `POST /videos`, `POST .../clips`, `POST .../oneshot`, `POST /motion` and revisions are refused before anything is charged.
- `402` with `{error, buyUrl}`: the balance is short. Nothing was charged for the refused renders. A clip call that ran short part way keeps the clips it paid for queued and says how many started. An upload that runs short stops with `status: "failed"`, the same words in `error`, `buyUrl` on the video, and its minutes given back.
- `503`: Postwyre did not answer. Nothing was started, and any credits it took for that render are given back; try again.
- A render that fails after three attempts gives its credits back, once. So does work still waiting or rendering when its video or job is deleted. A repeated call (the same `idempotencyKey`, the same clip, the same revision note) is charged once.

### Before and after a job

- **Before:** add `"dryRun": true` to `POST /videos`, `POST /videos/{id}/clips`, `POST /videos/{id}/oneshot`, `POST /motion` or `POST /motion/{id}/revisions`. Each answers `200 {dryRun: true, type, credits, charged}` and starts, counts and charges nothing. `charged` is whether the real call would take Postwyre credits on this server. A repeat of a call already made (a clip already waiting, rendering or ready; a used `idempotencyKey`; the same revision note) quotes `0`. An upload's quote adds `estimate: true`, `basis` (`seconds` sent, or `size` at 20 Mbps) and a `note`: the real charge uses the length ffprobe reads, so it can be higher. A one-shot quote adds `drafts`.
- **After:** every video, clip, draft and motion render carries `credits: {spent, refunded}`, where `spent` is the credits it took and `refunded` says whether they were given back. A motion job also carries `creditsTotal: {spent, refundedCredits}` in credits; `spent` counts credits that were later given back. `dryRun` must be `true` or `false` (anything else is `400`), and a dry run is refused with `403` like the real call when the brand cannot render.

Repeating a call is safe: sending a part again replaces it, `complete` twice is the same as once, asking for a clip that is waiting, rendering or ready returns it unchanged, and `DELETE` of a deleted video answers `404`.
