Pipeline Hub

workflow-ugc-automation

Claude-orchestrated batch pipeline for DawnBands AI UGC/video ads: product URL -> brand-dna.json (scripts/brand_dna.py), reference ad -> per-second dissect -> <=15 s scene prompts, one base -> N variants swapping ONE variable (hook / actor / setting / product angle / delivery) with a variants manifest, generation fan-out bound to registry ROLES behind Fish's per-batch spend go, then QA + review sheet + ad-batch-hub staging.

Lane: ugc-omni (every photoreal-people unit, gates G0-G4) / cartoon-h3 / statics / seedance b-roll, picked per format; H3 native voice (yapper) is a side arm only · not built yet: 8 (see below) · source: /Users/ayden/.openclaw/workspace/skills/workflow-ugc-automation

SKILL.mdprocedure.mdsources.mdEvals (7)Learnings (14)

workflow-ugc-automation — procedure

Batch dir: B=output/ugc-auto/<YYYY-MM-DD>-<slug> (new slug per batch). Every command below was checked against the script's argparse on 2026-10-06. Steps 0-5 and 7-8 are free; step 6 is the only paid step.

0. Balances, registry, catalog (free)

higgsfield account status                                             # Higgsfield credits
K=$(grep '^KIE_API_KEY=' .env | cut -d= -f2- | tr -d '"')
curl -s -H "Authorization: Bearer $K" https://api.kie.ai/api/v1/chat/credit   # {"code":200,"data":<credits>}

fal billing isn't readable with our key: read the model's fal page price before quoting (registry rule). All three went dry on 10-03; check before promising any render.

Monthly fal catalog refresh + per-role diff (free GETs; {"models":[...],"next_cursor":...,"has_more":bool} verified 10-06):

python3 - <<'EOF'
import json, subprocess, datetime as dt
from pathlib import Path
ws = Path.home() / ".openclaw/workspace"
key = next(l.split("=", 1)[1].strip().strip('"') for l in (ws / ".env").read_text().splitlines() if l.startswith("FAL_KEY="))
out, cur = [], None
while True:
    url = "https://api.fal.ai/v1/models?limit=100" + (f"&cursor={cur}" if cur else "")
    d = json.loads(subprocess.run(["curl", "-s", "-H", f"Authorization: Key {key}", url], capture_output=True, text=True).stdout)
    out += d["models"]; cur = d.get("next_cursor")
    if not d.get("has_more"): break
dst = ws / f"output/model-catalog/fal-{dt.date.today()}.json"; dst.write_text(json.dumps(out)); print(dst, len(out))
EOF
# diff vs the previous pull, new endpoints grouped by category (map categories to registry roles by hand)
python3 -c "
import json,sys,collections; a,b=(json.load(open(f)) for f in sys.argv[1:3]); old={m['endpoint_id'] for m in a}
new=collections.defaultdict(list)
[new[m['metadata'].get('category')].append(m['endpoint_id']) for m in b if m['endpoint_id'] not in old]
[print(c, len(v), *v[:15], sep='\n  ') for c,v in sorted(new.items())]" output/model-catalog/fal-2026-10-06.json output/model-catalog/fal-<today>.json

Category → role map for the diff: image-to-video / text-to-video → stylized, photoreal, native-speech, cheap i2v; video-to-video → motion transfer/recast, extend, edit; audio-to-video → lip-sync; text-to-image / image-to-image → image gen/edit; text-to-speech, text-to-audio → TTS, music, SFX. New candidates go into the registry's "Newer candidates" column; nothing becomes a pick without the registry's one-unit proof. Editing the registry is the registry owner's job: propose the rows to Fish.

1. Brand-DNA intake (free, read-only)

python3 scripts/brand_dna.py https://dawnbands.com/products/dawn-morning-recovery-band     # [--no-images] [--max-images N] [--truth <md>]
cp output/brand-dna/dawn-wake-band/brand-dna.json $B/

brand-dna.json (schema brand-dna/1): source_url, canonical_url, brand{name,vendor,meta_description,og_*}, product{title,handle,tags,description_text,options,variants[{title,sku,price,compare_at_price,availability}],price_min,price_max,currency}, images[{position,src,alt,local,flags}], colors{css_vars,accents_by_frequency,neutrals_by_frequency}, fonts{font_faces,declared_by_frequency}, copy{headlines,paragraphs}, reviews{count_label,aggregate_rating,snippets}, warnings[], sources{}.

