---
name: groundslate-sound-library
description: Build a sound effects library in GroundSlate from generated audio. Interviews the user on register, families, variation count and length, generates the cues locally with generate_sound_effect, falls back to ElevenLabs on the agent's own connection when the local model is missing, auditions before running the whole brief, and lands every file as a searchable library clip carrying the prompt that made it. Optionally binds the whole set to a project. Use when someone wants their own SFX library, a foley set for a film, UI or transition cues for a channel, or wants to stop reusing the same stock whoosh.
---

# Building a sound library with GroundSlate

GroundSlate makes the audio and then stores, types, names and searches it.
`generate_sound_effect` runs Stable Audio 3 on the user's Mac through MLX:
nothing is uploaded, nothing is metered, and the file lands in the library
with its prompt and its seed already on the record. The provider route is
the fallback rather than the architecture, and when you take it every byte
arrives through you and lands through an ingest tool instead.

The user answers one round of questions, listens to one family, and reviews
the finished set. Everything between those moments is yours.

Almost every way this goes wrong is an argument you did not pass, so the
operative steps below give you the literal call. Follow those exactly. The
prose around them is judgement; the code blocks are not.

## Before you promise anything

**Confirm the generator, before the interview and not after.** The local
one is the default: call `list_models` and look for the sound effects
weights. If they are not on the machine, `generate_sound_effect` refuses
with a sentence naming what is missing, and the download is the user's to
start in the app rather than a call you can make. Relay that sentence and
offer the fallback: your own ElevenLabs access, an MCP server already
connected or an API key you hold. If neither route is open, say exactly
that and stop; do not interview a user for a brief you cannot fill.

**The three notes below are the ElevenLabs route only.** Generating on the
Mac has no key, no tier and no sublicensing question, so if the local weights
are there, skip to the interview.

**Never let the key touch disk or library.** The key stays wherever your
environment keeps secrets. Do not write it into a file, a note, a project
note, a prompt you save, or any GroundSlate field. GroundSlate has nowhere to
put a provider credential and should never be handed one. If a user pastes a
key into the chat, use it for the session and tell them to rotate it.

**Turn off sublicensing before you generate a single cue.** By default
ElevenLabs sublicenses generated sound effects to third parties, including
making them available to other ElevenLabs users. There is a Disable control
on the Sound Effects product page, and opting out is **prospective only**: it
does not unwind sublicences already granted on cues generated before you
clicked it. So this is the first thing you ask the user to do, before the
interview, not a footnote at the end. A library that is also in everyone
else's pool is not the library they asked for.

**Settle the licence in one breath, then move on.** Three sentences, said
once:

- On the free tier, output is non-commercial and must carry "elevenlabs.io"
  or "11.ai" **in the title**. Upgrading later does **not** license what was
  already generated, so anything made on the free tier has to be regenerated
  on the paid plan before it can ship.
- Finished cues are theirs to use inside their own productions without
  limit.
- The Prohibited Use Policy bars commercially using or exploiting that output
  **on a standalone basis, for any purpose**, including as isolated files,
  audio samples, sound libraries or collections of sounds, and it reaches
  "any portion thereof". Building a personal library to cut with is squarely
  fine. Selling or licensing the pack is the case the policy names, and it
  applies on paid plans too.

Ask which tier they are on, and ask whether they have already generated cues
on a free tier that they intend to ship, because the answer to the second one
is a regenerate-everything answer.

**Confirm the profile.** `get_context`, then confirm the profile with the
user by name before anything lands. The user can switch profiles at any
moment and you are not told. Once confirmed, pass `profile=` on every library
call. The only calls that take no profile are the machine-wide ones: the job
queue, the media policy, the model settings.

**`folder_settings` is NOT machine-wide, and getting this wrong writes the
whole set into someone else's library.** It is profile-scoped. Called bare it
answers for whichever profile is active, which may not be the one you are
working in:

