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 `/Users/akhil/projects/content-goose/assets/character-library/`, 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.
npx gooseworks install --claude # Then in your agent: /gooseworks <prompt> --skill 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.
<run>."<run> for remix."Do NOT use to:
remix-ad skill).upload-ad-sample).| 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.
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.
source-sample.jsonShape (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 choicesA 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>.namegender: HER→"f", HIM→"m", NB→"nb"soul_id: null unless the run has a Higgsfield Soul anchoranchor_asset_id: "asset-char-<key>-base-01" if an anchor PNG exists, else nullanchor_image_url: file:// URL to working/characters/<name>-base.png
(or the first png matching the lowercase name), else nullmethod: "anchor-ref" if anchor PNG present, else nulldescription: null unless surfaced in the run's how-tocatalog_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-cardWhen 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.
Library location: /Users/akhil/projects/content-goose/assets/character-library/.
Layout:
character-library/
├── index.json ← machine-readable catalog
├── INDEX.md ← human-readable table (hand-curated)
└── <key>/
├── character.json
└── shots/
└── front.pngindex.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):
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.name equals the source character's name (case-insensitive).
If exactly one match, that row's key is the catalog id.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):
-, strip.
If the key already exists in index.json, append -2, -3, etc.assets/character-library/<key>/shots/.assets/character-library/<key>/shots/front.png. Materialize the
source PNG first if it's an LFS pointer (see step 1).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".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.INDEX.md is hand-curated; refresh it manually or run the library indexer if there is one. Do NOT edit INDEX.md.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.
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.
remix_spec.skills + skills_used. Drop molecule
slugs silently; surface a note if you couldn't recover at least one atom.selected: true voice. First voice in script order
unless the user passes a different selection.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.INDEX.md. It's hand-curated; tell the user to refresh
it.<run-dir>/remix/source-sample.json +
the character library.working/script.json missing → can't extract; ask the user where
the script is or refuse.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.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.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.<run-dir>/remix/source-sample.json — the upload-sample-shape JSON.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.
<run>."<run> for remix."remix-ad skill).QC gate for a generated static ad image — verify the file opens, matches the requested dimensions, shows the correct product/subject (right shape, colour, label, logo), and has no garbled text or severe artifacts. Records pass/fail/needs-human in verification.md. Used as the final check in the static ad remix flow before shipping.
Recreate a static graphic ad (Pinterest pin, IG/FB feed image, poster) from a reference image, swapping in a new brand's product and new copy while keeping the reference's layout, composition, and visual energy. ALWAYS generated with GPT Image 2 in edit-the-reference mode (fal-ai/gpt-image-1/edit-image, a billed FAL generation); the HTML/goose-graphics overlay is only an optional text-finishing step, never the generator. The static-graphics counterpart to the video remix-ad skill; this is what the app calls when a user picks a reference ad and wants it for their own product.
Generate a single photoreal or designed image with OpenAI gpt-image via fal.ai. Supports gpt-image-1 (default, fixed sizes — the FAL fallback for Higgsfield's `gpt_image_2`) and gpt-image-2 (`openai/gpt-image-2`, custom output sizes up to 3840px). Routes to text-to-image or the edit variant depending on whether a reference image is provided. Use for photoreal character anchors, scene keyframes, and designed sheets (e.g. storyboards) where precise layout and legible text matter.