Test run 10-06 on the URL above (no paid call): redirected to /products/dawn-wake-band; Dawn Wake Band, brand Dawn Sleep, $44.99 (compare $85.00), variants Black/Blue/Pink/Orange; 14 images downloaded, images 1 and 11-14 flagged lit-display; accents #2F4A63 (primary navy), #9EC7E3, #00B67A (review star); fonts Dawn Sans, Dawn Text, Inter; "1,242 Reviews", 7 review snippets; 3 warnings (redirect, lit images, price 44.99 vs product-truth 39.99).

How each field is used downstream: | Field | Used for | Never for | |---|---|---| | canonical_url | every ad link + Shop Now (check_links expects it) | — | | images[].local without lit-display | product refs, cartoonified product sheets, real-cutout composites on tier-A units | i2v start frames showing a lit face; a generated band frame that skipped ugc-omni's pick --product gate | | colors, fonts | end card, captions, statics brand lock (cross-check brand-kit.md) | inventing a new palette | | product.price_* | offer line at the CTA (live wins) | editing product-truth.md | | copy.headlines | product-angle axis candidates (benefit list) | hooks (hooks come from VOC) | | reviews.snippets | ideas for proof beats; check against VOC banks | "verbatim customer line" slot of the concept formula |

2. Dissect the reference (one Gemini call)

python3 scripts/ad_dissector.py <video.mp4|URL> --slug <slug> --brand-context "Dawn Band: silent vibrating wrist alarm for teens who sleep through alarms; mom buyer" [--competitor]

Reads: pacing.json (duration, cuts/10 s, mean shot, wps if ELEVENLABS_SCRIBE_KEY), dissect.json (hook, beats with start/end/role/visual/said-paraphrase/speaker, persuasion, production read, rebuild recipe). Source-type decision and the 80/20 innovation: format-ad-clone. --competitor marks the md as a gap map.

3. Scene chunking → scenes.json (free; scripts/ugc_tools/scene_chunker.py, built 10-06)

python3 scripts/ugc_tools/scene_chunker.py --script <approved script.txt|.json|cartoon-h3 lines.json> --lane ugc-omni|h3|yapper|seedance \
  --subject "the mom" [--dissect output/ad-dissect/<slug>/dissect.json] [--refs refs.json] --batch <slug> --parent "<ad>" --cpa N --pdp <canonical_url> --out $B/scenes.json

