render-vignette
Assemble a short-form 'vignette' ad from clean product cutouts composited over a kinetic background video — birefnet cutout, then a cold-open text card + product carousel + annotated specimen-sheet end card, plus loudnorm + separate-pass music mux. FREE assembly (PIL + rsvg + FFm
Install
npx skills add https://github.com/gooseworks-ai/goose-skills/tree/main/skills/ads/capabilities/render-vignette
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-vignette
Assemble a short-form 'vignette' ad from clean product cutouts composited over a kinetic background video. Motion lives in the BG video; the product rides on top as a static cutout layer. Music-led, zero VO, sub-12s, loopable — it reads muted because the product label + on-screen copy carry the message. Defaults to the V-CARD structure: a cold-open text card → a product carousel under one shared BG → an annotated specimen-sheet end card.
Run
strip_product_backgrounds.py— birefnet cutout of each PDP shot to clean hard-edge alpha (no halo/shadow).render_overlays.py— PIL +rsvg-convertrender the cold-open card (Boska Black, dead-center) + the annotated specimen-sheet end card (brand SVG logo + Space Grotesk annotations) as transparent 1080x1920 PNGs. FREE.composite_variants.py— one FFmpegfilter_complexper BG variant: BG (palette-aware dim) → cold-open overlay → cutouts (width-anchored, vertically centeredy=(H-h)/2) → end card. h264 crf20 yuv420p +faststart 30fps. FREE.music_and_mux.py— instrumental music bed →acompressor + loudnorm I=-18:TP=-2:LRA=9→ muxed into every variant in a SEPARATE pass with explicit-map 0:v:0 -map 1:a:0. The mux is FREE; the music generation is a paid call that in prod routes through create-music-elevenlabs.
Contract
- FREE assembly: birefnet cutout (see gap below) + PIL/rsvg overlays + FFmpeg composite + mux. No AI-rendered text; the product art/labels and on-screen copy are real, never invented.
- The template recipe (DB) supplies the per-brand config (products, cold-open text, end-card lines, BG concept, beat timing). This capability is the generic assembler.
- Craft rules preserved from the source molecule:
- Cutouts stripped clean (no halo/shadow), height-anchored at vertical-center (
y=(H-h)/2) so mixed-shape SKUs share one visual mid-line — never bottom-anchor (squat jars jump). For 9:16 scale by WIDTH (~75% tall bottles, ~65% squat jars). - Palette-aware BG dim: high-contrast/chrome BG → push saturation DOWN hard (
saturation=0.50); naturally-contrasty BG → lighter dim (saturation=0.85). - End card = annotated specimen-sheet (EST year + rule + wordmark + rule + ingredient + positioning + claim), never a bare logo. Use the WHITE logo variant on dark BGs, cream on light.
- Music-led, NO VO — instrumental only (VO/lyrics would fight the cold-open + end-card text). Loudnorm before the mux.
- Mux is a SEPARATE FFmpeg pass with explicit
-map 0:v:0 -map 1:a:0(single-pass composite+mux silently ships 1 kbps garbage audio).
- Cutouts stripped clean (no halo/shadow), height-anchored at vertical-center (
Gaps / routing notes
- birefnet is a paid FAL model, not free.
strip_product_backgrounds.pycallsfal-ai/birefnet/v2directly via afal_helpersshim (sys.path.insertinto a shared atoms dir +from fal_helpers import ...). It is bundled here because the cutout is an intrinsic assembly step, but in prod the birefnet cutout should route through create-image-fal (the fal-proxy capability that bills the Ads agent) rather than hittingfal.rundirectly. Treat the direct-fal path as a gap to close; the recipe gates the cutout togooseworks fetch create-image-fal. - Background sourcing is not in this capability. The kinetic BG is sourced upstream — PEXELS-FIRST (free stock, the key cost lever), falling back to T2V (create-video-fal) only when stock coverage fails — and dropped into
source/t2v-outputs/<slug>.mp4so the composite is source-agnostic. There is no Pexels fetcher bundled here; that lives in the recipe's playbook. music_and_mux.pyalso uses thefal_helpersshim for the ElevenLabs music generation; in prod that generation routes through create-music-elevenlabs, and only the loudnorm + separate-pass mux run locally as FREE assembly.
Files (goose-skills)
-
scripts
-
composite_variants.py 5.6 KB
"""Build 6 master mp4 variants at 9:16 1080×1920. v2.1 fixes: - Vertical-center cutouts (all 3 visual midpoints align) - Per-variant BG dim — alpha (chrome) gets heavier desaturate+darken, beta (ink-in-cream) keeps lighter dim For each T2V BG: 1. Convert to 9:16 (scale-to-fit-vertical + center-crop horizontal) 2. Darken/desaturate per variant family 3. Loop if needed 4. Overlay cutouts vertically-centered 5. Overlay cold-open card + annotated end card """ from __future__ import annotations import subprocess from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parent.parent COLD_OPEN = PROJECT_ROOT / "assets" / "text-overlays" / "cold-open-card-9x16.png" END_CARD = PROJECT_ROOT / "assets" / "text-overlays" / "end-card-annotated-9x16.png" HERO = PROJECT_ROOT / "assets" / "product-cutouts" / "molecular-hero-serum.png" GENESIS = PROJECT_ROOT / "assets" / "product-cutouts" / "molecular-genesis.png" RETINOL = PROJECT_ROOT / "assets" / "product-cutouts" / "retinol-synergist.png" OUT = PROJECT_ROOT / "finals" OUT.mkdir(parents=True, exist_ok=True) W, H = 1080, 1920 DURATION = 10.5 HERO_W = int(W * 0.75) GEN_W = int(W * 0.65) RET_W = int(W * 0.75) # Per-variant family BG processing BG_PROCESS_ALPHA = ( "scale=-2:1920:flags=lanczos," "crop=1080:1920:(iw-1080)/2:0," "eq=brightness=-0.30:saturation=0.50:contrast=1.15" # heavier desaturate to kill chrome metallic ) BG_PROCESS_BETA = ( "scale=-2:1920:flags=lanczos," "crop=1080:1920:(iw-1080)/2:0," "eq=brightness=-0.18:saturation=0.85:contrast=1.10" # lighter — ink-on-cream already has contrast ) VARIANTS = [ "alpha-VEO", "alpha-KLING", "alpha-SEED", "beta-VEO", "beta-KLING", "beta-SEED", ] def composite_one(variant: str) -> dict: bg = PROJECT_ROOT / "source" / "t2v-outputs" / f"{variant}.mp4" if not bg.exists(): return {"variant": variant, "status": "BG_MISSING"} bg_process = BG_PROCESS_ALPHA if variant.startswith("alpha-") else BG_PROCESS_BETA out = OUT / f"master-9x16-{variant}.mp4" # filter_complex: BG processed → cold-open overlay → 3 cutouts (vertically centered) → end card # Beat windows are HALF-OPEN [start, next_start): ffmpeg's between(t,a,b) is inclusive on # BOTH ends, so consecutive beats that share a boundary (cold-open ends at 3.0, hero starts # at 3.0, …) both draw on the single frame at the boundary — a ~1-frame flash of the old # beat under the new one (most visible as the cold-open card ghosting behind product 1). # gte(t,a)*lt(t,b) makes each beat own [a, b) exactly: no overlap, and no BG-only gap frame. filter_complex = ( f"[0:v]trim=duration={DURATION},setpts=PTS-STARTPTS," f"{bg_process}[bg];" f"[bg][1:v]overlay=0:0:enable='gte(t,1.5)*lt(t,3.0)'[v1];" # Hero Serum — vertical center f"[2:v]scale={HERO_W}:-1[hero];" f"[v1][hero]overlay=x=(W-w)/2:y=(H-h)/2:enable='gte(t,3.0)*lt(t,4.6)'[v2];" # Genesis — vertical center f"[3:v]scale={GEN_W}:-1[gen];" f"[v2][gen]overlay=x=(W-w)/2:y=(H-h)/2:enable='gte(t,4.6)*lt(t,6.2)'[v3];" # Retinol — vertical center f"[4:v]scale={RET_W}:-1[ret];" f"[v3][ret]overlay=x=(W-w)/2:y=(H-h)/2:enable='gte(t,6.2)*lt(t,7.8)'[v4];" # End card — runs to the end (no upper bound so a rounded duration can't drop the tail) f"[v4][5:v]overlay=0:0:enable='gte(t,7.8)'[vout]" ) cmd = [ "ffmpeg", "-y", "-stream_loop", "-1", "-t", str(DURATION), "-i", str(bg), "-loop", "1", "-t", str(DURATION), "-i", str(COLD_OPEN), "-loop", "1", "-t", str(DURATION), "-i", str(HERO), "-loop", "1", "-t", str(DURATION), "-i", str(GENESIS), "-loop", "1", "-t", str(DURATION), "-i", str(RETINOL), "-loop", "1", "-t", str(DURATION), "-i", str(END_CARD), "-filter_complex", filter_complex, "-map", "[vout]", "-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p", "-movflags", "+faststart", "-r", "30", "-t", str(DURATION), str(out), ] print(f"[{variant}] composing → {out.name}", flush=True) r = subprocess.run(cmd, capture_output=True, text=True, timeout=300) if r.returncode != 0: return {"variant": variant, "status": "FFMPEG_ERROR", "stderr": r.stderr[-2000:]} size_mb = out.stat().st_size / 1024 / 1024 return {"variant": variant, "status": "OK", "path": str(out.relative_to(PROJECT_ROOT)), "size_mb": round(size_mb, 1)} def main(): print(f"compositing {len(VARIANTS)} variants in parallel (v2.1: vertical center + per-variant BG dim)…") results = [] with ThreadPoolExecutor(max_workers=len(VARIANTS)) as ex: futures = {ex.submit(composite_one, v): v for v in VARIANTS} for fut in as_completed(futures): v = futures[fut] try: r = fut.result() results.append(r) if r["status"] == "OK": print(f"✓ {r['variant']}: {r['path']} ({r['size_mb']} MB)") else: print(f"✗ {r['variant']}: {r['status']}") if "stderr" in r: print(f" {r['stderr'][-800:]}") except Exception as e: print(f"✗ {v}: EXCEPTION {e}") results.append({"variant": v, "status": "EXCEPTION", "error": str(e)}) ok = sum(1 for r in results if r.get("status") == "OK") print(f"\n→ {ok}/{len(VARIANTS)} variants succeeded") return 0 if ok == len(VARIANTS) else 1 if __name__ == "__main__": import sys sys.exit(main()) -
music_and_mux.py 5.8 KB
"""Generate one music bed via fal-ai/elevenlabs/music, then mux into all 6 variants. Per memory: feedback_ffmpeg_map_directive: when ffmpeg has two -i inputs, always pass -map 0:v -map 1:a feedback_ffmpeg_lcut_endcard_recipe: build video + audio in SEPARATE ffmpeg passes, not one feedback_elevenlabs_music_decay: if music tapers in 2nd half, loop-and-flatten instead of regen """ from __future__ import annotations import subprocess import sys from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parent.parent SHARED = PROJECT_ROOT.parent.parent.parent / "skills" / "atoms" / "_shared" sys.path.insert(0, str(SHARED)) from fal_helpers import download, load_fal_key, subscribe # noqa: E402 FINALS = PROJECT_ROOT / "finals" MUSIC_DIR = PROJECT_ROOT / "assets" / "music" MUSIC_DIR.mkdir(parents=True, exist_ok=True) MUSIC_RAW = MUSIC_DIR / "mother-science-bed-raw.mp3" MUSIC_FINAL = MUSIC_DIR / "mother-science-bed-final.mp3" DURATION = 10.5 TARGET_MUSIC_DUR = 12.0 # generate slightly longer than video MUSIC_BRIEF = ( "Clinical-luxury skincare science vignette ad music bed. Slow-tempo ambient " "minimal electronic with warm low pads and subtle high-end sparkle. " "Instrumental only, no vocals, no lyrics. 90 BPM, sophisticated and quiet. " "Begins with soft atmospheric pad and gentle resonant tones. Subtly builds " "with a single sparkling synth note in the last 2 seconds for the end-card " "brand reveal. Premium, restrained, Augustinus Bader / La Mer luxury " "skincare commercial mood. 12 seconds total." ) VARIANTS = [ "alpha-VEO", "alpha-KLING", "alpha-SEED", "beta-VEO", "beta-KLING", "beta-SEED", ] def generate_music(): """Fire fal-ai/elevenlabs/music.""" print("generating music bed via fal-ai/elevenlabs/music…") print(f" brief: {MUSIC_BRIEF[:120]}…") result = subscribe( "fal-ai/elevenlabs/music", { "prompt": MUSIC_BRIEF, "music_length_ms": int(TARGET_MUSIC_DUR * 1000), "output_format": "mp3_44100_192", }, timeout_sec=600, ) if not result or "audio" not in result: raise RuntimeError(f"music gen failed: {result}") url = result["audio"]["url"] download(url, MUSIC_RAW) print(f" ✓ raw music: {MUSIC_RAW.relative_to(PROJECT_ROOT)} ({MUSIC_RAW.stat().st_size // 1024} KB)") return MUSIC_RAW def trim_and_normalize(src: Path): """Trim music to DURATION + apply gentle compressor + loudnorm so it sits under no-VO video.""" print(f"trimming + normalizing music to {DURATION}s…") cmd = [ "ffmpeg", "-y", "-i", str(src), "-t", str(DURATION), "-af", "acompressor=threshold=-12dB:ratio=2:attack=20:release=200,loudnorm=I=-18:TP=-2:LRA=9", "-c:a", "mp3", "-b:a", "192k", "-loglevel", "error", str(MUSIC_FINAL), ] r = subprocess.run(cmd, capture_output=True, text=True) if r.returncode != 0: print(f"FAIL: {r.stderr}") return False print(f" ✓ final music: {MUSIC_FINAL.relative_to(PROJECT_ROOT)} ({MUSIC_FINAL.stat().st_size // 1024} KB)") return True def mux_one(variant: str) -> dict: """Mux music into one variant. Use -map 0:v -map 1:a per memory rule.""" src_video = FINALS / f"master-9x16-{variant}.mp4" if not src_video.exists(): return {"variant": variant, "status": "VIDEO_MISSING"} # Write to temp then rename, so we don't corrupt the source tmp_out = FINALS / f"_tmp_{variant}.mp4" cmd = [ "ffmpeg", "-y", "-i", str(src_video), "-i", str(MUSIC_FINAL), "-c:v", "copy", "-c:a", "aac", "-b:a", "192k", "-ar", "44100", "-map", "0:v:0", "-map", "1:a:0", "-shortest", "-movflags", "+faststart", "-loglevel", "error", str(tmp_out), ] r = subprocess.run(cmd, capture_output=True, text=True, timeout=120) if r.returncode != 0: return {"variant": variant, "status": "MUX_ERROR", "stderr": r.stderr[-1000:]} # Move tmp over original tmp_out.replace(src_video) # Verify audio is actually there + not 1kbps probe = subprocess.run( ["ffprobe", "-v", "error", "-select_streams", "a:0", "-show_entries", "stream=codec_name,bit_rate,duration", "-of", "default=noprint_wrappers=1", str(src_video)], capture_output=True, text=True, ) return {"variant": variant, "status": "OK", "audio_probe": probe.stdout.strip()} def main(): load_fal_key() # 1. Generate music (skip if cached) if not MUSIC_RAW.exists(): generate_music() else: print(f"using cached music: {MUSIC_RAW.relative_to(PROJECT_ROOT)}") # 2. Trim + normalize if not trim_and_normalize(MUSIC_RAW): return 1 # 3. Mux into 6 variants in parallel print(f"\nmuxing music into {len(VARIANTS)} variants in parallel…") results = [] with ThreadPoolExecutor(max_workers=len(VARIANTS)) as ex: futures = {ex.submit(mux_one, v): v for v in VARIANTS} for fut in as_completed(futures): v = futures[fut] try: r = fut.result() results.append(r) if r["status"] == "OK": print(f"✓ {r['variant']}: muxed") for line in r["audio_probe"].splitlines(): print(f" {line}") else: print(f"✗ {r['variant']}: {r['status']}") except Exception as e: results.append({"variant": v, "status": "EXC", "error": str(e)}) print(f"✗ {v}: EXC {e}") ok = sum(1 for r in results if r.get("status") == "OK") print(f"\n→ {ok}/{len(VARIANTS)} variants muxed with music") return 0 if ok == len(VARIANTS) else 1 if __name__ == "__main__": sys.exit(main()) -
render_overlays.py 5.4 KB
"""Render 9:16 (1080×1920) transparent overlay PNGs for the composite: - cold-open-card-9x16.png '100% / PROVEN / RESULTS' Boska Black, dead-center - end-card-annotated-9x16.png EST 2023 / MOTHER SCIENCE logo / MAL•UH•SAY•ZIN / 10× tagline Pinterest-inspired specimen-sheet style """ from pathlib import Path import subprocess from PIL import Image, ImageChops, ImageDraw, ImageFont PROJECT_ROOT = Path(__file__).resolve().parent.parent BOSKA = PROJECT_ROOT / "assets" / "fonts" / "Boska-Black.ttf" SG_MED = PROJECT_ROOT / "assets" / "fonts" / "SpaceGrotesk-Medium.ttf" SG_SB = PROJECT_ROOT / "assets" / "fonts" / "SpaceGrotesk-SemiBold.ttf" OUT = PROJECT_ROOT / "assets" / "text-overlays" OUT.mkdir(parents=True, exist_ok=True) W, H = 1080, 1920 CREAM = (249, 247, 239, 255) # #f9f7ef CREAM_DIM = (249, 247, 239, 180) # 70% opacity cream for small annotations def render_cold_open_card_9x16(): """3 stacked lines: 100% / PROVEN / RESULTS, dead-center, Boska Black.""" im = Image.new("RGBA", (W, H), (0, 0, 0, 0)) draw = ImageDraw.Draw(im) lines = ["100%", "PROVEN", "RESULTS"] font_size = 240 font = ImageFont.truetype(str(BOSKA), font_size) line_height_mult = 0.92 line_height_px = int(font_size * line_height_mult) total_height = line_height_px * len(lines) start_y = (H - total_height) // 2 for i, line in enumerate(lines): bbox = draw.textbbox((0, 0), line, font=font) text_w = bbox[2] - bbox[0] x = (W - text_w) // 2 y = start_y + i * line_height_px draw.text((x, y), line, font=font, fill=CREAM) dst = OUT / "cold-open-card-9x16.png" im.save(dst, "PNG", optimize=True) print(f"✓ cold-open-card-9x16: {dst.relative_to(PROJECT_ROOT)} ({dst.stat().st_size // 1024} KB)") def autocrop_alpha(im): bbox = im.split()[-1].getbbox() return im.crop(bbox) if bbox else im def render_end_card_annotated_9x16(): """Pinterest-inspired specimen-sheet style end card. Layout (top → bottom centered): EST. 2023 (small tracking, Space Grotesk Med) ───────────────── (subtle horizontal rule) (gap) MOTHER SCIENCE (large cream wordmark from brand SVG) (gap) ───────────────── (subtle horizontal rule) MAL · UH · SAY · ZIN (small tracking, Space Grotesk Med) NOVEL MOLECULE (smaller still, Space Grotesk Med) 10× MORE POWERFUL THAN VITAMIN C (smaller, Space Grotesk Med) """ im = Image.new("RGBA", (W, H), (0, 0, 0, 0)) draw = ImageDraw.Draw(im) # ── Render brand logo SVG at target width ── svg = PROJECT_ROOT / "assets" / "end-cards" / "mother-science-logo-cream.svg" target_logo_w = int(W * 0.80) # 80% frame width tmp = OUT / "_logo_raw.png" subprocess.run( ["rsvg-convert", "-w", "2400", str(svg), "-o", str(tmp)], check=True, capture_output=True, ) logo = autocrop_alpha(Image.open(tmp).convert("RGBA")) logo_h = int(logo.height * (target_logo_w / logo.width)) logo = logo.resize((target_logo_w, logo_h), Image.LANCZOS) # ── Layout positions ── center_y = H // 2 logo_y = center_y - logo_h // 2 logo_x = (W - target_logo_w) // 2 # ── Top annotation ── f_top = ImageFont.truetype(str(SG_MED), 26) top_text = "E S T 2 0 2 3" # extra-tracking via spaces bbox = draw.textbbox((0, 0), top_text, font=f_top) top_w = bbox[2] - bbox[0] top_y = logo_y - 130 draw.text(((W - top_w) // 2, top_y), top_text, font=f_top, fill=CREAM_DIM) # ── Rule line above logo ── rule_w = 200 rule_x = (W - rule_w) // 2 rule_y_top = logo_y - 60 draw.line([(rule_x, rule_y_top), (rule_x + rule_w, rule_y_top)], fill=CREAM_DIM, width=2) # ── Logo ── im.paste(logo, (logo_x, logo_y), logo) # ── Rule line below logo ── rule_y_bot = logo_y + logo_h + 60 draw.line([(rule_x, rule_y_bot), (rule_x + rule_w, rule_y_bot)], fill=CREAM_DIM, width=2) # ── Annotation block below ── f_mal = ImageFont.truetype(str(SG_SB), 38) mal = "M A L · U H · S A Y · Z I N" bbox = draw.textbbox((0, 0), mal, font=f_mal) mal_w = bbox[2] - bbox[0] mal_y = rule_y_bot + 35 draw.text(((W - mal_w) // 2, mal_y), mal, font=f_mal, fill=CREAM) f_sub = ImageFont.truetype(str(SG_MED), 22) sub1 = "N O V E L M O L E C U L E" bbox = draw.textbbox((0, 0), sub1, font=f_sub) sub1_w = bbox[2] - bbox[0] sub1_y = mal_y + 60 draw.text(((W - sub1_w) // 2, sub1_y), sub1, font=f_sub, fill=CREAM_DIM) f_claim = ImageFont.truetype(str(SG_MED), 24) claim = "10× MORE POWERFUL ANTIOXIDANT THAN VITAMIN C" bbox = draw.textbbox((0, 0), claim, font=f_claim) claim_w = bbox[2] - bbox[0] claim_y = sub1_y + 50 draw.text(((W - claim_w) // 2, claim_y), claim, font=f_claim, fill=CREAM_DIM) tmp.unlink() dst = OUT / "end-card-annotated-9x16.png" im.save(dst, "PNG", optimize=True) print(f"✓ end-card-annotated-9x16: {dst.relative_to(PROJECT_ROOT)} ({dst.stat().st_size // 1024} KB)") def main(): render_cold_open_card_9x16() render_end_card_annotated_9x16() if __name__ == "__main__": main() -
strip_product_backgrounds.py 4.1 KB
"""Strip product backgrounds via fal-ai/birefnet/v2. Reads PNGs from ../source/scraped-product-images/, fires birefnet-v2 in parallel, saves cutouts to ../assets/product-cutouts/, validates alpha quality, writes manifest.json with per-file stats. Run from this directory: python3 01_strip_product_backgrounds.py """ from __future__ import annotations import json import sys from concurrent.futures import ThreadPoolExecutor, as_completed from pathlib import Path PROJECT_ROOT = Path(__file__).resolve().parent.parent SHARED = PROJECT_ROOT.parent.parent.parent / "skills" / "atoms" / "_shared" sys.path.insert(0, str(SHARED)) from fal_helpers import download, load_fal_key, subscribe, upload_file # noqa: E402 SOURCE_DIR = PROJECT_ROOT / "source" / "scraped-product-images" OUTPUT_DIR = PROJECT_ROOT / "assets" / "product-cutouts" MANIFEST = OUTPUT_DIR / "manifest.json" PRODUCTS = [ "molecular-hero-serum.png", "molecular-genesis.png", "retinol-synergist.png", ] def strip_one(filename: str) -> dict: src = SOURCE_DIR / filename dst = OUTPUT_DIR / filename if not src.exists(): return {"file": filename, "status": "ERROR_MISSING_SOURCE"} print(f"[{filename}] uploading {src.stat().st_size // 1024} KB…", flush=True) image_url = upload_file(src) print(f"[{filename}] running birefnet-v2…", flush=True) result = subscribe( "fal-ai/birefnet/v2", {"image_url": image_url}, timeout_sec=300, ) if not result or "image" not in result: return {"file": filename, "status": "ERROR_NO_IMAGE_IN_RESULT", "result": result} print(f"[{filename}] downloading cutout…", flush=True) download(result["image"]["url"], dst) # validate alpha from PIL import Image im = Image.open(dst) if im.mode != "RGBA": im = im.convert("RGBA") im.save(dst) alpha = im.split()[3] pixels = list(alpha.getdata()) total = len(pixels) transparent = sum(1 for p in pixels if p == 0) opaque = sum(1 for p in pixels if p == 255) partial = total - transparent - opaque pct_transparent = 100 * transparent / total pct_partial = 100 * partial / total quality = "GOOD" warnings = [] if pct_transparent < 20: quality = "BAD_NO_REMOVAL" warnings.append(f"only {pct_transparent:.1f}% transparent — BG not stripped") if pct_partial > 8: quality = "WARN_SOFT_EDGE" warnings.append(f"{pct_partial:.1f}% partial-alpha edge — may show halo") return { "file": filename, "status": "OK", "quality": quality, "warnings": warnings, "size_px": list(im.size), "pct_transparent": round(pct_transparent, 1), "pct_partial_alpha": round(pct_partial, 1), "output_path": str(dst.relative_to(PROJECT_ROOT)), "fal_url": result["image"]["url"], } def main() -> int: OUTPUT_DIR.mkdir(parents=True, exist_ok=True) load_fal_key() print(f"running birefnet/v2 on {len(PRODUCTS)} PNGs in parallel…", flush=True) results = [] with ThreadPoolExecutor(max_workers=3) as ex: futures = {ex.submit(strip_one, p): p for p in PRODUCTS} for fut in as_completed(futures): try: r = fut.result() results.append(r) print(f"\n>>> DONE [{r['file']}]: {r.get('status')} {r.get('quality', '')}") for w in r.get("warnings", []): print(f" WARN: {w}") except Exception as e: results.append({"file": futures[fut], "status": "EXCEPTION", "error": str(e)}) print(f"\n>>> FAILED [{futures[fut]}]: {e}") MANIFEST.write_text(json.dumps({"results": results}, indent=2)) print(f"\nmanifest: {MANIFEST}") bad = [r for r in results if r.get("status") != "OK" or r.get("quality") == "BAD_NO_REMOVAL"] if bad: print(f"\n{len(bad)} file(s) need attention:") for r in bad: print(f" - {r['file']}: {r.get('status')} / {r.get('quality', 'n/a')}") return 1 return 0 if __name__ == "__main__": sys.exit(main())
-
-
tests
-
smoke-test.md 706 B
# Smoke Test render_overlays.py (cold-open card + annotated end card PNGs) ; composite_variants.py (BG dim + vertically-centered cutouts + overlays → silent master) ; music_and_mux.py (loudnorm + separate-pass mux, `-map 0:v:0 -map 1:a:0`) — 1080x1920, 9-12s, 30fps, h264 yuv420p crf20 +faststart, AAC 192k ~-18 LUFS. Pass when the assembly scripts run to a valid master mp4 with real (non-1kbps) audio. Assembly (overlays + composite + mux) is FREE. The birefnet cutout (`strip_product_backgrounds.py`) and the music generation are paid FAL/ElevenLabs calls that in prod route through create-image-fal / create-music-elevenlabs (proxy-routed, bills the agent) — not a provider SDK's default host.
-
-
SKILL.md 4.2 KB
--- name: render-vignette description: Assemble a short-form 'vignette' ad from clean product cutouts composited over a kinetic background video — birefnet cutout, then a cold-open text card + product carousel + annotated specimen-sheet end card, plus loudnorm + separate-pass music mux. FREE assembly (PIL + rsvg + FFmpeg); the recipe supplies the config and gates the paid BG/cutout/music calls to their own capabilities. Use for the vignette format. status: active --- # render-vignette Assemble a short-form 'vignette' ad from clean product cutouts composited over a kinetic background video. Motion lives in the BG video; the product rides on top as a static cutout layer. Music-led, zero VO, sub-12s, loopable — it reads muted because the product label + on-screen copy carry the message. Defaults to the V-CARD structure: a cold-open text card → a product carousel under one shared BG → an annotated specimen-sheet end card. ## Run 1. `strip_product_backgrounds.py` — birefnet cutout of each PDP shot to clean hard-edge alpha (no halo/shadow). 2. `render_overlays.py` — PIL + `rsvg-convert` render the cold-open card (Boska Black, dead-center) + the annotated specimen-sheet end card (brand SVG logo + Space Grotesk annotations) as transparent 1080x1920 PNGs. FREE. 3. `composite_variants.py` — one FFmpeg `filter_complex` per BG variant: BG (palette-aware dim) → cold-open overlay → cutouts (width-anchored, vertically centered `y=(H-h)/2`) → end card. h264 crf20 yuv420p +faststart 30fps. FREE. 4. `music_and_mux.py` — instrumental music bed → `acompressor + loudnorm I=-18:TP=-2:LRA=9` → muxed into every variant in a SEPARATE pass with explicit `-map 0:v:0 -map 1:a:0`. The mux is FREE; the music generation is a paid call that in prod routes through create-music-elevenlabs. ## Contract - FREE assembly: birefnet cutout (see gap below) + PIL/rsvg overlays + FFmpeg composite + mux. No AI-rendered text; the product art/labels and on-screen copy are real, never invented. - The template recipe (DB) supplies the per-brand config (products, cold-open text, end-card lines, BG concept, beat timing). This capability is the generic assembler. - Craft rules preserved from the source molecule: - Cutouts stripped clean (no halo/shadow), height-anchored at vertical-center (`y=(H-h)/2`) so mixed-shape SKUs share one visual mid-line — never bottom-anchor (squat jars jump). For 9:16 scale by WIDTH (~75% tall bottles, ~65% squat jars). - Palette-aware BG dim: high-contrast/chrome BG → push saturation DOWN hard (`saturation=0.50`); naturally-contrasty BG → lighter dim (`saturation=0.85`). - End card = annotated specimen-sheet (EST year + rule + wordmark + rule + ingredient + positioning + claim), never a bare logo. Use the WHITE logo variant on dark BGs, cream on light. - Music-led, NO VO — instrumental only (VO/lyrics would fight the cold-open + end-card text). Loudnorm before the mux. - Mux is a SEPARATE FFmpeg pass with explicit `-map 0:v:0 -map 1:a:0` (single-pass composite+mux silently ships 1 kbps garbage audio). ## Gaps / routing notes - **birefnet is a paid FAL model, not free.** `strip_product_backgrounds.py` calls `fal-ai/birefnet/v2` directly via a `fal_helpers` shim (`sys.path.insert` into a shared atoms dir + `from fal_helpers import ...`). It is bundled here because the cutout is an intrinsic assembly step, but in prod the birefnet cutout should route through **create-image-fal** (the fal-proxy capability that bills the Ads agent) rather than hitting `fal.run` directly. Treat the direct-fal path as a gap to close; the recipe gates the cutout to `gooseworks fetch create-image-fal`. - **Background sourcing is not in this capability.** The kinetic BG is sourced upstream — PEXELS-FIRST (free stock, the key cost lever), falling back to T2V (create-video-fal) only when stock coverage fails — and dropped into `source/t2v-outputs/<slug>.mp4` so the composite is source-agnostic. There is no Pexels fetcher bundled here; that lives in the recipe's playbook. - `music_and_mux.py` also uses the `fal_helpers` shim for the ElevenLabs music generation; in prod that generation routes through **create-music-elevenlabs**, and only the loudnorm + separate-pass mux run locally as FREE assembly. -
skill.meta.json 307 B
{ "slug": "render-vignette", "category": "capabilities", "domain": "ads", "tags": [ "ads" ], "installation": { "base_command": "npx goose-skills install render-vignette", "supports": [ "claude", "cursor", "codex" ] }, "requires_skills": [ "watch" ] }
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.