render-absurdist-explainer
Assemble an absurdist animated-explainer video ad (~38s, 9:16) from per-scene i2v clips + their measured VO windows — retime each clip to its VO, re-encode every segment to identical 30fps/libx264/yuv420p so the concat demuxer never drops frames, concat, build a REAL-product PIL
Install
npx skills add https://github.com/gooseworks-ai/goose-skills/tree/main/skills/ads/capabilities/render-absurdist-explainer
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install gooseworks-ai-goose-skills@llmmart
git clone https://github.com/gooseworks-ai/goose-skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole gooseworks-ai/goose-skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
render-absurdist-explainer
The free, deterministic renderer for the absurdist-explainer video ad format — the bright Pixar/Disney 3D spot where a personified villain (the problem) narrates the whole ad in one voice, teaches the product's ownable mechanism through cartoon biology, lists the damage, then watches its own scheme collapse when the product arrives. This capability is the FREE assembly stage only. All generative work (nano-banana keyframes, Seedance i2v clips, ElevenLabs VO + music) happens upstream in the recipe and is handed to this capability as files.
It ports the validated compose recipe from two reference runs (HUM "Big Chill" cortisol absurdism and Soteri "Eczema, the pH villain"). The recipe is deterministic — iterate the cut for free, re-roll only the offending paid beat.
What it does (the deterministic recipe)
- Per-scene retime. Each i2v clip is retimed to its measured VO window
(
scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,fps=30,setsar=1, thentpad=stop_mode=cloneif the VO is longer than the clip, else-ttrim). - Identical re-encode. Every segment is re-encoded
libx264 -crf 18 -pix_fmt yuv420p -r 30even if already correct — a framerate mismatch makes the concat demuxer silently drop frames. - Concat all scene segments + the end card via the concat demuxer (
-c copy). - Real-product end card.
build_endcard.pycomposites the REAL retail product photo over the brand palette (flat, or sampled from the photo's own edge pixel) with a typeset wordmark + claim rows + CTA pill in PILImageDraw.text— never an AI cartoon bottle, never AI-rendered brand text.compose.pyKen-Burnses it 1.00 → 1.04 over the dwell. - Mix. VO bus
loudnorm I=-14 TP=-1.5, music busloudnorm I=-26 TP=-3thenvolume=0.62,amix inputs=2 duration=first normalize=0→ master lands at -14.5..-13.5 LUFS with the music ducked under the VO. - Captions last.
make_captions.pyemits a libass.ass(one cue per scene, Arial 64 white / 6px outline / MarginV=330,start = scene_start + 0.08s, suppressed on the end card).compose.pyburns it as the final filter so captions sit on top.
Scripts (free — Python + ffmpeg + PIL, no bash, no paid calls)
scripts/build_endcard.py— PIL composite of the real product photo + typeset brand layer (wordmark / product line / claim rows / accent CTA pill). Reads the sameconfig.json. Run this FIRST soend_card.imageexists beforecompose.py.scripts/make_captions.py— emits the per-scene libass.assfrom the SAME scene table compose reads, so caption windows stay in lockstep with the cut. Run beforecompose.py(or pointconfig.captions_assat nothing to skip captions).scripts/compose.py— the assembler: per-scene retime + identical 30fps re-encode → concat → Ken-Burns end card → VO/music loudnorm mix → burn captions → master mp4.scripts/config.example.json— the shape of theconfigthe recipe binds (the brand-neutralised Soteri values as a worked reference).
Inputs (all via --config + a runtime work dir — NO hardcoded paths)
config.json carries: scenes[] (each {id, clip, target_sec, vo, caption, atempo?}
where target_sec is the measured VO window), end_card{product_image, image, dwell_sec, zoom_to, wordmark, product_line, claims[], cta, background?}, brand_palette {primary, primary_lite, accent, grey}, music_bed, music_volume (default 0.62),
atempo (compose-stage VO speed-up, default off; the reference runs used 1.3 when the VO
read slow), captions_ass, and caption_style. See config.example.json.
Craft rules (load-bearing — faithful to the source molecule)
- The end card is the REAL product photo, composited — never an AI cartoon bottle. Both reference runs shipped an AI bottle first and had to re-shoot with the real photo.
- No AI-rendered brand text anywhere. Wordmark, claims, CTA, motif — all PIL
ImageDraw.text. AI draws the world + characters only. - Re-encode every segment to 30fps before concat, even if already correct, or the concat demuxer silently drops frames.
target_secis the MEASURED VO duration (ffprobe each VO mp3), never a planned word count — VO drives the per-scene timing.- Mix constants are validated — VO -14 LUFS, music -26 LUFS then
volume≈0.62(Soteri) to0.70(Big Chill),amix normalize=0. Master target -14.5..-13.5 LUFS, true-peak ≤ -1.5 dBFS. - Caption
start = scene_start + 0.08s, suppressed on the end card (its typeset copy carries the message — two text layers at one spot are both unreadable).
Requires
watch (QC the final master — confirm the villain silhouette holds, the single voice
carries the whole spot, the motif lands ≥3×, no AI brand text leaked into a cartoon
background, the end card is the real product, and duration is within ±0.1s of the summed
windows). The recipe gates the paid create-image-fal (keyframes), create-video-fal
(Seedance i2v), create-vo-elevenlabs, and create-music-elevenlabs calls to their own
capabilities — this capability itself makes NO paid calls.
Files (goose-skills)
-
scripts
-
build_endcard.py 4.4 KB
#!/usr/bin/env python3 """build_endcard.py — the REAL-product end card (1080x1920), composited in PIL. Ports the validated end-card recipe from the Soteri "pH villain" run (generated/endcard/build_endcard.py). The end card is ALWAYS a composite of the real retail product photo — NEVER an AI-rendered cartoon bottle (both reference runs shipped an AI bottle first and had to re-shoot with the real photo). ALL brand text is typeset here with PIL ImageDraw.text — never AI-rendered. Layout (top -> bottom): - background: the brand's primary palette colour, OR (default) sampled from the product photo's own edge pixel for a seamless paste. - the real product photo, scaled to ~1015px tall, centred, offset y=88. - brand wordmark (large, brand primary colour). - product line (medium). - claim rows (small, grey). - a CTA pill (rounded-rect in the brand accent colour, white text). Reads a config.json; writes endcard.png into --out (or the config's end_card.image). """ import argparse, json, os from PIL import Image, ImageDraw, ImageFont W, H = 1080, 1920 # Portable font fallback chain: DejaVu (ships with Pillow / most Linux), then macOS # Arial, then Pillow's built-in. Bold + regular variants each. _BOLD_CANDS = [ "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", "/System/Library/Fonts/Supplemental/Arial Bold.ttf", "/Library/Fonts/Arial Bold.ttf", "DejaVuSans-Bold.ttf", ] _REG_CANDS = [ "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", "/System/Library/Fonts/Supplemental/Arial.ttf", "/Library/Fonts/Arial.ttf", "DejaVuSans.ttf", ] def font(bold, size): for c in (_BOLD_CANDS if bold else _REG_CANDS): try: return ImageFont.truetype(c, size) except OSError: continue return ImageFont.load_default() def _hex(s, default=(0, 0, 0)): if not s: return default s = s.lstrip("#") if len(s) == 3: s = "".join(ch * 2 for ch in s) try: return tuple(int(s[i:i + 2], 16) for i in (0, 2, 4)) except ValueError: return default def main(): ap = argparse.ArgumentParser(description="Build the real-product end card PNG.") ap.add_argument("--config", required=True) ap.add_argument("--out", help="output PNG path (defaults to config.end_card.image)") a = ap.parse_args() cfg = json.load(open(a.config)) ec = cfg["end_card"] palette = cfg.get("brand_palette", {}) primary = _hex(palette.get("primary"), (46, 111, 94)) primary_lite = _hex(palette.get("primary_lite") or palette.get("primary"), primary) accent = _hex(palette.get("accent"), (232, 103, 76)) grey = _hex(palette.get("grey"), (107, 111, 105)) white = (255, 255, 255) prod = Image.open(ec["product_image"]).convert("RGB") # background: explicit brand bg, else sample the product photo's own edge pixel if ec.get("background"): bg = _hex(ec["background"], (255, 255, 255)) else: bg = prod.getpixel((6, 6)) img = Image.new("RGB", (W, H), bg) d = ImageDraw.Draw(img) # product photo, scaled to height ~1015, centred, offset y=88 ph = int(ec.get("product_height", 1015)) pw = int(prod.width * ph / prod.height) img.paste(prod.resize((pw, ph), Image.LANCZOS), ((W - pw) // 2, int(ec.get("product_y", 88)))) def line(y, text, fnt, fill): d.text((W // 2, y), text, font=fnt, fill=fill, anchor="ma") # typeset copy block y = int(ec.get("copy_y", 1190)) line(y, ec["wordmark"], font(True, 100), primary); y += 128 if ec.get("product_line"): line(y, ec["product_line"], font(True, 46), primary_lite); y += 96 for claim in ec.get("claims", []): line(y, claim, font(False, 35), grey); y += 56 y += 36 # CTA pill (rounded-rect in the accent colour, white text) cta = ec.get("cta") if cta: cf = font(True, 43) bb = d.textbbox((0, 0), cta, font=cf) cw, chh = bb[2] - bb[0], bb[3] - bb[1] padx, pady = 50, 30 pw2, ph2 = cw + 2 * padx, chh + 2 * pady px = (W - pw2) // 2 d.rounded_rectangle([px, y, px + pw2, y + ph2], radius=ph2 // 2, fill=accent) d.text((W // 2, y + ph2 // 2), cta, font=cf, fill=white, anchor="mm") out = a.out or ec["image"] os.makedirs(os.path.dirname(os.path.abspath(out)), exist_ok=True) img.save(out) print("wrote", out, img.size) if __name__ == "__main__": main() -
compose.py 11.1 KB
#!/usr/bin/env python3 """compose.py — the deterministic FREE assembler for the absurdist-explainer ad. Ports the validated compose recipe from the two reference runs (HUM "Big Chill" and Soteri "Eczema, the pH villain"). Given the per-scene i2v clips + the per-scene VO windows + the VO track + the music bed + a built end-card PNG + a caption .ass file, it renders the master mp4: 1. Per-scene retime — each clip is retimed to its MEASURED VO window: scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,fps=30,setsar=1 then tpad=stop_mode=clone (if the VO is longer than the clip) else -t (trim). Every segment is RE-ENCODED to identical libx264/crf18/yuv420p/30fps so the concat demuxer never silently drops frames on a framerate mismatch. 2. End card — the pre-built endcard.png (real product composite, see build_endcard.py) is Ken-Burnsed (slow 1.00 -> 1.04 zoom) over its dwell window and appended as the final scene. 3. Concat — all segments concatenated via the concat demuxer (-c copy). 4. VO track — each VO cue is (optionally) atempo-compressed, padded, clamped to its window, and concatenated into one wav. 5. Music bed — fit to the total runtime with a fade in/out tail. 6. Mix — VO bus loudnorm I=-14 TP=-1.5, music bus loudnorm I=-26 TP=-3 then volume (default 0.62), amix inputs=2 duration=first normalize=0. This lands the master at -14.5..-13.5 LUFS with the music ducked under the VO. 7. Caption burn — the libass .ass is burned LAST so captions sit on top of the video, then muxed with the mix into the master mp4. This capability makes NO paid calls. All inputs come via --config + the work dir; the recipe (the paid orchestration: keyframes / clips / VO / music) hands them off. """ import argparse, json, os, subprocess, sys, tempfile # ---- canvas / encode constants (validated on both reference runs) ---- W, H = 1080, 1920 FPS = 30 CRF_SEG = 18 # per-scene segment encode CRF_MASTER = 19 # final burn+mux encode PRESET = "medium" # ---- audio mix constants (validated) ---- VO_LOUDNORM = "loudnorm=I=-14:TP=-1.5:LRA=11" MUSIC_LOUDNORM = "loudnorm=I=-26:TP=-3:LRA=11" MUSIC_VOLUME_DEFAULT = 0.62 # Soteri 0.62 / Big Chill 0.70 FADE_OUT_TAIL = 1.4 # music out-fade length FADE_IN = 0.6 # music in-fade length def run(cmd, quiet=True): r = subprocess.run(cmd, capture_output=True, text=True) if r.returncode: sys.stderr.write((r.stderr or "")[-2000:] + "\n") sys.exit(f"FAILED: {' '.join(str(c) for c in cmd[:6])} ...") return r def ffprobe_dur(path): r = subprocess.run( ["ffprobe", "-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", path], capture_output=True, text=True) return float(r.stdout.strip()) def main(): ap = argparse.ArgumentParser(description="Compose the absurdist-explainer master.") ap.add_argument("--config", required=True, help="path to config.json (see config.example.json)") ap.add_argument("--work-dir", required=True, help="scratch dir for intermediates (created if missing)") ap.add_argument("--out", required=True, help="output master mp4 path") a = ap.parse_args() cfg = json.load(open(a.config)) work = a.work_dir seg_dir = os.path.join(work, "_work") os.makedirs(seg_dir, exist_ok=True) scenes = cfg["scenes"] # [{id, clip, target_sec, vo, atempo?}, ...] endcard = cfg["end_card"] # {image, dwell_sec, zoom_to?} music_bed = cfg.get("music_bed") # path or None music_volume = float(cfg.get("music_volume", MUSIC_VOLUME_DEFAULT)) captions_ass = cfg.get("captions_ass") # path to pre-built .ass, or None global_atempo = cfg.get("atempo") # default compose-stage atempo for all VO cues # ------------------------------------------------------------------- # 1. per-scene video segments (retime -> identical 30fps encode) # ------------------------------------------------------------------- concat = os.path.join(seg_dir, "concat.txt") with open(concat, "w") as cf: for s in scenes: n = s["id"] clip = s["clip"] tgt = float(s["target_sec"]) seg = os.path.join(seg_dir, f"seg-{n}.mp4") src_dur = ffprobe_dur(clip) vf = "scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,fps=30,setsar=1" pad = tgt - src_dur if pad > 0.05: vf += f",tpad=stop_mode=clone:stop_duration={pad:.3f}" run(["ffmpeg", "-y", "-loglevel", "error", "-i", clip, "-vf", vf, "-t", f"{tgt:.3f}", "-c:v", "libx264", "-preset", PRESET, "-crf", str(CRF_SEG), "-pix_fmt", "yuv420p", "-r", str(FPS), "-an", seg]) cf.write(f"file 'seg-{n}.mp4'\n") print(f" scene-{n} clip {src_dur:.2f}s -> {tgt:.2f}s") # end card: Ken-Burns the real-product PIL composite (never AI) ec_img = endcard["image"] ec_dwell = float(endcard.get("dwell_sec", 4.0)) zoom_to = float(endcard.get("zoom_to", 1.04)) frames = int(round(ec_dwell * FPS)) ec_seg = os.path.join(seg_dir, "seg-endcard.mp4") # slow continuous 1.00 -> zoom_to over the dwell. Feed a SINGLE image frame # (-loop 1 -frames:v 1 into the graph via zoompan d=<frames>) so zoompan emits # exactly `frames` output frames — the whole-clip Ken-Burns. -t clamps the output. zstep = (zoom_to - 1.0) / max(frames, 1) run(["ffmpeg", "-y", "-loglevel", "error", "-loop", "1", "-i", ec_img, "-vf", (f"scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,setsar=1," f"zoompan=z='min(zoom+{zstep:.6f}\\,{zoom_to})':d={frames}:" f"x='iw/2-(iw/zoom/2)':y='ih/2-(ih/zoom/2)':s={W}x{H}:fps={FPS}"), "-t", f"{ec_dwell:.3f}", "-c:v", "libx264", "-preset", PRESET, "-crf", str(CRF_SEG), "-pix_fmt", "yuv420p", "-r", str(FPS), "-an", ec_seg]) cf.write("file 'seg-endcard.mp4'\n") print(f" end-card {ec_dwell:.2f}s zoom->{zoom_to}") # ------------------------------------------------------------------- # 2. concat video (all segments are 30fps -> no silent frame drops) # ------------------------------------------------------------------- video = os.path.join(seg_dir, "video.mp4") run(["ffmpeg", "-y", "-loglevel", "error", "-f", "concat", "-safe", "0", "-i", concat, "-c", "copy", video]) # ------------------------------------------------------------------- # 3. VO track (atempo optional, padded + clamped per scene, concatenated) # ------------------------------------------------------------------- voconcat = os.path.join(seg_dir, "voconcat.txt") with open(voconcat, "w") as vf: for s in scenes: n = s["id"] tgt = float(s["target_sec"]) vo = s.get("vo") wav = os.path.join(seg_dir, f"vo-{n}.wav") atempo = s.get("atempo", global_atempo) if vo and os.path.exists(vo): af = [] if atempo: af.append(f"atempo={atempo}") af.append("apad") run(["ffmpeg", "-y", "-loglevel", "error", "-i", vo, "-af", ",".join(af), "-t", f"{tgt:.3f}", "-ar", "44100", "-ac", "2", wav]) else: # no VO for this scene -> silence for the window run(["ffmpeg", "-y", "-loglevel", "error", "-f", "lavfi", "-i", "anullsrc=channel_layout=stereo:sample_rate=44100", "-t", f"{tgt:.3f}", wav]) vf.write(f"file 'vo-{n}.wav'\n") # end-card window: silence so the audio spans the full video (the end card has no # VO). Without this, -shortest would truncate the master and drop the end card. ec_wav = os.path.join(seg_dir, "vo-endcard.wav") run(["ffmpeg", "-y", "-loglevel", "error", "-f", "lavfi", "-i", "anullsrc=channel_layout=stereo:sample_rate=44100", "-t", f"{float(endcard.get('dwell_sec', 4.0)):.3f}", ec_wav]) vf.write("file 'vo-endcard.wav'\n") vo_track = os.path.join(seg_dir, "vo-track.wav") run(["ffmpeg", "-y", "-loglevel", "error", "-f", "concat", "-safe", "0", "-i", voconcat, "-c", "copy", vo_track]) total = ffprobe_dur(vo_track) print(f" total runtime: {total:.2f}s") # ------------------------------------------------------------------- # 4. + 5. music bed (fit + fade) and mix # ------------------------------------------------------------------- mix = os.path.join(seg_dir, "mix.wav") if music_bed and os.path.exists(music_bed): music = os.path.join(seg_dir, "music.wav") fade_out_st = max(total - FADE_OUT_TAIL, 0.0) run(["ffmpeg", "-y", "-loglevel", "error", "-i", music_bed, "-af", f"afade=t=in:st=0:d={FADE_IN},afade=t=out:st={fade_out_st:.3f}:d={FADE_OUT_TAIL}", "-t", f"{total:.3f}", "-ar", "44100", "-ac", "2", music]) run(["ffmpeg", "-y", "-loglevel", "error", "-i", vo_track, "-i", music, "-filter_complex", f"[0:a]{VO_LOUDNORM}[vo];" f"[1:a]{MUSIC_LOUDNORM},volume={music_volume}[mus];" f"[vo][mus]amix=inputs=2:duration=first:dropout_transition=0:normalize=0[a]", "-map", "[a]", "-ar", "44100", "-ac", "2", mix]) else: # VO only — still loudnorm to the -14 LUFS target run(["ffmpeg", "-y", "-loglevel", "error", "-i", vo_track, "-af", VO_LOUDNORM, "-ar", "44100", "-ac", "2", mix]) # ------------------------------------------------------------------- # 6. burn captions LAST + mux -> master # ------------------------------------------------------------------- burn_in = video if captions_ass and os.path.exists(captions_ass): # ass= filter needs an escaped path; use a work-relative copy to dodge colons/spaces run(["ffmpeg", "-y", "-loglevel", "error", "-i", video, "-i", mix, "-vf", f"ass={_ass_escape(captions_ass)}", "-map", "0:v", "-map", "1:a", "-c:v", "libx264", "-preset", PRESET, "-crf", str(CRF_MASTER), "-pix_fmt", "yuv420p", "-r", str(FPS), "-c:a", "aac", "-b:a", "192k", "-shortest", a.out]) else: run(["ffmpeg", "-y", "-loglevel", "error", "-i", burn_in, "-i", mix, "-map", "0:v", "-map", "1:a", "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-shortest", a.out]) md = ffprobe_dur(a.out) expected = sum(float(s["target_sec"]) for s in scenes) + float(endcard.get("dwell_sec", 4.0)) print(f"WROTE {a.out} {md:.2f}s (expected ~{expected:.2f}s, " f"delta {md-expected:+.2f}s)") def _ass_escape(path): # ffmpeg filtergraph escaping for a filename inside ass=... return path.replace("\\", "\\\\").replace(":", "\\:").replace("'", "\\'") if __name__ == "__main__": main() -
config.example.json 3.4 KB
{ "_comment": "Illustrative example — the values are the validated Soteri 'Eczema, the pH villain' run, brand-neutralised. The recipe binds a NEW brand's own clips / VO / product / palette / copy here. Every path is a placeholder to REPLACE with an absolute path or a runtime working/ path. `target_sec` per scene is the MEASURED VO window (ffprobe each VO mp3 — never a planned word count). Scene order is the 12-scene spine (villain intro -> teach -> damage list -> hero -> CLIMAX -> payoff); the end card is a SEPARATE block (built by build_endcard.py), not a scene.", "atempo": 1.3, "music_bed": "/abs/or/working/audio/music-bed.mp3", "music_volume": 0.62, "captions_ass": "/abs/or/working/audio/captions.ass", "caption_style": { "font": "Arial", "size": 64, "outline": 6, "shadow": 3, "marginv": 330 }, "scenes": [ { "id": "01", "clip": "/abs/or/working/clips/scene-01.mp4", "target_sec": 3.13, "vo": "/abs/or/working/audio/vo/vo-01-intro.mp3", "caption": "I'm Eczema." }, { "id": "02", "clip": "/abs/or/working/clips/scene-02.mp4", "target_sec": 5.41, "vo": "/abs/or/working/audio/vo/vo-02-barrier.mp3", "caption": "Meet the moisture barrier." }, { "id": "03", "clip": "/abs/or/working/clips/scene-03.mp4", "target_sec": 1.40, "vo": "/abs/or/working/audio/vo/vo-03-break.mp3", "caption": "So I break it." }, { "id": "04", "clip": "/abs/or/working/clips/scene-04.mp4", "target_sec": 3.42, "vo": "/abs/or/working/audio/vo/vo-04-secret.mp3", "caption": "It's really a pH problem." }, { "id": "05", "clip": "/abs/or/working/clips/scene-05.mp4", "target_sec": 4.56, "vo": "/abs/or/working/audio/vo/vo-05-healthy.mp3", "caption": "Healthy skin = pH 4.9" }, { "id": "06", "clip": "/abs/or/working/clips/scene-06.mp4", "target_sec": 3.94, "vo": "/abs/or/working/audio/vo/vo-06-pushup.mp3", "caption": "Push the pH up — the wall cracks." }, { "id": "07", "clip": "/abs/or/working/clips/scene-07.mp4", "target_sec": 3.39, "vo": "/abs/or/working/audio/vo/vo-07-skin.mp3", "caption": "Dry. Red. Itchy." }, { "id": "08", "clip": "/abs/or/working/clips/scene-08.mp4", "target_sec": 2.41, "vo": "/abs/or/working/audio/vo/vo-08-night.mp3", "caption": "Nobody sleeps tonight." }, { "id": "09", "clip": "/abs/or/working/clips/scene-09.mp4", "target_sec": 2.59, "vo": "/abs/or/working/audio/vo/vo-09-arrive.mp3", "caption": "Until Soteri Skin shows up." }, { "id": "10", "clip": "/abs/or/working/clips/scene-10.mp4", "target_sec": 4.64, "vo": "/abs/or/working/audio/vo/vo-10-lock.mp3", "caption": "pH/LOCK snaps it back to 4.9" }, { "id": "11", "clip": "/abs/or/working/clips/scene-11.mp4", "target_sec": 6.27, "vo": "/abs/or/working/audio/vo/vo-11-defeat.mp3", "caption": "Calm baby. No more eczema." } ], "end_card": { "product_image": "/abs/or/working/raw-materials/product-endcard.jpg", "image": "/abs/or/working/generated/endcard/endcard.png", "dwell_sec": 3.97, "zoom_to": 1.04, "background": null, "product_height": 1015, "product_y": 88, "copy_y": 1190, "wordmark": "Soteri Skin", "product_line": "Baby Eczema Relief Cream", "claims": [ "Powered by pH/LOCK® Technology", "Steroid-Free · Fragrance-Free · Ages 0–5" ], "cta": "soteriskin.com" }, "brand_palette": { "primary": "#2E6F5E", "primary_lite": "#3E8A73", "accent": "#E8674C", "grey": "#6B6F69" } } -
make_captions.py 2.8 KB
#!/usr/bin/env python3 """make_captions.py — emit a libass .ass, one caption pill per scene (v1). Ports the validated caption recipe from the Soteri run (production/make_captions.py). Reads the SAME config.json compose.py reads, so the per-scene target seconds are the single source of truth — the caption windows are derived from the compose scene table, guaranteeing they stay in lockstep with the cut. Caption style (validated on both reference runs): Arial 64px, white #FFFFFF, 6px outline #141414, 3px shadow, bottom-third MarginV=330. Rules: - Dialogue start = scene_start + 0.08s (avoids the caption flashing a frame before the cut — see the molecule's Failure Modes). - A scene with no `caption` (e.g. the end card) is suppressed — its own typeset copy carries the message, and two text layers at the same spot are both unreadable. """ import argparse, json, os HEADER = """[Script Info] ScriptType: v4.00+ PlayResX: 1080 PlayResY: 1920 WrapStyle: 0 ScaledBorderAndShadow: yes [V4+ Styles] Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding Style: Cap,{font},{size},&H00FFFFFF,&H000000FF,&H00141414,&H00000000,-1,0,0,0,100,100,0,0,1,{outline},{shadow},2,90,90,{marginv},1 [Events] Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text """ def ts(s): h = int(s // 3600) m = int((s % 3600) // 60) sec = s % 60 return f"{h}:{m:02d}:{sec:05.2f}" def main(): ap = argparse.ArgumentParser(description="Emit the per-scene caption .ass.") ap.add_argument("--config", required=True) ap.add_argument("--out", help="output .ass path (defaults to config.captions_ass)") a = ap.parse_args() cfg = json.load(open(a.config)) scenes = cfg["scenes"] cap_cfg = cfg.get("caption_style", {}) header = HEADER.format( font=cap_cfg.get("font", "Arial"), size=cap_cfg.get("size", 64), outline=cap_cfg.get("outline", 6), shadow=cap_cfg.get("shadow", 3), marginv=cap_cfg.get("marginv", 330), ) lines, t = [], 0.0 for s in scenes: dur = float(s["target_sec"]) cap = s.get("caption") if cap: lines.append(f"Dialogue: 0,{ts(t + 0.08)},{ts(t + dur)},Cap,,0,0,0,,{cap}") t += dur # end card window has no caption (its typeset copy carries it) out = a.out or cfg.get("captions_ass") if not out: raise SystemExit("no --out and no config.captions_ass set") os.makedirs(os.path.dirname(os.path.abspath(out)), exist_ok=True) with open(out, "w") as f: f.write(header + "\n".join(lines) + "\n") print(f"captions.ass written — {len(lines)} cues, total {t:.2f}s -> {out}") if __name__ == "__main__": main()
-
-
tests
-
smoke-test.md 3.5 KB
# Smoke test — render-absurdist-explainer Verifies the free PIL + ffmpeg assembly end-to-end. No paid calls. Needs: Python 3 with Pillow, ffmpeg/ffprobe, and a sans-serif TrueType font (DejaVu ships with Pillow on most Linux; macOS uses Arial). You supply per-scene clips + VO mp3s + a music bed + a real product photo (any 1080×1920 mp4s and a ≥1000px product jpg work for a smoke run). ## Setup ```bash cd scripts python3 -m pip install pillow # if not present mkdir -p /tmp/absurdist-smoke # Copy config.example.json -> config.json and point every path at real files: # - scenes[].clip : your per-scene i2v mp4s (1080x1920) # - scenes[].vo : the per-scene VO mp3s (target_sec = ffprobe of each) # - end_card.product_image : a real retail product photo (>=1000px) # - music_bed : an instrumental bed (or set to null to run VO-only) cp config.example.json /tmp/absurdist-smoke/config.json ``` ## Run ```bash # 1) real-product end card (PIL) — must run BEFORE compose python3 build_endcard.py --config /tmp/absurdist-smoke/config.json \ --out /tmp/absurdist-smoke/endcard.png # (then set end_card.image in config.json to /tmp/absurdist-smoke/endcard.png) # 2) per-scene captions (libass) — must run BEFORE compose python3 make_captions.py --config /tmp/absurdist-smoke/config.json \ --out /tmp/absurdist-smoke/captions.ass # (then set captions_ass in config.json to /tmp/absurdist-smoke/captions.ass) # 3) assemble the master python3 compose.py --config /tmp/absurdist-smoke/config.json \ --work-dir /tmp/absurdist-smoke \ --out /tmp/absurdist-smoke/master.mp4 ``` ## Expect - `build_endcard.py` writes `endcard.png` (1080×1920): the REAL product photo centred over the brand palette, with a typeset wordmark + claim rows + accent CTA pill. NOT an AI bottle; NO smeared/AI-rendered text. - `make_captions.py` writes `captions.ass` — one cue per scene with a caption, the end card suppressed, `start = scene_start + 0.08s`. - `compose.py` prints per-scene retime lines, the total runtime, and a final `WROTE ... (expected ~Xs, delta ±...)`. `master.mp4` is 1080×1920, 30fps; its duration is within ±0.1s of `sum(scenes[].target_sec) + end_card.dwell_sec`. - Run the `watch` skill on `master.mp4`: the villain silhouette holds across scenes, the single villain voice carries the whole spot, the motif word lands ≥3×, no AI brand text leaked into a cartoon background, captions don't collide with on-screen text, and the end card is the real product with legible copy. ## Fail signals - Concat drops frames / audio desyncs → a segment wasn't re-encoded to 30fps (all segments MUST be `libx264 -r 30` before the concat demuxer). compose.py always re-encodes, so this means a source clip fed the wrong stream — check the ffprobe output. - Master loudness is off (not ~-14 LUFS) → the mix busses were bypassed; confirm both `loudnorm` filters ran and `amix normalize=0`. - End card shows a cartoon/AI bottle or smeared text → `end_card.product_image` points at an AI render, or a font failed to load (build_endcard falls back to DejaVu → Arial → Pillow default; a Pillow-default fallback looks bitmapped — install DejaVu/Arial). - Caption flashes a frame before a cut → the +0.08s offset was removed from make_captions. - Duration far off the summed windows → a `target_sec` wasn't the measured VO duration (ffprobe each VO mp3; don't use planned word counts). ```
-
-
SKILL.md 5.9 KB
--- name: render-absurdist-explainer description: Assemble an absurdist animated-explainer video ad (~38s, 9:16) from per-scene i2v clips + their measured VO windows — retime each clip to its VO, re-encode every segment to identical 30fps/libx264/yuv420p so the concat demuxer never drops frames, concat, build a REAL-product PIL end card (never AI) with a slow Ken-Burns, mix VO (loudnorm I=-14) under music (loudnorm I=-26, volume 0.62, amix normalize=0), and burn libass captions last. FREE deterministic assembly (bash-free, Python + ffmpeg + PIL); the recipe supplies the clips, VO, music, product photo, palette, and caption table and gates the paid keyframe/clip/VO/music calls to their own capabilities. Use for the absurdist-explainer format. status: active --- # render-absurdist-explainer The free, deterministic renderer for the **absurdist-explainer** video ad format — the bright Pixar/Disney 3D spot where a personified villain (the problem) narrates the whole ad in one voice, teaches the product's ownable mechanism through cartoon biology, lists the damage, then watches its own scheme collapse when the product arrives. This capability is the **FREE assembly stage only**. All generative work (nano-banana keyframes, Seedance i2v clips, ElevenLabs VO + music) happens upstream in the recipe and is handed to this capability as files. It ports the validated compose recipe from two reference runs (HUM "Big Chill" cortisol absurdism and Soteri "Eczema, the pH villain"). The recipe is deterministic — iterate the cut for free, re-roll only the offending paid beat. ## What it does (the deterministic recipe) 1. **Per-scene retime.** Each i2v clip is retimed to its **measured** VO window (`scale=1080:1920:force_original_aspect_ratio=increase,crop=1080:1920,fps=30,setsar=1`, then `tpad=stop_mode=clone` if the VO is longer than the clip, else `-t` trim). 2. **Identical re-encode.** Every segment is re-encoded `libx264 -crf 18 -pix_fmt yuv420p -r 30` even if already correct — a framerate mismatch makes the concat demuxer silently drop frames. 3. **Concat** all scene segments + the end card via the concat demuxer (`-c copy`). 4. **Real-product end card.** `build_endcard.py` composites the REAL retail product photo over the brand palette (flat, or sampled from the photo's own edge pixel) with a typeset wordmark + claim rows + CTA pill in PIL `ImageDraw.text` — **never an AI cartoon bottle, never AI-rendered brand text**. `compose.py` Ken-Burnses it 1.00 → 1.04 over the dwell. 5. **Mix.** VO bus `loudnorm I=-14 TP=-1.5`, music bus `loudnorm I=-26 TP=-3` then `volume=0.62`, `amix inputs=2 duration=first normalize=0` → master lands at -14.5..-13.5 LUFS with the music ducked under the VO. 6. **Captions last.** `make_captions.py` emits a libass `.ass` (one cue per scene, Arial 64 white / 6px outline / MarginV=330, `start = scene_start + 0.08s`, suppressed on the end card). `compose.py` burns it as the final filter so captions sit on top. ## Scripts (free — Python + ffmpeg + PIL, no bash, no paid calls) - `scripts/build_endcard.py` — PIL composite of the real product photo + typeset brand layer (wordmark / product line / claim rows / accent CTA pill). Reads the same `config.json`. Run this FIRST so `end_card.image` exists before `compose.py`. - `scripts/make_captions.py` — emits the per-scene libass `.ass` from the SAME scene table compose reads, so caption windows stay in lockstep with the cut. Run before `compose.py` (or point `config.captions_ass` at nothing to skip captions). - `scripts/compose.py` — the assembler: per-scene retime + identical 30fps re-encode → concat → Ken-Burns end card → VO/music loudnorm mix → burn captions → master mp4. - `scripts/config.example.json` — the shape of the `config` the recipe binds (the brand-neutralised Soteri values as a worked reference). ## Inputs (all via `--config` + a runtime work dir — NO hardcoded paths) `config.json` carries: `scenes[]` (each `{id, clip, target_sec, vo, caption, atempo?}` where `target_sec` is the **measured** VO window), `end_card{product_image, image, dwell_sec, zoom_to, wordmark, product_line, claims[], cta, background?}`, `brand_palette {primary, primary_lite, accent, grey}`, `music_bed`, `music_volume` (default 0.62), `atempo` (compose-stage VO speed-up, default off; the reference runs used 1.3 when the VO read slow), `captions_ass`, and `caption_style`. See `config.example.json`. ## Craft rules (load-bearing — faithful to the source molecule) - **The end card is the REAL product photo, composited — never an AI cartoon bottle.** Both reference runs shipped an AI bottle first and had to re-shoot with the real photo. - **No AI-rendered brand text anywhere.** Wordmark, claims, CTA, motif — all PIL `ImageDraw.text`. AI draws the world + characters only. - **Re-encode every segment to 30fps before concat**, even if already correct, or the concat demuxer silently drops frames. - **`target_sec` is the MEASURED VO duration** (ffprobe each VO mp3), never a planned word count — VO drives the per-scene timing. - **Mix constants are validated** — VO -14 LUFS, music -26 LUFS then `volume≈0.62` (Soteri) to `0.70` (Big Chill), `amix normalize=0`. Master target -14.5..-13.5 LUFS, true-peak ≤ -1.5 dBFS. - **Caption `start = scene_start + 0.08s`**, suppressed on the end card (its typeset copy carries the message — two text layers at one spot are both unreadable). ## Requires `watch` (QC the final master — confirm the villain silhouette holds, the single voice carries the whole spot, the motif lands ≥3×, no AI brand text leaked into a cartoon background, the end card is the real product, and duration is within ±0.1s of the summed windows). The recipe gates the paid `create-image-fal` (keyframes), `create-video-fal` (Seedance i2v), `create-vo-elevenlabs`, and `create-music-elevenlabs` calls to their own capabilities — this capability itself makes NO paid calls. -
skill.meta.json 329 B
{ "slug": "render-absurdist-explainer", "category": "capabilities", "domain": "ads", "tags": [ "ads" ], "installation": { "base_command": "npx goose-skills install render-absurdist-explainer", "supports": [ "claude", "cursor", "codex" ] }, "requires_skills": [ "watch" ] }
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.