extract-source-sample
This is an agent-executed skill. There are no Python scripts. The agent
reads the run folder, builds the JSON, and stamps catalog links by hand. The
content-goose run folders aren't always cleanly structured (some have empty
production/ JSON, some carry everything in working/) — an agent adapts, a
script would brittle out.
When to use
- "Extract the source-sample.json for
<run>."
- "Get the upload-sample JSON for this ad so I can remix it."
- "Prep
<run> for remix."
Do NOT use to:
- Rewrite the script for a new brand (that's a separate agent step that
consumes this skill's output).
- Render the remix (that's the existing
remix-ad skill).
- Upload an ad to the library (that's
upload-ad-sample).
Inputs
| Input |
Required |
Notes |
run-dir |
yes |
Absolute path to a content-goose ad-run folder (e.g. clients/ladder/ad-runs/run-02-podcast-skit). |
out |
no |
Where to write the JSON. Default: <run-dir>/remix/source-sample.json. |
That's the entire interface.
What the agent must do
1. Read the run
Open each file if it exists; tolerate missing files (most production/*.json
in older runs are empty stubs — fall back to working/):
working/script.json — primary source of truth for scenes, voices, set.
production/asset-manifest.json — assets[] with role active_master
points at the master mp4; per-asset provider + metadata.model produce
the atom-skill rows.
HOW_TO_MAKE_THIS_VIDEO.md — gets dumped verbatim into how_to.
video-project.json — fallback for title / format when script.json doesn't
carry them.
finals/*.mp4 — fallback for the master mp4 if asset-manifest is empty.
working/characters/*.png — anchor portraits per character.
working/*.py — driver scripts (render_vo.py, render_variants.py,
render_clips.py, stitch.py, build_end_card.py, etc.). These are the
source's runnable code; the remix consumer ports them. Capture in
production_scripts[] (step 2 below).
For sources with character-pose stills (any run with a
working/characters/ folder of <character>-<pose>.png files —
podcast-skit, founder-led, testimonial, recreate-ugc, etc.), audit every
PNG with file. Do NOT stop at the base portraits. The recipe shot list
references variant expression PNGs (e.g. brittney-eyebrow-up.png,
brad-phone-up.png) by filename; the consumer assumes they exist on disk
and will spend real money on lipsync calls before discovering they don't.
For sources without character-pose stills (music-video b-roll, abstract
animated, product-only) — skip this audit; variant_assets[] stays empty.
Record per file (when auditing):
{
"file": "brittney-eyebrow-up.png",
"pose_tag": "skeptical-eyebrow", // slug from filename stem
"kind": "real" | "lfs-pointer" | "missing",
"size_bytes": 142336
}
file <path> says PNG image data, … for real binaries and ASCII text
for LFS pointers. A real binary is >10KB in practice; an LFS pointer is
<200 bytes.
Materialize LFS pointers before reading any binary. An LFS pointer is a
tiny (<200 byte) ASCII file beginning with version https://git-lfs.github.com.
If a PNG or mp4 looks like one, run:
cd <run-dir-or-repo-root>
git lfs fetch --include=<relative path>
git lfs checkout <relative path>
before referencing it. If git lfs pull no-ops and the LFS endpoint
returns 404 (objects committed as pointers but never pushed — common on
content-goose), leave the entry as kind: "lfs-pointer" in
variant_assets[]. The consumer will regenerate or scrape; this skill
does NOT fabricate. See [[feedback_lfs_pointer_audit_before_paid_calls]]
and [[feedback_fal_subscribe_error_envelope]] for the downstream cost when
this audit is skipped — Hume run-03 lost ~$3 + 25 min to it.
2. Build source-sample.json
Shape (every key always present, arrays may be empty):
{
"title": "<from script.json or video-project.json>",
"format": "video",
"ratio": "<aspect_ratio from script.json — e.g. 9:16>",
"formatProfile": "podcast-skit-fabricated", // enum, see below
"media_url": "file://<abs path to master mp4>",
"thumbnail_url": null,
"brand": "<derive from path: clients/<brand>/ad-runs/...>",
"tags": [],
"recipe": { "shots": [...], "total_duration_sec": <int> },
"extracted_script": "HER: …\nHIM: …\n…",
"skills_used": ["generate-voiceover", "..."], // atoms only
"skills_source": "measured" | "derived-from-production-scripts" | "inferred-canonical" | "guessed",
"how_to": "<contents of HOW_TO_MAKE_THIS_VIDEO.md or null>",
"production_scripts": [
{ "path": "working/render_vo.py", "role": "voiceover" },
{ "path": "working/render_variants.py", "role": "stills" },
{ "path": "working/render_clips.py", "role": "lipsync" },
{ "path": "working/stitch.py", "role": "stitch" },
{ "path": "working/build_end_card.py", "role": "end_card" }
],
"remix_spec": {
"version": 1,
"skills": [{"slug": "...", "provider": "...", "model": "..."}],
"worlds": [{"key": "...", "name": "...", "set": "...", "lighting": null, "color_grade": null, "reference_image_url": null, "catalog_id": null}],
"characters": [
{
"key": "her",
"name": "Brittney",
"gender": "f",
"soul_id": null,
"anchor_asset_id": "asset-char-her-base-01",
"anchor_image_url": "file://...png",
"method": "anchor-ref",
"description": null,
"catalog_id": "brittney",
"variant_assets": [
{ "file": "brittney-base.png", "pose_tag": "base", "kind": "real", "size_bytes": 1842336 },
{ "file": "brittney-eyebrow-up.png", "pose_tag": "skeptical-eyebrow", "kind": "lfs-pointer", "size_bytes": 132 },
{ "file": "brittney-shrug.png", "pose_tag": "shrug", "kind": "missing", "size_bytes": 0 }
]
}
],
"voices": [{"voice_id": "kPzsL2i3teMYv0FxEYQ6", "voice_name": "Brittney", "provider": "elevenlabs", "settings": {"stability": 0.45, "similarityBoost": 0.78, "style": 0.45, "useSpeakerBoost": true}, "selected": true, "catalog_id": "brittney"}]
}
}
formatProfile — open string, drives downstream pipeline choices
A short slug naming the source's ad format. Open vocabulary — the
content-goose molecule library has ~40 distinct ad formats and growing;
don't try to fit a closed enum. Pick a slug that matches the source's
molecule name (e.g. create-podcast-skit-ad → podcast-skit-fabricated,
create-cinematic-music-video → music-video-sung), or invent a new
short slug when none fits.
The consumer (remix-script, remix-ad) routes on the slug. Two routing
properties downstream cares about — record them alongside the profile so
the consumer doesn't have to re-derive:
"formatProfile": "podcast-skit-fabricated",
"formatProfileProperties": {
"audioType": "spoken-vo" | "sung-music" | "mixed",
"sceneCount": "flexible" | "locked-to-source"
}
audioType drives the caption pipeline (spoken-vo → Whisper word-level;
sung-music → script.json scene windows, because Whisper returns 🎵 Music Playing 🎵; mixed → split per segment).
sceneCount drives whether the remix can flex (most spoken formats) or
must lock 1:1 (sung-music, anywhere lyric meter sets timing).
If you can't confidently assign either property, leave it null — the
consumer surfaces to the user rather than guessing.
Known slugs from past runs (extend as new formats appear):
| Slug |
audioType |
sceneCount |
Origin |
podcast-skit-fabricated |
spoken-vo |
flexible |
Ladder run-02 (HER/HIM, 22 scenes, talking-head + broll) |
music-video-sung |
sung-music |
locked-to-source |
Loóna run-01 (single VOCAL track, 14 lyric-locked beats) |
The two known slugs are what real retros produced. Add new rows here when
you extract a source that fits a new molecule (single-host-ugc, animated-
explainer-villain, stop-motion-tabletop, goose-vs-tool, hook-variant, etc.).
Don't pre-invent slugs that haven't shipped yet.
Per-section derivation:
recipe.shots[] — one shot per scene in script.json.scenes[]:
{ "id": "s01", "shot": "<still filename>", "type": "<shot type>", "speaker": "HER|HIM|null", "duration_sec": <parsed from time field e.g. "0:02-0:05"→3> }.
Sum durations into total_duration_sec.
Add pose_tag ONLY when the filename matches <character>-<pose>.png
(the character-pose convention used by podcast-skit and other character-led
formats). Derive it by stripping the character prefix from the filename
stem (e.g. brittney-eyebrow-up.png → pose_tag: "eyebrow-up",
brad-phone-up.png → pose_tag: "phone-up"). For formats whose shots
aren't keyed to character poses (music-video b-roll, product hyperframes,
abstract animated scenes), omit pose_tag from the shot.
extracted_script — concatenate <who>: <text> per scene, newlines
between.
remix_spec.worlds[0] — derive from script.json.set_description:
{ key: <slug of the run's setting label>, name: <human label>, set: <full set_description>, lighting: null, color_grade: null, reference_image_url: null, catalog_id: null }.
remix_spec.characters[] — one per voice role (HER, HIM, …) in
script.json.voices:
key: "her" / "him" (lowercase role)
name: voices.<role>.name
gender: HER→"f", HIM→"m", NB→"nb"
soul_id: null unless the run has a Higgsfield Soul anchor
anchor_asset_id: "asset-char-<key>-base-01" if an anchor PNG exists, else null
anchor_image_url: file:// URL to working/characters/<name>-base.png
(or the first png matching the lowercase name), else null
method: "anchor-ref" if anchor PNG present, else null
description: null unless surfaced in the run's how-to
catalog_id: stamped in step 3.
variant_assets[]: emit ONLY when the source uses character-pose
stills (the <character>-<pose>.png filename convention). One entry
per PNG in working/characters/ whose filename starts with the
character's lowercase name. Each entry {file, pose_tag, kind: real|lfs-pointer|missing, size_bytes} — derived from the file
audit in step 1. This is the canary the consumer needs to decide
whether to regenerate variants before paid lipsync calls. Include
the base entry too (pose_tag: "base"). For formats without
per-character pose stills (music-video, animated, product-only),
set variant_assets: [] or omit the key.
remix_spec.voices[] — one per voice in script.json.voices:
voice_id, voice_name (from name), provider: "elevenlabs"
settings: copy from script's settings, rename to camelCase
(similarity_boost→similarityBoost, use_speaker_boost→useSpeakerBoost)
selected: true for the first voice in script order, false otherwise
— exactly one selected: true.
catalog_id: stamped in step 3.
remix_spec.skills[] — derive atom rows from
production/asset-manifest.json.assets[]: each asset's provider +
metadata.model (+ skill or metadata.skill for the slug) produces one
row, deduped. Drop molecule slugs — only atoms allowed. The canonical
atom inventory is:
generate-voiceover, generate-character-image, generate-broll-shot,
generate-lipsync, generate-music, compose-master, burn-captions,
add-captions-klap, render-hyperframe, stitch-clips, build-end-card
When the asset-manifest is empty (common in older runs), derive atoms
generically — don't hard-code per-format recipes. The repo has ~40
ad-format molecules and growing; canonical recipes drift fast. Use this
cascade:
Read production_scripts[] (next section) — each driver script's
actual provider calls are authoritative. Open render_vo.py, grep
for elevenlabs/fal/higgsfield imports + endpoint URLs, and
derive one atom row per provider × model the script actually invokes.
This is more reliable than any guessed recipe because it reflects
what the source ACTUALLY did, not what the format usually does.
If production_scripts[] is also empty, fall back to the
podcast-skit-fabricated canonical recipe ONLY when
formatProfile === "podcast-skit-fabricated" — the one format with
enough run data to canonicalize:
[
{"slug": "generate-voiceover", "provider": "elevenlabs", "model": null},
{"slug": "generate-character-image", "provider": "higgsfield", "model": null},
{"slug": "generate-lipsync", "provider": "fal", "model": null},
{"slug": "compose-master", "provider": "ffmpeg", "model": null},
{"slug": "burn-captions", "provider": "ffmpeg", "model": "libass"}
]
Note model: null — model ids drift (eleven_multilingual_v2 →
eleven_multilingual_v3, veed/fabric-1.0 → veed/fabric-2.0); don't
freeze them in the SKILL.
For any other formatProfile with neither asset-manifest nor
production_scripts, surface to the user and ask which atoms ran.
Don't invent — skills_source: "guessed" is worse than null.
Mirror skills_used as the flat slug list.
skills_source — top-level field recording how the atom list was
obtained, so the consumer knows how much to trust it:
"measured" — derived from a populated asset-manifest.json (cascade
step would have used the real provider/model fields).
"derived-from-production-scripts" — grepped from the source's
working/*.py driver scripts (cascade step 1). Reliable: reflects
actual API calls.
"inferred-canonical" — fell back to the canonical podcast-skit
recipe (cascade step 2). Only valid when formatProfile === "podcast-skit-fabricated".
"guessed" — none of the above worked and the user supplied the
list. Should be rare; surface in the summary.
The consumer reads this field. Without it, a guessed atom list
propagates downstream as if it were measured (Ladder extract retro
flagged this — skills_source=inferred quietly made it into Hume's
remix-plan as fact). When skills_source !== "measured", the consumer
should cross-check against production_scripts[] before trusting any
individual row.
production_scripts[] — list every working/*.py file in the run
with {path, role}. Roles: voiceover | stills | variants | lipsync | stitch | end_card | music | composites | other. Match by filename:
| Filename |
Role |
render_vo.py, gen_vo.py |
voiceover |
render_keyframes.py, gen_keyframes.py |
stills |
render_variants.py |
variants |
render_clips.py |
lipsync |
stitch.py, compose.py, compose_master.py |
stitch |
build_end_card.py |
end_card |
gen_music.py |
music |
burn_captions.py, make_subtitles.py |
(none — these are atom-level scripts) |
build_composites.py |
composites |
| anything else |
other |
These are the source's runnable code. The consumer ports them as the
starting template — molecule SKILL.mds are recipes, not executables.
3. Link characters + voices to the character library — and add any that are missing
Library location: <repo-root>/assets/character-library/, where <repo-root>
is the content-goose checkout that contains the run-dir (derive it by walking up
from run-dir to the directory that holds assets/ — do not hardcode an
absolute machine path, and never write outside this repo).
Layout:
character-library/
├── index.json ← machine-readable catalog
├── INDEX.md ← human-readable table (hand-curated)
└── <key>/
├── character.json
└── shots/
└── front.png
index.json schema (one row per character):
{
"key": "brittney",
"name": "Brittney",
"gender": "f",
"ethnicity": "white",
"age_band": "20s",
"archetype": "podcast-skeptic-host",
"voice": "Brittney",
"voice_id": "kPzsL2i3teMYv0FxEYQ6",
"default_shot": "brittney/shots/front.png",
"shots": ["front"],
"source": "reuse"
}
character.json schema (per character folder — match an existing one
verbatim; e.g. brittney/character.json):
{
"key": "brittney",
"name": "Brittney",
"gender": "f",
"ethnicity": "white",
"age_band": "20s",
"archetype": "podcast-skeptic-host",
"description": "...",
"source": "reuse",
"origin_anchor_path": "clients/ladder/ad-runs/run-02-podcast-skit/working/characters/brittney-base.png",
"generation_prompt": null,
"default_voice": { "name": "Brittney", "voice_id": "kPzsL2i3teMYv0FxEYQ6", "provider": "elevenlabs" },
"note": null,
"shots": [{"angle": "front", "path": "shots/front.png", "is_default": true}],
"default_shot": "shots/front.png"
}
Matching rules (per character in the source):
- Primary key —
voice_id. Search index.json for a row whose
voice_id equals the source character's voice_id. If exactly one match,
that row's key is the catalog id. Done.
- Fallback — name (case-insensitive). If voice_id didn't match, search
for a row whose
name equals the source character's name (case-insensitive).
If exactly one match, that row's key is the catalog id.
- No match → extend the library, then link. See below.
When a match is found, stamp catalog_id on BOTH the character row AND the
voices[] row that shares the same voice_id.
Extending the library (no-match path):
- Pick a key: lowercase the name, replace non-alphanumeric with
-, strip.
If the key already exists in index.json, append -2, -3, etc.
- Create
assets/character-library/<key>/shots/.
- Copy the source anchor PNG to
assets/character-library/<key>/shots/front.png. Materialize the
source PNG first if it's an LFS pointer (see step 1).
- Write
assets/character-library/<key>/character.json with the schema
above. Fill in what you can confidently derive — leave the rest null
rather than guessing:
key, name, gender, default_voice — from the source.
ethnicity, age_band, archetype, description — leave null
unless the run's HOW_TO.md or character description explicitly states
them.
source: "reuse" (we're pulling from an existing run, not generating
fresh).
origin_anchor_path: the source PNG's path relative to the
content-goose repo root (e.g.
clients/ladder/ad-runs/run-02-podcast-skit/working/characters/brittney-base.png).
generation_prompt: null.
note: null.
shots: [{"angle": "front", "path": "shots/front.png", "is_default": true}].
default_shot: "shots/front.png".
- Append a row to
assets/character-library/index.json matching that
character.json's outer fields. Bump total by 1. Keep characters[] in
the existing order — append at the end.
- Tell the user:
INDEX.md is hand-curated; refresh it manually or run the library indexer if there is one. Do NOT edit INDEX.md.
- Now stamp
catalog_id: "<new-key>" on the source-sample.json's character
row + the voice row sharing that voice_id.
Refuse to fabricate library fields. If you don't know a character's
ethnicity / age band / archetype, write null. A wrong guess pollutes
future remixes — the user prefers a null they can fill in over a
confident wrong value.
4. Write the output + summary
Default output path: <run-dir>/remix/source-sample.json (create the
remix/ folder if it doesn't exist; do not touch anything else in the run
folder).
Print a summary:
extracted source-sample at: <out path>
title: <title>
brand: <brand>
formatProfile: <profile>
recipe: <N> shots, <total_duration_sec>s
remix_spec: <S> skills (<measured|inferred>), <W> worlds, <C> chars, <V> voices
variant audit: <X> real / <Y> lfs-pointer / <Z> missing across <C> chars
production_scripts: <K> scripts ({voiceover, lipsync, stitch, end_card, …})
catalog links:
characters: her=brittney, him=brad
voices: kPzsL2i3teMYv0FxEYQ6=brittney, T4x5CtnhOiichhcqFzgg=brad
library extensions: <none | <key> (new)>
If any catalog link is null, surface that too — the user wants to know
what didn't link. If variant audit shows any LFS pointers or missing
PNGs, lead with that in the summary — it's the single biggest cost
multiplier for the downstream remix if missed.
Decision rules
- Agent-executed; no scripts. The run folders aren't perfectly
structured — adapt to what's actually present rather than imposing a
rigid extractor.
- Atoms only in
remix_spec.skills + skills_used. Drop molecule
slugs silently; surface a note if you couldn't recover at least one atom.
- Exactly one
selected: true voice. First voice in script order
unless the user passes a different selection.
- camelCase voice settings. Never emit
similarity_boost / use_speaker_boost.
catalog_id is null only when matching genuinely fails AND the
library-extension step also failed (e.g. no anchor PNG to seed
shots/front.png). Otherwise every character + voice should end up
linked.
- Materialize LFS pointers before reading binaries or copying them
into the library.
- Don't touch
INDEX.md. It's hand-curated; tell the user to refresh
it.
- Don't touch anything else in the run folder. This skill is read-only
on the source run, write-only on
<run-dir>/remix/source-sample.json +
the character library.
Failure modes
working/script.json missing → can't extract; ask the user where
the script is or refuse.
- No anchor PNG for a character → the character row's
anchor_image_url + anchor_asset_id + method stay null, AND library
extension can't proceed (no shots/front.png to copy). Stamp the row's
catalog_id: null and tell the user in the summary.
- Multiple library matches on voice_id → very rare; surface both keys
and ask which one to link to.
production/asset-manifest.json empty (common in older runs) →
fall back to the canonical podcast-skit atom list; flag in the summary
that skills were inferred rather than read.
- LFS pointer for an anchor PNG and
git lfs isn't installed or the
repo isn't an LFS clone → surface the error; don't copy the pointer
bytes into the library.
Output
<run-dir>/remix/source-sample.json — the upload-sample-shape JSON.
- Optionally, new folder(s) under
assets/character-library/<key>/ and
updated assets/character-library/index.json if any source character
wasn't in the library yet.
The output JSON is what the next agent step (script rewrite / character
swap) and remix-ad consume.
1---2name: extract-source-sample3description: Given the path to a finished content-goose ad-run folder, extract everything that defines that ad — recipe shot list, VO script, characters, voices, world, atom-skills, master mp4 — and emit a `source-sample.json` in the exact shape the `upload-ad-sample` skill writes to the Goose Ads library. Also links every character and voice to the central character library at `<repo-root>/assets/character-library/` (repo-root derived from the run-dir, not a hardcoded path), and if a character isn't in the library yet, adds it first then links. Use when the user wants to remix one of their existing ads — this skill produces the source JSON that the script-rewriting step and `remix-ad` consume.4---5
6# extract-source-sample
7
8This is an **agent-executed** skill. There are no Python scripts. The agent
9reads the run folder, builds the JSON, and stamps catalog links by hand. The
10content-goose run folders aren't always cleanly structured (some have empty
11production/ JSON, some carry everything in working/) — an agent adapts, a
12script would brittle out.
13
14## When to use
15
16- "Extract the source-sample.json for `<run>`."
17- "Get the upload-sample JSON for this ad so I can remix it."
18- "Prep `<run>` for remix."
19
20Do NOT use to:
21- Rewrite the script for a new brand (that's a separate agent step that
22 consumes this skill's output).
23- Render the remix (that's the existing `remix-ad` skill).
24- Upload an ad to the library (that's `upload-ad-sample`).
25
26## Inputs
27
28| Input | Required | Notes |
29|---|---|---|
30| `run-dir` | yes | Absolute path to a content-goose ad-run folder (e.g. `clients/ladder/ad-runs/run-02-podcast-skit`). |
31| `out` | no | Where to write the JSON. Default: `<run-dir>/remix/source-sample.json`. |
32
33That's the entire interface.
34
35## What the agent must do
36
37### 1. Read the run
38
39Open each file if it exists; tolerate missing files (most production/*.json
40in older runs are empty stubs — fall back to `working/`):
41
42- `working/script.json` — **primary source of truth** for scenes, voices, set.
43- `production/asset-manifest.json` — `assets[]` with role `active_master`
44 points at the master mp4; per-asset `provider` + `metadata.model` produce
45 the atom-skill rows.
46- `HOW_TO_MAKE_THIS_VIDEO.md` — gets dumped verbatim into `how_to`.
47- `video-project.json` — fallback for title / format when script.json doesn't
48 carry them.
49- `finals/*.mp4` — fallback for the master mp4 if asset-manifest is empty.
50- `working/characters/*.png` — anchor portraits per character.
51- `working/*.py` — driver scripts (`render_vo.py`, `render_variants.py`,
52 `render_clips.py`, `stitch.py`, `build_end_card.py`, etc.). These are the
53 source's runnable code; the remix consumer ports them. Capture in
54 `production_scripts[]` (step 2 below).
55
56**For sources with character-pose stills** (any run with a
57`working/characters/` folder of `<character>-<pose>.png` files —
58podcast-skit, founder-led, testimonial, recreate-ugc, etc.), audit every
59PNG with `file`. Do NOT stop at the base portraits. The recipe shot list
60references variant expression PNGs (e.g. `brittney-eyebrow-up.png`,
61`brad-phone-up.png`) by filename; the consumer assumes they exist on disk
62and will spend real money on lipsync calls before discovering they don't.
63
64For sources without character-pose stills (music-video b-roll, abstract
65animated, product-only) — skip this audit; `variant_assets[]` stays empty.
66
67Record per file (when auditing):
68
69```jsonc
70{
71 "file": "brittney-eyebrow-up.png",
72 "pose_tag": "skeptical-eyebrow", // slug from filename stem
73 "kind": "real" | "lfs-pointer" | "missing",
74 "size_bytes": 142336
75}
76```
77
78`file <path>` says `PNG image data, …` for real binaries and `ASCII text`
79for LFS pointers. A real binary is `>10KB` in practice; an LFS pointer is
80`<200 bytes`.
81
82**Materialize LFS pointers before reading any binary.** An LFS pointer is a
83tiny (<200 byte) ASCII file beginning with `version https://git-lfs.github.com`.
84If a PNG or mp4 looks like one, run:
85
86```bash
87cd <run-dir-or-repo-root>
88git lfs fetch --include=<relative path>
89git lfs checkout <relative path>
90```
91
92before referencing it. **If `git lfs pull` no-ops and the LFS endpoint
93returns 404** (objects committed as pointers but never pushed — common on
94content-goose), leave the entry as `kind: "lfs-pointer"` in
95`variant_assets[]`. The consumer will regenerate or scrape; this skill
96does NOT fabricate. See [[feedback_lfs_pointer_audit_before_paid_calls]]
97and [[feedback_fal_subscribe_error_envelope]] for the downstream cost when
98this audit is skipped — Hume run-03 lost ~$3 + 25 min to it.
99
100### 2. Build `source-sample.json`
101
102Shape (every key always present, arrays may be empty):
103
104```jsonc
105{
106 "title": "<from script.json or video-project.json>",
107 "format": "video",
108 "ratio": "<aspect_ratio from script.json — e.g. 9:16>",
109 "formatProfile": "podcast-skit-fabricated", // enum, see below
110 "media_url": "file://<abs path to master mp4>",
111 "thumbnail_url": null,
112 "brand": "<derive from path: clients/<brand>/ad-runs/...>",
113 "tags": [],
114 "recipe": { "shots": [...], "total_duration_sec": <int> },
115 "extracted_script": "HER: …\nHIM: …\n…",
116 "skills_used": ["generate-voiceover", "..."], // atoms only
117 "skills_source": "measured" | "derived-from-production-scripts" | "inferred-canonical" | "guessed",
118 "how_to": "<contents of HOW_TO_MAKE_THIS_VIDEO.md or null>",
119 "production_scripts": [
120 { "path": "working/render_vo.py", "role": "voiceover" },
121 { "path": "working/render_variants.py", "role": "stills" },
122 { "path": "working/render_clips.py", "role": "lipsync" },
123 { "path": "working/stitch.py", "role": "stitch" },
124 { "path": "working/build_end_card.py", "role": "end_card" }
125 ],
126 "remix_spec": {
127 "version": 1,
128 "skills": [{"slug": "...", "provider": "...", "model": "..."}],
129 "worlds": [{"key": "...", "name": "...", "set": "...", "lighting": null, "color_grade": null, "reference_image_url": null, "catalog_id": null}],
130 "characters": [
131 {
132 "key": "her",
133 "name": "Brittney",
134 "gender": "f",
135 "soul_id": null,
136 "anchor_asset_id": "asset-char-her-base-01",
137 "anchor_image_url": "file://...png",
138 "method": "anchor-ref",
139 "description": null,
140 "catalog_id": "brittney",
141 "variant_assets": [
142 { "file": "brittney-base.png", "pose_tag": "base", "kind": "real", "size_bytes": 1842336 },
143 { "file": "brittney-eyebrow-up.png", "pose_tag": "skeptical-eyebrow", "kind": "lfs-pointer", "size_bytes": 132 },
144 { "file": "brittney-shrug.png", "pose_tag": "shrug", "kind": "missing", "size_bytes": 0 }
145 ]
146 }
147 ],
148 "voices": [{"voice_id": "kPzsL2i3teMYv0FxEYQ6", "voice_name": "Brittney", "provider": "elevenlabs", "settings": {"stability": 0.45, "similarityBoost": 0.78, "style": 0.45, "useSpeakerBoost": true}, "selected": true, "catalog_id": "brittney"}]
149 }
150}
151```
152
153#### `formatProfile` — open string, drives downstream pipeline choices
154
155A short slug naming the source's ad format. **Open vocabulary** — the
156content-goose molecule library has ~40 distinct ad formats and growing;
157don't try to fit a closed enum. Pick a slug that matches the source's
158molecule name (e.g. `create-podcast-skit-ad` → `podcast-skit-fabricated`,
159`create-cinematic-music-video` → `music-video-sung`), or invent a new
160short slug when none fits.
161
162The consumer (`remix-script`, `remix-ad`) routes on the slug. Two routing
163properties downstream cares about — record them alongside the profile so
164the consumer doesn't have to re-derive:
165
166```jsonc
167"formatProfile": "podcast-skit-fabricated",
168"formatProfileProperties": {
169 "audioType": "spoken-vo" | "sung-music" | "mixed",
170 "sceneCount": "flexible" | "locked-to-source"
171}
172```
173
174- `audioType` drives the caption pipeline (spoken-vo → Whisper word-level;
175 sung-music → script.json scene windows, because Whisper returns `🎵 Music
176 Playing 🎵`; mixed → split per segment).
177- `sceneCount` drives whether the remix can flex (most spoken formats) or
178 must lock 1:1 (sung-music, anywhere lyric meter sets timing).
179
180If you can't confidently assign either property, leave it `null` — the
181consumer surfaces to the user rather than guessing.
182
183**Known slugs from past runs (extend as new formats appear):**
184
185| Slug | audioType | sceneCount | Origin |
186|---|---|---|---|
187| `podcast-skit-fabricated` | `spoken-vo` | `flexible` | Ladder run-02 (HER/HIM, 22 scenes, talking-head + broll) |
188| `music-video-sung` | `sung-music` | `locked-to-source` | Loóna run-01 (single VOCAL track, 14 lyric-locked beats) |
189
190The two known slugs are what real retros produced. Add new rows here when
191you extract a source that fits a new molecule (single-host-ugc, animated-
192explainer-villain, stop-motion-tabletop, goose-vs-tool, hook-variant, etc.).
193Don't pre-invent slugs that haven't shipped yet.
194
195Per-section derivation:
196
197- **`recipe.shots[]`** — one shot per scene in `script.json.scenes[]`:
198 `{ "id": "s01", "shot": "<still filename>", "type": "<shot type>", "speaker": "HER|HIM|null", "duration_sec": <parsed from `time` field e.g. "0:02-0:05"→3> }`.
199 Sum durations into `total_duration_sec`.
200
201 **Add `pose_tag` ONLY when the filename matches `<character>-<pose>.png`**
202 (the character-pose convention used by podcast-skit and other character-led
203 formats). Derive it by stripping the character prefix from the filename
204 stem (e.g. `brittney-eyebrow-up.png` → `pose_tag: "eyebrow-up"`,
205 `brad-phone-up.png` → `pose_tag: "phone-up"`). For formats whose shots
206 aren't keyed to character poses (music-video b-roll, product hyperframes,
207 abstract animated scenes), omit `pose_tag` from the shot.
208
209- **`extracted_script`** — concatenate `<who>: <text>` per scene, newlines
210 between.
211
212- **`remix_spec.worlds[0]`** — derive from `script.json.set_description`:
213 `{ key: <slug of the run's setting label>, name: <human label>, set: <full set_description>, lighting: null, color_grade: null, reference_image_url: null, catalog_id: null }`.
214
215- **`remix_spec.characters[]`** — one per voice role (HER, HIM, …) in
216 `script.json.voices`:
217 - `key`: `"her"` / `"him"` (lowercase role)
218 - `name`: `voices.<role>.name`
219 - `gender`: HER→`"f"`, HIM→`"m"`, NB→`"nb"`
220 - `soul_id`: null unless the run has a Higgsfield Soul anchor
221 - `anchor_asset_id`: `"asset-char-<key>-base-01"` if an anchor PNG exists, else null
222 - `anchor_image_url`: `file://` URL to `working/characters/<name>-base.png`
223 (or the first png matching the lowercase name), else null
224 - `method`: `"anchor-ref"` if anchor PNG present, else null
225 - `description`: null unless surfaced in the run's how-to
226 - `catalog_id`: stamped in step 3.
227 - **`variant_assets[]`**: emit ONLY when the source uses character-pose
228 stills (the `<character>-<pose>.png` filename convention). One entry
229 per PNG in `working/characters/` whose filename starts with the
230 character's lowercase name. Each entry `{file, pose_tag, kind:
231 real|lfs-pointer|missing, size_bytes}` — derived from the `file`
232 audit in step 1. **This is the canary the consumer needs to decide
233 whether to regenerate variants before paid lipsync calls.** Include
234 the base entry too (`pose_tag: "base"`). For formats without
235 per-character pose stills (music-video, animated, product-only),
236 set `variant_assets: []` or omit the key.
237
238- **`remix_spec.voices[]`** — one per voice in `script.json.voices`:
239 - `voice_id`, `voice_name` (from `name`), `provider: "elevenlabs"`
240 - `settings`: copy from script's `settings`, **rename to camelCase**
241 (`similarity_boost`→`similarityBoost`, `use_speaker_boost`→`useSpeakerBoost`)
242 - `selected`: `true` for the first voice in script order, `false` otherwise
243 — exactly one `selected: true`.
244 - `catalog_id`: stamped in step 3.
245
246- **`remix_spec.skills[]`** — derive atom rows from
247 `production/asset-manifest.json.assets[]`: each asset's `provider` +
248 `metadata.model` (+ `skill` or `metadata.skill` for the slug) produces one
249 row, deduped. **Drop molecule slugs** — only atoms allowed. The canonical
250 atom inventory is:
251 ```
252 generate-voiceover, generate-character-image, generate-broll-shot,
253 generate-lipsync, generate-music, compose-master, burn-captions,
254 add-captions-klap, render-hyperframe, stitch-clips, build-end-card
255 ```
256
257 **When the asset-manifest is empty (common in older runs), derive atoms
258 generically — don't hard-code per-format recipes.** The repo has ~40
259 ad-format molecules and growing; canonical recipes drift fast. Use this
260 cascade:
261
262 1. **Read `production_scripts[]` (next section) — each driver script's
263 actual provider calls are authoritative.** Open `render_vo.py`, grep
264 for `elevenlabs`/`fal`/`higgsfield` imports + endpoint URLs, and
265 derive one atom row per provider × model the script actually invokes.
266 This is more reliable than any guessed recipe because it reflects
267 what the source ACTUALLY did, not what the format usually does.
268
269 2. **If `production_scripts[]` is also empty**, fall back to the
270 `podcast-skit-fabricated` canonical recipe ONLY when
271 `formatProfile === "podcast-skit-fabricated"` — the one format with
272 enough run data to canonicalize:
273 ```jsonc
274 [
275 {"slug": "generate-voiceover", "provider": "elevenlabs", "model": null},
276 {"slug": "generate-character-image", "provider": "higgsfield", "model": null},
277 {"slug": "generate-lipsync", "provider": "fal", "model": null},
278 {"slug": "compose-master", "provider": "ffmpeg", "model": null},
279 {"slug": "burn-captions", "provider": "ffmpeg", "model": "libass"}
280 ]
281 ```
282 Note `model: null` — model ids drift (eleven_multilingual_v2 →
283 eleven_multilingual_v3, veed/fabric-1.0 → veed/fabric-2.0); don't
284 freeze them in the SKILL.
285
286 3. **For any other formatProfile with neither asset-manifest nor
287 production_scripts**, surface to the user and ask which atoms ran.
288 Don't invent — `skills_source: "guessed"` is worse than `null`.
289
290 Mirror `skills_used` as the flat slug list.
291
292- **`skills_source`** — top-level field recording how the atom list was
293 obtained, so the consumer knows how much to trust it:
294 - `"measured"` — derived from a populated `asset-manifest.json` (cascade
295 step would have used the real provider/model fields).
296 - `"derived-from-production-scripts"` — grepped from the source's
297 `working/*.py` driver scripts (cascade step 1). Reliable: reflects
298 actual API calls.
299 - `"inferred-canonical"` — fell back to the canonical podcast-skit
300 recipe (cascade step 2). Only valid when `formatProfile ===
301 "podcast-skit-fabricated"`.
302 - `"guessed"` — none of the above worked and the user supplied the
303 list. Should be rare; surface in the summary.
304
305 **The consumer reads this field.** Without it, a guessed atom list
306 propagates downstream as if it were measured (Ladder extract retro
307 flagged this — skills_source=inferred quietly made it into Hume's
308 remix-plan as fact). When `skills_source !== "measured"`, the consumer
309 should cross-check against `production_scripts[]` before trusting any
310 individual row.
311
312- **`production_scripts[]`** — list every `working/*.py` file in the run
313 with `{path, role}`. Roles: `voiceover | stills | variants | lipsync |
314 stitch | end_card | music | composites | other`. Match by filename:
315
316 | Filename | Role |
317 |---|---|
318 | `render_vo.py`, `gen_vo.py` | voiceover |
319 | `render_keyframes.py`, `gen_keyframes.py` | stills |
320 | `render_variants.py` | variants |
321 | `render_clips.py` | lipsync |
322 | `stitch.py`, `compose.py`, `compose_master.py` | stitch |
323 | `build_end_card.py` | end_card |
324 | `gen_music.py` | music |
325 | `burn_captions.py`, `make_subtitles.py` | (none — these are atom-level scripts) |
326 | `build_composites.py` | composites |
327 | anything else | other |
328
329 These are the source's runnable code. The consumer ports them as the
330 starting template — molecule SKILL.mds are recipes, not executables.
331
332### 3. Link characters + voices to the character library — and add any that are missing
333
334Library location: **`<repo-root>/assets/character-library/`**, where `<repo-root>`
335is the content-goose checkout that contains the `run-dir` (derive it by walking up
336from `run-dir` to the directory that holds `assets/` — do **not** hardcode an
337absolute machine path, and never write outside this repo).
338Layout:
339```
340character-library/
341├── index.json ← machine-readable catalog
342├── INDEX.md ← human-readable table (hand-curated)
343└── <key>/
344 ├── character.json
345 └── shots/
346 └── front.png
347```
348
349`index.json` schema (one row per character):
350```jsonc
351{
352 "key": "brittney",
353 "name": "Brittney",
354 "gender": "f",
355 "ethnicity": "white",
356 "age_band": "20s",
357 "archetype": "podcast-skeptic-host",
358 "voice": "Brittney",
359 "voice_id": "kPzsL2i3teMYv0FxEYQ6",
360 "default_shot": "brittney/shots/front.png",
361 "shots": ["front"],
362 "source": "reuse"
363}
364```
365
366`character.json` schema (per character folder — match an existing one
367verbatim; e.g. `brittney/character.json`):
368```jsonc
369{
370 "key": "brittney",
371 "name": "Brittney",
372 "gender": "f",
373 "ethnicity": "white",
374 "age_band": "20s",
375 "archetype": "podcast-skeptic-host",
376 "description": "...",
377 "source": "reuse",
378 "origin_anchor_path": "clients/ladder/ad-runs/run-02-podcast-skit/working/characters/brittney-base.png",
379 "generation_prompt": null,
380 "default_voice": { "name": "Brittney", "voice_id": "kPzsL2i3teMYv0FxEYQ6", "provider": "elevenlabs" },
381 "note": null,
382 "shots": [{"angle": "front", "path": "shots/front.png", "is_default": true}],
383 "default_shot": "shots/front.png"
384}
385```
386
387**Matching rules** (per character in the source):
388
3891. **Primary key — `voice_id`.** Search `index.json` for a row whose
390 `voice_id` equals the source character's voice_id. If exactly one match,
391 that row's `key` is the catalog id. Done.
3922. **Fallback — name (case-insensitive).** If voice_id didn't match, search
393 for a row whose `name` equals the source character's name (case-insensitive).
394 If exactly one match, that row's `key` is the catalog id.
3953. **No match → extend the library, then link.** See below.
396
397When a match is found, stamp `catalog_id` on BOTH the character row AND the
398voices[] row that shares the same `voice_id`.
399
400**Extending the library (no-match path):**
401
4021. Pick a key: lowercase the name, replace non-alphanumeric with `-`, strip.
403 If the key already exists in `index.json`, append `-2`, `-3`, etc.
4042. Create `assets/character-library/<key>/shots/`.
4053. Copy the source anchor PNG to
406 `assets/character-library/<key>/shots/front.png`. **Materialize the
407 source PNG first if it's an LFS pointer** (see step 1).
4084. Write `assets/character-library/<key>/character.json` with the schema
409 above. Fill in what you can confidently derive — leave the rest `null`
410 rather than guessing:
411 - `key`, `name`, `gender`, `default_voice` — from the source.
412 - `ethnicity`, `age_band`, `archetype`, `description` — leave `null`
413 unless the run's `HOW_TO.md` or character description explicitly states
414 them.
415 - `source`: `"reuse"` (we're pulling from an existing run, not generating
416 fresh).
417 - `origin_anchor_path`: the source PNG's path **relative to the
418 content-goose repo root** (e.g.
419 `clients/ladder/ad-runs/run-02-podcast-skit/working/characters/brittney-base.png`).
420 - `generation_prompt`: `null`.
421 - `note`: `null`.
422 - `shots`: `[{"angle": "front", "path": "shots/front.png", "is_default": true}]`.
423 - `default_shot`: `"shots/front.png"`.
4245. Append a row to `assets/character-library/index.json` matching that
425 character.json's outer fields. Bump `total` by 1. Keep `characters[]` in
426 the existing order — append at the end.
4276. Tell the user: `INDEX.md is hand-curated; refresh it manually or run the
428 library indexer if there is one.` Do NOT edit `INDEX.md`.
4297. Now stamp `catalog_id: "<new-key>"` on the source-sample.json's character
430 row + the voice row sharing that voice_id.
431
432> **Refuse to fabricate library fields.** If you don't know a character's
433> ethnicity / age band / archetype, write `null`. A wrong guess pollutes
434> future remixes — the user prefers a null they can fill in over a
435> confident wrong value.
436
437### 4. Write the output + summary
438
439Default output path: `<run-dir>/remix/source-sample.json` (create the
440`remix/` folder if it doesn't exist; do not touch anything else in the run
441folder).
442
443Print a summary:
444```
445extracted source-sample at: <out path>
446 title: <title>
447 brand: <brand>
448 formatProfile: <profile>
449 recipe: <N> shots, <total_duration_sec>s
450 remix_spec: <S> skills (<measured|inferred>), <W> worlds, <C> chars, <V> voices
451 variant audit: <X> real / <Y> lfs-pointer / <Z> missing across <C> chars
452 production_scripts: <K> scripts ({voiceover, lipsync, stitch, end_card, …})
453 catalog links:
454 characters: her=brittney, him=brad
455 voices: kPzsL2i3teMYv0FxEYQ6=brittney, T4x5CtnhOiichhcqFzgg=brad
456 library extensions: <none | <key> (new)>
457```
458
459If any catalog link is `null`, surface that too — the user wants to know
460what didn't link. **If `variant audit` shows any LFS pointers or missing
461PNGs, lead with that in the summary** — it's the single biggest cost
462multiplier for the downstream remix if missed.
463
464## Decision rules
465
466- **Agent-executed; no scripts.** The run folders aren't perfectly
467 structured — adapt to what's actually present rather than imposing a
468 rigid extractor.
469- **Atoms only in `remix_spec.skills` + `skills_used`.** Drop molecule
470 slugs silently; surface a note if you couldn't recover at least one atom.
471- **Exactly one `selected: true` voice.** First voice in script order
472 unless the user passes a different selection.
473- **camelCase voice settings.** Never emit `similarity_boost` / `use_speaker_boost`.
474- **`catalog_id` is null only when matching genuinely fails AND the
475 library-extension step also failed** (e.g. no anchor PNG to seed
476 `shots/front.png`). Otherwise every character + voice should end up
477 linked.
478- **Materialize LFS pointers** before reading binaries or copying them
479 into the library.
480- **Don't touch `INDEX.md`.** It's hand-curated; tell the user to refresh
481 it.
482- **Don't touch anything else in the run folder.** This skill is read-only
483 on the source run, write-only on `<run-dir>/remix/source-sample.json` +
484 the character library.
485
486## Failure modes
487
488- **`working/script.json` missing** → can't extract; ask the user where
489 the script is or refuse.
490- **No anchor PNG for a character** → the character row's
491 `anchor_image_url` + `anchor_asset_id` + `method` stay null, AND library
492 extension can't proceed (no `shots/front.png` to copy). Stamp the row's
493 `catalog_id: null` and tell the user in the summary.
494- **Multiple library matches on voice_id** → very rare; surface both keys
495 and ask which one to link to.
496- **`production/asset-manifest.json` empty (common in older runs)** →
497 fall back to the canonical podcast-skit atom list; flag in the summary
498 that skills were inferred rather than read.
499- **LFS pointer for an anchor PNG and `git lfs` isn't installed or the
500 repo isn't an LFS clone** → surface the error; don't copy the pointer
501 bytes into the library.
502
503## Output
504
505- `<run-dir>/remix/source-sample.json` — the upload-sample-shape JSON.
506- Optionally, new folder(s) under `assets/character-library/<key>/` and
507 updated `assets/character-library/index.json` if any source character
508 wasn't in the library yet.
509
510The output JSON is what the next agent step (script rewrite / character
511swap) and `remix-ad` consume.