```
folder_settings(profile="prof_xxxxxxxx")   # the profile you confirmed
  -> downloads: .../GroundSlate Library/prof_xxxxxxxx/Downloaded Media
folder_settings()                          # whatever is active right now
  -> downloads: .../GroundSlate Library/prof_OTHER/Downloaded Media
```

Always pass the profile. `generate_sound_effect` files its own output, so
this matters on the ElevenLabs route: write the bytes you fetch into the
`downloads` path `folder_settings` returns for the profile you confirmed.

**If you are generating through the ElevenLabs MCP server, pass
`output_directory` on every call**, set to that same `downloads` path. It
defaults to `~/Desktop`, which GroundSlate has not been granted and cannot
read, so the ingest fails after the credits are spent. `list_folders` shows
what is granted. Granting more is the user's, in the app under Data then
Allowed Folders; `add_allowed_folder` is disabled for MCP clients and changes
nothing, so ask rather than call it.

## The interview

Ask in one round. These are the answers you cannot infer, and every one of
them changes what you generate.

- **What they cut.** A software channel, a wedding film, a podcast, a game,
  short-form vertical. This sets the whole register and is the only question
  that has no default.
- **The register.** Three answers, and they do not mix well in one set:
  *utility*, dry short cues for interface, transitions and emphasis, no tail,
  no space; *designed*, cinematic risers, impacts, drones and stingers with
  their own reverb baked in; *organic*, foley that sounds recorded, footsteps,
  paper, doors, cloth, weather. A library can hold all three, but generate
  them as separate passes with separate briefs, because a prompt written for
  one produces embarrassing results in another.
- **The families.** What they reach for and never have. Push for specifics: a
  "transition" is not a family, "a short upward whoosh into a hard stop" is.
  If they cannot name families, propose a starting set from what they cut and
  let them cut it down. Twelve families is a real library; forty is a
  procurement exercise nobody finishes listening to.
- **Variations per family.** Three to five. One is not a library, it is a
  tic the audience learns by episode four. Past five you are generating
  material nobody auditions. Say the number back with the total attached,
  because a set nobody finishes listening to is the real cost locally, and
  on the ElevenLabs route it is the credits as well.
- **Length, per family, in seconds.** This is a real parameter and you will
  pass it as `seconds` locally, or as `duration_seconds` at ElevenLabs, so
  collect a number rather than an adjective. The local floor is **1s** and the
  ceiling is **120s**, refused rather than clamped at either end; the provider
  floor is 0.5s. A utility cue that has to be shorter than the floor is
  generated at the floor and trimmed after the fact. Ask whether they want the
  tail or want it dry so they can add space themselves. Dry is the better
  default, because reverb can be added later and never removed.
- **Output format, on the ElevenLabs route only.** Ask for `pcm_44100` unless
  they have a reason to want something else. It costs nothing extra and it
  removes an entire class of failure later: GroundSlate's bundled ffmpeg has
  no MP3 encoder, so an MP3 set cannot be levelled without transcoding it
  first. Locally there is nothing to choose: `generate_sound_effect` hands
  back a 44.1 kHz stereo wav.
- **The project question, asked once.** Does this set belong to a specific
  project, or to the whole profile? A film's own foley belongs to that
  project. A channel's interface cues belong to the profile, because the
  next twenty videos want them too. If they name a project, `search_projects`
  for it and hold the `prj_` id; if they want a new one, `create_project`. If
  they say the profile, bind nothing and say so: an unbound clip is a
  first-class library clip and the ingest result will report `project: null`.
- **Anything they already have** that the set should sit beside without
  clashing. If there is an existing bed or a house sound, ask for one
  example, and if it is already indexed, listen to what the library knows
  about it before writing prompts.

## Write prompts as the description you will search for later

This is the mechanic the whole library rests on. When a generated file is
ingested, its prompt is written to the clip's `ai_generation.json` and
indexed into full-text search. So the prompt is not throwaway input, it is
the metadata. Write it as the sentence the user would type in six months when
they want this sound back.

- Describe the sound, its material, its motion and its space:
  "short dry wooden knock, close mic, no reverb, single hit" finds itself
  later. "whoosh 3" does not.
