# cast-selection.json (seamless-cast-selection-v1)

Version 1, frozen 2026-10-16. Changes in the last revision:
- CS104 also checks `origin_ms + resume_ms` against the runtime for a native-conformed clock;
- short shots and groups (CS109, CS110) are warnings: they need original context at delivery (rule below);
- `other_people` may be null on an unselected shot.

Draft 2 added `episode.source_clock`, allowed an unselected shot without a box, checked short shots by their contiguous
group and fixed the checkers' default schema path. The wire format is frozen; any change to it needs a new `$id`.

This file is the hand-off from **cast identification** (finding the shots) to **batch swaps** (the Production v4 swap,
run once per shot). It holds references and numbers only. It never holds media.

- **Schema:** `seamless-cast-selection-v1.json`, a JSON Schema (2020-12). Every object is closed (`additionalProperties: false`).
- **Checkers:** both are dependency-free. Each validates the schema and then the rules below, and prints numbered errors, each with a fix.
  - `tools/check-cast-selection.mjs` (Node 18 or later)
  - `tools/check_cast_selection.py` (Python 3.8 or later, standard library only)

```
node check-cast-selection.mjs cast-selection.json [--schema seamless-cast-selection-v1.json]
python3 -I check_cast_selection.py cast-selection.json [--schema seamless-cast-selection-v1.json]
```

Without `--schema`, each checker looks for the schema next to itself, then one directory up.

Exit codes: 0 valid, also when only warnings are printed; 1 invalid; 2 unreadable.

## Rules beyond the schema

| Code | Rule |
|---|---|
| CS102 | `start_frame < resume_frame` |
| CS103 | `start_ms` and `resume_ms` must equal `floor(frame * 1000 * fps_den / fps_num)`. Frames are the truth. |
| CS104 | `resume_ms` must not exceed the episode's `runtime_ms`; with a native-conformed clock, neither may `origin_ms + resume_ms` |
| CS105 | `person_box.frame` must be inside the shot, `[start_frame, resume_frame)` |
| CS106 / CS107 | A selected shot has no `skip_reason`; an unselected shot has one |
| CS108 | Selected shots never overlap. Back-to-back shots share a boundary: the first shot's Resume is the next shot's Start. |
| CS109 (warning) | A selected shot under 1.5 s that touches no other selected shot: it needs original context at delivery |
| CS110 (warning) | A short shot whose contiguous selected group (back-to-back shots sharing boundaries) lasts under 1.5 s in total: it needs original context at delivery |
| CS111 | A selected shot needs a `person_box`. An unselected shot may have none (null or omitted): never invent a box. |
| CS112 | A `native-conformed` source clock needs `origin_ms` |
| CS113 | A selected shot needs `other_people` (an integer, 0 or more); it may be null only on an unselected shot |
| CS201 (warning) | `source_clock.kind` is `capture`: frames are not mapped to episode time. Not ready for generation or packaging until conformed; the batch runner refuses it. |
| CS202 (warning) | `cadence_verified` is false |
| CS101 | `shot_id` is unique |

The schema itself is checked under codes CS001 to CS013:
- `reviewed_by_human` is the constant `true`;
- paths are relative, with no `..`, no drive letter and no backslash;
- there are at most 500 shots, and a longer list fails: export an explicit subset, never truncate;
- box values are non-negative integers, with width and height at least 1;
- fps is `fps_num`/`fps_den`, for example 24000/1001.

## How it maps to the v4 swap inputs

| cast-selection.json | The v4 guide's input (`/create/character-swap`) |
|---|---|
| `shots[].start_frame`, `resume_frame` | **[Start, Resume)**, step 1 and the capture diagram. Start is the first frame replaced; Resume is the first frame not replaced. The model gets frames up to Resume − 1. Cut the shot from `start_frame` up to, not including, `resume_frame`, at `fps_num/fps_den`. |
| `shots[].start_ms`, `resume_ms` | Derived from the frames, for display and for the edit pack's scene times. Never edit them by hand: change the frame and recompute. |
| `shots[].person_box` `{frame, x, y, w, h}` | The **W3 box** (`seamless-track-one-person.json`, "Box round the person"), in the analysed source's pixels. W3 boxes the shot's first frame. If `frame` is not the shot's first frame, run W3 from that frame, or reverse the shot as the guide shows for a person who walks in. |
| `shots[].other_people` | Name each person who stays in the prompt ("exactly as in the source"). |
| `shots[].selected`, `skip_reason` | Pick the route and Keep the original, steps 6 and 8. Only selected shots are swapped. |
| `character` | Who is recast. Only a human-reviewed group can be exported. |
| `replacement.reference_images` | **Picture 1 and Picture 2**, step 3. The person running batch mode adds these before the run; the cast-ID pipeline exports the list empty. |
| `episode` | Capture facts: the frame rate, the runtime, and the sha256 of the analysed recording. The batch runner checks that it is cutting from the same file. |
| `provenance` | The pipeline version and model hashes, for reproducibility. |

## Delivery context for short shots (applied by the batch runner, not stored here)

The selection describes camera and model bounds only. When a selected shot, or a contiguous selected group, lasts under
1.5 s, the batch runner's delivery plan adds untouched original frames by one deterministic rule:

The target length is the minimal frame count N = ceil(1.5 × fps_num / fps_den), the first count whose exact
rational duration (N × fps_den / fps_num) is at least 1.5 s.

1. Append original frames after the group's Resume, using as many as are available, until the group reaches N.
2. If the tail is blocked (end of the source file, the origin-adjusted runtime, the next selected shot's Start),
   prepend the remaining deficit before the group's Start. Prepended frames may not reach into the previous
   selected shot or into another group's already allocated delivery interval, and never before frame 0.
3. If no complete interval of N frames fits, keep that group original. Other groups are unaffected.

Context frames never enter the model input, and generation never crosses a camera cut. Example at 24000/1001:
N = 36 (35 frames = 1.4598 s is short; 36 = 1.5015 s passes), so a 31-frame shot (1.293 s) gets 5 original
context frames. Delivering 37 frames (6 context frames) is also valid, but one more than the minimum.
Vector `valid/07-v4-31-frame-shot.json` is that case: warning CS109, exit 0.

## What the file does not say

- **`other_people`** is the count observed on the anchor frame only, not a promise that the count holds for the whole shot. It may be null on an unselected shot.
- **The box is a planning anchor, not a mask.** It marks the person on one observed frame. An anchor on an interior frame needs tracking both forward and in reverse from that frame. A mask is never carried across a cut.
- **The source clock.** With `native-conformed`, frame 0 sits at `origin_ms` on the episode clock, at the episode's native constant rate. With `capture` (for example an OBS recording), the frames and their ms are the recording's own, so conform them before generating or packaging.
- **Grouping is for delivery minimums only.** Adjacent selected shots are grouped so a clip reaches the 1.5 s minimum. Each camera shot is still generated on its own.
- **A human-reviewed selection does not approve masks or takes.** `reviewed_by_human` approves the character group only.

The human checkpoints are unchanged. A selection file is not an approval: every take is still reviewed for seams,
identity, mouth timing, unintended accessories and occlusion, and the original is kept when the result is a different
person.

## Conformance vectors

These are in the website repository under `apps/website/test/fixtures/cast-selection/`: 8 valid files (four with warnings) and 16 invalid ones
(numbers 05 and 13 moved to the valid set in draft 3; the gaps are kept so references stay stable), with the expected first error code for each invalid file in `invalid/expected.json`. Both checkers print the
same messages (`test/cast-selection.test.ts`).