It applies rules 2-7 below (lane caps, <1.2 s merge, split-not-speed, tiers, hook units body=false, ref map → one action → one camera → positive constraints → quality → clean-footage clause, 60-100 words, pronoun-swap warning) and flags the reveal cap vs the dissect. Claude then fills each <FILL> slot and clears issues/warnings. Rules: 1. Start from the APPROVED script (not the reference's words). The dissect supplies beat order, beat lengths and pacing targets. 2. Merge beats until a unit hits its lane cap: H3 ≤15 s and ≤4 panels (≤3 on product/CTA); ugc-omni one script line per A-roll take in a 4/6/8/10 s bucket; Seedance per the registry row. Beats under ~1.2 s never get their own unit. 3. Tier every unit (core §2b): A = hook's first 3 s, band on wrist/in hand, CTA, 3+ people; B = one-two people acting; C = establishing, empty rooms, clocks, inserts. 4. Words per unit: EL narrator ≥3.4 wps floor; omni native ~2.2-2.7 wps (measured 10-06); never speed a line to fit: split it. 5. Reference map first (prompted lanes: Seedance/Kling/Flux-style multi-ref; Omni and H3 take refs by slot): one line per ref saying its single job and what to preserve ("@image1 = product: matte black ribbed band, flush dark display, keep proportions"; "@image2 = actor identity: face, hair"). One environment ref, not three rooms; no two product versions. From a video ref, name what to borrow ("camera move only"). 6. Shot grammar: one main action and one camera instruction per shot, subject motion and camera motion in separate sentences; the same subject noun all the way through ("the mom", never "she"/"the woman" swaps); the critical instruction first (models follow the first 2-3 reliably); ~60-100 words per unit; constraints as positive statements ("Face stable, no deformation. Natural hand structure."), never a negative-prompt list. Close with a clean-footage line: no captions, subtitles, text, logos or end card (Remotion adds them). 7. Seams: every join between shots is cut (separate units, cut in ffmpeg/Remotion) or stated (shots batched in one unit with the transition written out; an unstated seam comes back as a model-picked cut). Last-frame chaining across units is not used on photoreal people (the keyframe chain Fish closed 10-05). 8. Not adopted from the same guides: the "cinematic 4K quality suffix" on UGC units (pushes a commercial look; UGC wants phone texture, natural light, restrained movement), "generate 2-4 variants" (we run --candidates 1), blurred face refs (filter bypass).

Schema:

{"batch": "<slug>", "parent": {"ad": "<name>", "cpa": 0}, "format_skill": "ugc-omni|format-podcast|cartoon-h3",
 "pdp": "<canonical_url>", "units": [
  {"id": "U1", "role": "hook", "tier": "A", "lane": "ugc-omni", "registry_role": "Photoreal / multi-ref video with locked character",
   "seconds": 6, "line": "<exact spoken line>", "body": false, "seam_out": "cut|stated",
   "ref_map": {"<ref>": "<its one job + what to preserve>"},
   "action": "<who, doing what, framing, where the band is>",
   "audio": {"voice": "<actor voice lock>", "room_tone": "<room>", "delivery": "<how it's said>"},
   "quality": "<capture device, light, aspect 9:16>", "refs": ["<brand-dna images/NN or actor frame>"]}]}

Prompt block template (ILLUSTRATIVE wording, ours): Action: "Mom, late 30s, sits on the bottom stair in a dim hallway at 6:40, phone in one hand, looks up the stairs, then into the lens." Audio: "Tired, flat mom voice, hallway echo, a muffled alarm upstairs under the line. Delivery: like telling a friend, not performing. Line: ''." Quality: "Front phone camera, arm's length, window light from the left, slight handheld drift, 9:16." Lock the product, wrist and words; leave camera moves to the model.

4. Variants → variants.json (free)

{"base": "U1-U6 as approved", "parent": "<ad + CPA>", "constant": "body units U3-U6, actor A, setting kitchen, product angle 'wakes by touch'",
 "variants": [
  {"id": "v01-hook-h2", "axis": "hook", "value": "<H2 line + first-3s action>", "regen_units": ["U1"], "reuse_units": ["U2", "U3", "U4", "U5", "U6"]},
  {"id": "v02-actor-b", "axis": "actor", "value": "actor B (locked ref frame)", "regen_units": ["U1", "U2", "U3", "U4", "U5", "U6"], "reuse_units": []}]}

Axis rules: hook regenerates only the hook unit(s) and re-uses the body; delivery keeps every word and regenerates the on-camera units with one delivery change in the Audio block (one lock per delivery on omni); actor regenerates every on-camera unit with a new locked character (new omni lock per actor) and keeps lines, setting and angle; setting swaps the environment only; product_angle swaps which benefit/mechanism unit leads (lines from claims-register.md, mechanism lines verbatim from core §1). Prompt variables: write the base prompt once with {setting} / {actor} placeholders and expand per variant (his variable mode), so the diff between variants is exactly the variable. Per lane: cartoon-h3 hooks × leads = python3 -m scripts.cartoon_h3.matrix --base <pack.md> --variants <variants.md> [--write]; style as the variable = python3 -m scripts.cartoon_h3.reskin; ugc-omni hooks = extra lines with "hook": "H2" in job.json then omni.py assemble --hook H2; statics = one spec per variant run with gen_statics.py <spec> --only <slugs>.

5. Draft on the hub + Fish's go (free)

WS=~/.openclaw/workspace
cd ~/ad-batch-hub && git pull --rebase --autostash
python3 stage-session.py $WS/$B --brand "Dawn Band" --type ugc --draft --notes "<constant · variable · parent CPA>" --push
curl -s https://ad-batch-hub.dragopower21.workers.dev/api/reviews/<session-id>        # verdicts; missing key = unreviewed

python3 scripts/ugc_tools/batch_variation.py plan --base $B/scenes.json --axis <axis> --values values.json --price <lane>=<$>/s [--base-generated] [--regen-allowance 2] [--draft-passes N] writes variants/<id>/scenes.json (regen units changed, the rest reuse_from: base) and $B/manifest.json with the quote and "go": null. Quote the batch: units to generate × lane price (omni cost --job, seedance runner --dry-run total, h3 = run.py --dry-run quote in tiers.md, h3.py FAL_PRICE fixed 10-06) + regen allowance + any draft passes (step 6). Fish's go in chat → python3 scripts/ugc_tools/batch_variation.py approve --manifest $B/manifest.json --quote-usd N --message "<his words>" (writes "go": {"by", "at", "quote_usd", "message", "variants_sha"}). Before step 6: batch_variation.py check --manifest $B/manifest.json (exit 2 = no go, quote grew, or variants changed after the go). No go, no step 6. A go covers this batch only.

6. Fan-out (PAID, after the go)

Smoke one unit of the base first; Fish's eye; then the rest. Shared body before hooks.

# photoreal talking head + b-roll = ugc-omni G0-G4 (dry run unless --spend); job shape: references/omni-job.example.json
O=scripts/ugc_omni/omni.py; J=output/ugc_omni/<slug>
python3 $O board --job $J; python3 $O cost --job $J                                  # G0 (free)
python3 $O refscan --job $J --src <real creator video>; python3 $O ref --job $J --pick $J/ref/<cand>.jpg
python3 $O avatar --job $J --n 3 --arms gpt2,sd5 --spend; python3 $O pick --job $J --name avatar --file <png>      # G1
python3 $O variant --job $J --name holding --product --arms gpt2,sd5 --prompt "<...Display off: ...>" --spend
python3 $O pick --job $J --name holding --product --file <png>                      # refuses <7/10 / wrong display
python3 $O lock --job $J --line H1-1 --n 3 --spend; python3 $O lock --job $J --approve <take>     # G2 (= the smoke)
python3 $O gen --job $J --lines all --spend; python3 $O verify --job $J                       # G3
python3 $O broll --job $J --id <b> --stage frame --arms gpt2,sd5 --spend; python3 $O pick --job $J --name broll_<b> [--product] --file <png>
python3 $O broll --job $J --id <b> --stage anim --spend                                # real-footage fallback: copy to broll/<b>.mp4
python3 $O assemble --job $J --hook H1                                                  # G4, per hook variant
# Seedance b-roll on Higgsfield: SUBMITS UNLESS --dry-run. Quote first.
python3 scripts/seedance_broll_runner.py --prompts $B/seed-prompts.json --run-id <slug> --dry-run
python3 scripts/seedance_broll_runner.py --prompts $B/seed-prompts.json --run-id <slug> --scene-id <smoke> --no-discord
# single-take yapper = the same omni job with "mode": "oneshot" (no b-roll, no new frames, reveal via end_frame, anchor_every 3).
# H3 native voice (yapper.py) = side arm only, one variable vs the locked omni take; its keyframe chain stays closed (Fish 10-05).
# cartoon units: full cartoon-h3 gate sequence (vo → storyboard → approved → canvas → gen --prompts-only → gen --spend)
python3 -m scripts.cartoon_h3.run --pack <pack.md> <refs> --stage gen --spend --backend fal --model h3-max

Credit discipline (binding, feedback_higgsfield_credit_discipline): --candidates 1; ≤4 panels per H3 job; --regen N in its own call; never the Higgsfield UI Rerun; recover orphans (scripts/cartoon_h3/recover_jobs.py) instead of resubmitting; batch regen cap 8. Log every job (lane, role, model id, cost) into manifest.json.

7. QA

python3 scripts/ugc_omni/omni.py verify --job output/ugc_omni/<slug> --lines all        # transcript match, stutters, gaps, trim point
python3 scripts/gemini_qa.py --asset $B/final/<variant>.mp4 --type final --style "photoreal phone UGC" --duration <s>   # rubric is cartoon-flavoured: advisory only

Then eyes, frame by frame on every product unit: band side-on, wrist down, display dark, black ribbed silicone; any generated band passed ugc-omni's pick --product (else real footage); no smartwatch, no touchscreen; captions = script.

8. Review sheet + hub finalize

Log every take during step 6: python3 scripts/ugc_tools/batch_variation.py log --manifest $B/manifest.json --variant <id> --unit U1 --lane <lane> --model <id> --cost-usd N --status approved|rejected|failed --file <path> [--qa "<notes>"] (refuses without a go; enforces the batch regen cap of 8). Then python3 scripts/ugc_tools/review_sheet.py --manifest $B/manifest.json [--final <variant>=<final.mp4>] → $B/review.html (contact sheet, status-coloured takes) + $B/review.md: | variant | axis | value | file | len s | QA (verify / gemini / eyes) | takes to approved | cost $ (incl. failed takes) | cost / approved $ | Fish | |---|---|---|---|---|---|---|---|---|---| $B/ads.json per hub format: video (≤24 MB copy), id = variant id, hook, headline, primary, url = canonical_url, page_name "Dawn Sleep" (UGC/video default), ratio "9x16", concept = the axis + value.

python3 ~/ad-batch-hub/stage-session.py $WS/$B --finalize <session-id> --push

9. Launch handoff (outside this workflow)

media-buyer owns it: own ad set per concept in Testing, one variable vs the parent, never judge <7 days, never pause. Every upload through scripts/meta_preflight.py (sanitize → _clean/), every creative payload through check_links(..., expect=canonical_url), then scripts/meta_link_audit.py <adset>. Human approval on every Meta write; never from a loop. Record project_launch_<date>_<slug>.md and the hub launch (stage-session.py --record-launch).

Worked example (ILLUSTRATIVE, not run)