- Put the family name in every variation's prompt, spelled the same way
  every time. That single consistency is what makes a family retrievable as
  a group.
- Vary the variations meaningfully, and vary them in the words rather than in
  the seed. Locally the same prompt and the same seed are bit-exact
  reproducible, so a second call with both unchanged dedupes against the clip
  already in the library instead of minting a near-copy; at ElevenLabs the
  same prompt four times gives four genuinely different files that are useless
  as alternatives to each other. Either way the fix is the same: change
  something real between variations, the material, the speed, the weight, the
  distance, the direction, and you get variations that are actually different
  sounds which happen to belong together. Move `seed` only when you want
  another take of a description you are keeping.
- Never encode a version number where a description belongs. The library has
  ids for identity; prompts are for meaning.
- The prompt is write-only from the search side. A `search_catalog` hit
  returns title, summary, shot text and transcript, none of which contain the
  prompt, so you cannot read back from a search result what a clip was asked
  for. To see it, open `ai_generation.json` in the clip's folder.

## Audition one family before you run the whole brief

Generate the first family in full, land it, and play it back to the user
before generating anything else. A register that is wrong is wrong across
every family, and catching it after one family costs four files instead of
forty-eight. Locally the audition is close to free, which is a reason to do
it properly rather than a reason to skip it.

Show the family as a list with what each variation was asked for, and ask one
question: closer, further, drier, wetter, shorter, heavier. Then apply the
answer to the brief for every remaining family, not just to a regenerated
first one.

On the ElevenLabs route only: if the user is on the free tier and intends to
ship this work, say plainly that the audition family will have to be
regenerated after they upgrade, because the licence attaches when the audio
is made and not when the plan changes.

## Generating on your Mac

`generate_sound_effect` is the default door and it is a background job: it
returns a `job_id`, and `job_wait(job_id)` hands back the path to a 44.1 kHz
stereo wav. There is nothing to write, nothing to ingest and nothing to
fetch. The file is already a library clip carrying its prompt and its seed,
and `project_id` binds it to the binder as it lands.

```
generate_sound_effect(
  prompt="short dry wooden knock, close mic, no reverb, single hit",
  seconds=0.8,
  seed=0,
  project_id="<prj_ id, or omit>",
  profile="prof_xxxxxxxx",
  agent="<your name>"
)
-> {"job_id": ...}   # then job_wait(job_id)
```

Four things the engine will not tell you until it bites:

**The prompt wants a source, a material, a motion and a perspective.**
"footsteps crunching on dry gravel, slow steady pace, close mic" comes back
usable; "footsteps" comes back generic. That is the same sentence you want in
the search index six months from now, which is why the section above is the
one this rests on.

**Length is refused past 120 seconds, never clamped**, and refused below one
second at the other end. Ask for two takes and join them if a bed-length cue
is really what the brief wants.

**Every generation on your Mac shares one GPU lock**, across voice, music,
effects and video, and across every agent connected to GroundSlate. A batch
is a queue rather than a fan-out. Effects are fast enough that this is rarely
the bottleneck, though the first call of a session also pays the model load,
so do not read the first cue's timing as the batch's.

**`negative_prompt` is inert at the default `cfg` of 1.0.** Raising `cfg`
above 1.0 is what turns on the unconditional branch a negative prompt steers
away from. Leave both alone unless you have a reason, and say what the reason
was in the project note.

## Generating at ElevenLabs, when the local model is not there

Three things the endpoint needs that nothing will tell you:

**The model id is not discoverable.** `GET /v1/models` returns the TTS models
and does not list the sound-effects models at all, so an agent that looks it
up the obvious way records a false one. The values are
`eleven_text_to_sound_v2` and `eleven_text_to_sound_v3`. Pass one explicitly
and record the same string in provenance.

**`duration_seconds` is the only length control, and omitting it costs five
times as much.** With it, you pay 10 credits per second. Without it, you pay
a flat 50 credits per generation whatever comes back. A one-second cue is
therefore 10 credits or 50, decided by one argument. Forty-eight files is
480 credits or 2,400.

**The range is 0.5 to 30 seconds over the REST endpoint, but the official
MCP server caps `duration_seconds` at 5.** A long designed cue needs the REST
call. The value is honoured exactly.

```
POST https://api.elevenlabs.io/v1/sound-generation
{
  "text": "<the prompt, written as the description you will search for>",
  "model_id": "eleven_text_to_sound_v2",
  "duration_seconds": 0.8,
  "output_format": "pcm_44100"
}
-> raw audio bytes
```

Budget the whole brief before the first call and say the number out loud:
families x variations x seconds x 10 credits. Check the balance at
`GET /v1/usage/character-stats?breakdown_type=product_type` rather than
`/v1/user/subscription`, which lags behind actual usage.

## Landing the audio

A cue made by `generate_sound_effect` is already landed: skip this whole
section and go straight to naming. What follows is the ElevenLabs route.

**ElevenLabs hands you bytes, never a URL.** The sound-effects endpoint
returns audio directly and the MCP server writes a file, so there is no
hosted asset to fetch and no second door to look for. Write the bytes into
the profile's `downloads` folder and call `ingest_generation` with the local
path. (`fetch_media` is the right tool for providers that return a hosted
URL, such as fal.ai. It is not usable here.)

```
ingest_generation(
  path="<downloads>/whoosh_short_01.wav",
  provenance={
    "generator": "elevenlabs",
    "model": "eleven_text_to_sound_v2",
    "prompt": "<the exact prompt>",
    "generated_at": "<ISO timestamp>"
  },
  idempotency_key="sfx-whoosh-short-01",
  project_id="<prj_ id, or omit>",
  profile="prof_xxxxxxxx",
  agent="<your name>"
)
```

`generator` and `model` are required; the generator slug must be one
GroundSlate knows, or the payload is refused as `not_a_generation`. No
`source_text` is needed for sound effects. Do not put cost or billing data in
provenance; GroundSlate stores no money metadata and will drop it.
`idempotency_key` must be unique per file, so that a batch which fails at
file thirty and gets retried does not ingest the first twenty-nine twice.

Then read three things on every result, because each one is a silent failure
otherwise:

- **`deduped`.** GroundSlate fingerprints content, on both routes. If those exact bytes are
  already in the library, the ingest returns `deduped: true` with the
  *existing* clip id and **throws your new prompt away**. That is how a
  retried batch comes back thinner than the brief. If you need the new prompt
  on an existing clip, use `amend_generation`, not a second ingest.
- **`lufs_i` in the clip's `music.json`.** Every audio ingest measures
  loudness for free. A cue below about -45 LUFS is a failed generation: the
  model underran the prompt, which happens on quiet material at short
  durations. It will ingest cleanly, index cleanly and play nothing.
  Regenerate it with more energetic wording rather than landing silence in
  the library.
- **The Whisper note.** Every audio ingest queues a transcription, and on
  sound effects it should come back empty. Confirm that it did. A transcript
  with words in it means Whisper hallucinated over noise, and that text goes
  straight into the search index this whole library depends on.

These are background jobs. Queue the batch, follow `job_events`, and report
as families land rather than blocking on each file.

**A file that already exists on disk**, recorded rather than generated, goes
through `index_audio` instead. Do not push real recordings through the
generation door: a generation record is a claim about how the file was made,
and it is wrong for something a microphone captured.

## Name it, or you have made a folder, not a library

An ingested clip is searchable by its prompt immediately. That is not the
same as being browsable, and the Library grid is where people actually look.

- `name_video` every clip with a human title in a stable shape: family
  first, then what makes this variation different. "Whoosh, short and dry"
  and "Whoosh, long and airy" sort together and read at a glance. Name from
  the sound, never from the filename.
- **Read the result, and do not read `found: false` as failure.** On audio it
  answers `found: false` with `written: true` and `sidecar: true`: audio
  titles live in a durable `title_override.json` beside the clip rather than
  in the library store. `written` is the field that tells you whether the
  title landed. The sidecar survives a re-index, so nothing you do later
  erases it.
- A title you set is marked as yours (`source: "ai"`) and a title the USER
  typed is not replaceable by you. If a clip already carries a human's name,
  leave it.
- `edit_clip_summary` one sentence where the title cannot carry it: the
  intended use, the register, whether it has a tail. This is the text a
  future search matches when the prompt's wording is not what the user
  thinks of.
- `set_favorite` the one cue per family you would reach for first. A library
  with a default in every family is faster than a library where every use is
  a decision.
- Do not rename files on disk. The library is the index, and renaming breaks
  the path a clip is keyed on.

## Give every cue its role

Audio carries an editing role, the same way footage does. The four values are
`music`, `narration`, `soundeffect` and `ambience`, and for this work you
want `soundeffect` on the one-shots and `ambience` on anything sustained that
sits under a scene.

The indexer classifies each clip as it lands and abstains rather than
guessing, so most cues arrive already stamped. Check, and correct the ones it
left unset or got wrong:

```
set_visual_kind(source="<clip id>", visual_kind="soundeffect",
                agent="<your name>")
```

`sound_effect`, `voiceover` and `room tone` are accepted spellings and fold
to the stored token. The role is media-gated, so the video and photo roles
are refused on audio. Your correction is durable, survives a re-index, and
always outranks the classifier. Passing an empty string clears it back to
unset.

This is what makes the set findable as a set, in the app's Role filter and in
search:

```
search_catalog(visual_kind="soundeffect", media_type="audio",
               profile="prof_xxxxxxxx")
```

Give the user that query, and put it in the project note.

## Bind it, or say plainly that you did not

If the set belongs to a project, `add_media_to_project` binds every clip that
did not already bind at ingest. Read the result's `unresolved` field rather
than assuming: it is non-empty when a reference matched nothing, and those
clips were never added.

Then `add_project_note` the brief itself: register, families, variation
count, the length per family, the model id, the prompt pattern, and the
search above. That note is how the next batch matches this one instead of
being a second library with the same job. `list_project_outputs` to confirm
what the binder actually holds.

If the set belongs to the profile rather than a project, say so explicitly in
your report and hand the user that same search, so they know how to get back
to it.

## Level the set by peak, not by loudness

A library where every cue needs a different fader move is a library people
stop using. Measured across a fresh set, the spread is routinely 40 dB or
more.

**Use peak normalisation.** These cues are too short for `loudnorm`: EBU R128
needs seconds of material and will miss its target by 6 dB or more on a
sub-second transient, which is exactly what a utility cue is.

Two passes per file, both through `render_ffmpeg`:

```
# 1. measure
render_ffmpeg(argv=["-i", "<in>", "-af", "volumedetect", "-f", "null", "-"],
              out_path="<scratch>/measure.txt",
              agent="<your name>", label="measure", profile="prof_xxxxxxxx")
# read max_volume out of result.stderr

# 2. apply, gain = -1.0 minus max_volume
render_ffmpeg(argv=["-y", "-i", "<in>", "-af", "volume=<gain>dB",
                    "-c:a", "pcm_s16le", "-ar", "44100", "<out.wav>"],
              out_path="<out.wav>",
              agent="<your name>", label="level", profile="prof_xxxxxxxx")
```

Four things will otherwise bite you, and three of them fail quietly:

- **`agent=` is required.** Without it the job is refused outright as
  `unidentified_job`.
- **The output path must appear at the end of `argv` as well as in
  `out_path`.** Otherwise ffmpeg is handed no output file.
- **The job reports `state: "done"` and `pct: 100` even when ffmpeg failed.**
  Only `result.ok` tells the truth. Check it on every render, or you will
  report a levelled set that does not exist.
- **GroundSlate's bundled ffmpeg has no MP3 encoder.** It has aac, alac,
  flac, opus, pcm_s16le and vorbis. Write WAV. Generating at `pcm_44100` in
  the first place skips this entirely.

The levelled files land in Scratch Outputs, which is temporary and may be
emptied. `commit_to_library` them, then `index_audio` the levelled set if it
is the one you want people cutting with. Otherwise the library still holds
the un-levelled originals and the work you just did evaporates.

When these cues later go under narration, `mix_audio` does the ducking. Do
not pre-duck a library cue: the file has to work under every future mix, not
the one you are imagining now.

## Show them, every round

A round of work ends when the human has seen it, not when you believe it is
good. `request_review(source, ask, agent=, profile=)` puts the file in front
of them with a timeline they can comment on, regions they can draw and a
verdict bar. Do this at the end of every round, unprompted. Describing a
render in prose is the thing the tool exists to replace.

- ALWAYS pass an `ask`. With one, the window carries Approve and Request
  changes. WITHOUT one it is a SHOW: it still accepts notes and offers no way
  to submit them, so a human can write thirty notes into a window that cannot
  send them. Use a show only to put an asset in front of somebody with nothing
  to decide.
- ALWAYS pass `agent=`. Unnamed, you are recorded as the person: nothing is
  reused, `withdraw_review` refuses everything including your own, and the
  app's raise skips your review entirely.
- READ `placed` BACK AND SAY WHAT IT SAYS. `opened` means the window is coming
  up, `waiting` means the app is running but not frontmost so its Dock icon is
  bouncing and the review is on the Dashboard, `saved` means the app is shut
  and it will be there next launch. Telling somebody to take a look when their
  app is closed is exactly what that field exists to prevent.
- Poll `review_status` for the verdict and the notes. Park on `review_wait`
  only when the verdict genuinely blocks the next step; it is a blocking wait
  on a human.
- One live ask per file. `withdraw_review` takes back a superseded question of
  your own. It refuses a review that has been decided, one carrying comments,
  and anybody else's, which is correct: never delete somebody's writing.

Every note carries an anchor: `t`, sometimes `t_end`, and a `region` with a
normalised box plus a `where` in words. Work from the anchor rather than your
reading of the sentence. Map each note to the scene that owns that moment and
write the map into the project with `write_project_file` before you start, so
the round survives you.

Then report per note, one block each, DONE or NOT DONE or PARTIAL, with the
evidence. A claim is not evidence. "Added kinetic text" is a claim; "probe
returned clean, rendered 562 frames, the three lines land at 2.94, 4.10 and
6.84 inside the safe area" is evidence. If a note cannot be done, say NOT DONE
and why, and never report a note done that you did not verify. Ask for the
next round the same way.

## Everything happens inside GroundSlate

The tools run on the user's Mac, in the background, on a queue. They are
faster than you and they cost none of your context. Every minute you spend
hand rolling a filtergraph is a minute the machine was idle and your context
was being spent on something nobody can read afterwards. Reach for the tool
first, every time.

- Authored pages: `write_composition` writes your HTML to a real path the
  renderers can open, and vendors sibling files beside it. Before it existed
  an agent authoring scenes had to reach for a filesystem bridge outside
  GroundSlate. Do not go back to that. `preview_html` to check it,
  `render_html` to bake it.
- Assembly: `render_sequence` takes N clips and N-1 seams in one call and
  encodes every frame exactly once. `author_film` expresses scenes and seams
  as one page composition. Both enforce the identity below; a hand rolled
  concat does not.
- Ducking: `mix_audio` IS the broadcast sidechain. Pass voice and music, get
  `ffmpeg_argv` back, hand it to `render_ffmpeg`. Note the one parameter that
  is not in decibels: `threshold` is a linear amplitude because that is what
  the compressor takes, and a dB value is refused rather than misread.
- Effects: `list_effects` and `apply_effect` for the graded looks,
  `list_ai_effects` with `preview_ai_effect` before `apply_ai_effect`.
- Transitions: `list_transitions` catalogs both engines, the ffmpeg one and
  the page one (`engine="html"`), and `transition_motion` gives you a move's
  actual curve and what it drives. Choose on the MEASURED `seam_seconds`, not
  the `cost` tier, which is wrong in both directions at the edges.
- The web: see the capture section. GroundSlate renders pages itself now.

And do not bake early. A film that exists only as a finished mp4 has to be
rebuilt in full for a one word change. Keep the edit as data for as long as
you can, and bake the deliverable last.

## The project is the source of truth, and the test is a second agent

Everything this production learns lives in the project, not in the
conversation and not in a scratch directory. The test is concrete: a
DIFFERENT agent, tomorrow, given only the profile and the project id, must be
able to pick the work up and continue it. If they cannot, the production
failed, however good the film is.

- `create_project` early and hold the `prj_` id. Pass it to everything that
  binds, not only the final render.
- Media from the web binds as it arrives: `fetch_asset` with `project_id`.
  Media you made binds in two steps, `index_image` then
  `add_media_to_project`, whose result carries `unresolved` for references
  that matched nothing. Read it rather than assuming.
- Text is working material and it has its own door. The brief, the script,
  the per scene cue tables, the note map from each review round, the voice
  ids, the measurements you took and the ones that surprised you: all of it
  goes in with `write_project_file` under an area prefix. `add_project_note`
  records a decision AND the reason for it, which is the part a later agent
  cannot reconstruct.
- `project_manifest`, `list_project_files` and `read_project_file` are how
  that agent resumes. Write for them.
- `commit_to_library` the finals only, with the project. Renders live in
  Scratch Outputs, which may be emptied.

Anything you leave only in the conversation is gone when the conversation
ends. That includes the reason you chose a voice, the cue table you measured,
and which of the client's notes you decided not to act on and why.

## Timing is frames, and frames are the currency

- A transition OVERLAPS its two shots, it never adds time. A cut runs
  `sum(clip frames) - sum(seam frames)`. Do the arithmetic in frames.
- Declare durations that land on whole frames at your fps. 18.15 seconds at
  30fps is 544.5 frames and floors to 544, one short of what you meant, and
  nothing tells you.
- ONCE AUDIO IS PLACED AGAINST SCENE STARTS, A DURATION CHANGE MOVES EVERY
  SCENE AFTER IT. Freeze scene durations before you place a single cue, or
  accept that you will re-derive every cue downstream by hand.
- Verify frame counts with `ffprobe` on the delivered file, not from the job
  record. Jobs can vanish from a shared queue without writing a file and
  without a terminal state.
- Anything that passes through `minterpolate` needs an explicit constant frame
  rate normalisation afterwards. Its timestamps do not land on the frame grid,
  and the next step drops frames without saying so.
- Keep the whole film one pixel format and one colour range. A single
  full range clip among limited range ones is a black level jump at that
  splice, and a frame count will never show it.

## An instrument that cannot fail is not evidence

- Prove the red before you trust the green. Run your check against something
  you know is broken and confirm it reports the fault. A checker that skips
  elements with children will pass every headline in the film.
- Whisper is a reconstructive model. Its language model restores the plural it
  expects, so a transcript will tell you a word was spoken when the audio does
  not contain it. Never use a transcript as evidence for an inflection, a word
  ending, or anything acoustic. Measure the acoustics.
- Beware a default that is indistinguishable from a real answer. It passes
  every test and is wrong exactly where it matters.
- When two instruments disagree, the one that can be wrong in the direction of
  the common case is the suspect. Look at the thing itself; looking settles
  cases that measurement cannot.
- Measure the population the claim is about, and confirm on a different axis
  than the one that produced the claim.

## Report like a librarian

- After the batch, report what landed and what did not. A generation that
  failed, a cue that came back near-silent, an ingest that returned
  `deduped: true` and discarded its prompt, these are results, not noise.
  Name the families that came back thinner than the brief, so the user knows
  before they go looking for a variation that was never made.
- End with the shape of the library: how many families, how many cues, where
  they live, whether they are bound to a project, and the one search that
  returns all of them. Say which route made them, and on the ElevenLabs route
  say what it cost.
