ugc-fixloop
the UGC fix-loop toolkit — surgically re-render a bad window/beat of a single-take UGC master (stitch_replacement.py, pure FFmpeg) and GPT cross-model review a Seedance prompt before render (vet_seedance_prompt.py, routed through the openai-proxy). Fetch it into a one-shot UGC re
Install
npx skills add https://github.com/gooseworks-ai/goose-skills/tree/main/skills/ads/capabilities/ugc-fixloop
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
ugc-fixloop
The UGC fix-loop toolkit. The one-shot UGC video recipes (create-ugc-*-video-from-refs)
render a single continuous Seedance 2.0 reference-to-video master with native lip-synced
audio. This capability ships the two scripts those recipes run, so they exist on the remote
machine (fetched into /tmp/gooseworks-scripts/ugc-fixloop/).
Any re-render of a replacement clip goes through the same proxy path the recipe uses for the take (
create-video-fal/ fal-proxy), NEVER a directfal.runcall.
Env / deps
stitch_replacement.py— no API key, no network. Needsffmpeg+ffprobeon PATH (all local FFmpeg).vet_seedance_prompt.py— routes through the GooseWorks openai-proxy (<api_base>/api/internal/openai-proxy/v1/chat/completions), reading creds from~/.gooseworks/credentials.json— no direct OpenAI call, no local key; the call bills the Ads agent. Exits 3 if the proxy/creds are unavailable so the recipe can fall back to an inline self-review (the vet is advisory, not a gate).
Run — vet_seedance_prompt.py (GPT cross-model prompt review)
A deliberately NON-Claude second opinion on the Seedance prompt before you spend the render (Claude reviewing its own prompt is a weaker signal). Takes the prompt as an argument:
vet_seedance_prompt.py --prompt-file working/seedance-prompt.txt \
[--brief "one-line intent"] [--refs "@Image1=avatar; @Image2=product; @Image3=env"] \
[--words 28] [--out working/seedance-review.md]
Prints + saves the structured review (verdict, line edits, word budget, consistency risk).
Run — stitch_replacement.py (surgical beat/window swap, deterministic)
Replaces one segment of the master on the VIDEO track only; the master's audio (VO + ambience) plays straight through, so lip-sync on talking beats is never touched. Output is re-encoded H.264 / yuv420p at the master's fps + resolution.
Required: --master M.mp4 --replacement R.mp4 --output O.mp4. Pick the window ONE of two ways:
# By beat (1-indexed segment between auto-detected scene cuts):
stitch_replacement.py --master M.mp4 --replacement R.mp4 --output O.mp4 --replace-beat 2
# By explicit window (seconds):
stitch_replacement.py --master M.mp4 --replacement R.mp4 --output O.mp4 \
--window-start 4.21 --window-end 8.75 --fit stretch
All args:
--master(required) — the single-take master mp4.--replacement(required) — the re-rendered silent replacement clip (generated viacreate-video-fal).--output(required) — output mp4 path.--window-start/--window-end(float seconds) — explicit hole to replace.--replace-beat(int, 1-indexed) — pick the segment between detected scene cuts.--scene-threshold(float, default0.3) — scene-cut sensitivity for--replace-beat.--fit {stretch,trim,freeze}(defaultstretch) — reconcile replacement length to the hole.--dry-run— print the ffmpeg command without running.
Warns if output duration drifts >0.15s from the master (audio-sync check).
Files (goose-skills)
-
scripts
-
stitch_replacement.py 6.4 KB
#!/usr/bin/env python3 """Surgically replace one segment of a master UGC clip with a re-rendered replacement clip, preserving the master's continuous audio. This is the deterministic core of the create-ugc-product-video-from-refs fix loop. The master is a single Seedance reference-to-video render with native lip-synced audio. When one internal beat drifts (a bad swing, a morphing product, a contorted body), we re-render JUST that beat as a short silent clip and swap it in on the VIDEO TRACK ONLY — the master's audio (VO + ambience) plays straight through, so lip-sync on the talking beats is never touched. Two ways to choose the window to replace: 1. Explicit: --window-start S --window-end E (seconds) 2. By beat: --replace-beat N (1-indexed segment between scene cuts; cuts are auto-detected with --scene-threshold) The replacement is almost never exactly the hole length. --fit reconciles it: stretch (default) — time-scale the replacement to fill the hole exactly (slight slow/fast motion; flatters athletic motion) trim — cut the replacement to the hole length (drops the tail) freeze — play the replacement, then hold its last frame to fill Output is re-encoded H.264 / yuv420p at the master's fps and resolution so the seams are clean and the file plays everywhere. Usage: stitch_replacement.py --master M.mp4 --replacement R.mp4 --output O.mp4 \ --replace-beat 2 stitch_replacement.py --master M.mp4 --replacement R.mp4 --output O.mp4 \ --window-start 4.21 --window-end 8.75 --fit stretch """ from __future__ import annotations import argparse import json import subprocess import sys def run(cmd: list[str]) -> str: p = subprocess.run(cmd, capture_output=True, text=True) if p.returncode != 0: sys.exit(f"FATAL: command failed: {' '.join(cmd)}\n{p.stderr}") return p.stdout def probe(path: str) -> dict: out = run([ "ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height,r_frame_rate", "-show_entries", "format=duration", "-of", "json", path, ]) d = json.loads(out) s = d["streams"][0] num, den = s["r_frame_rate"].split("/") return { "w": int(s["width"]), "h": int(s["height"]), "fps": float(num) / float(den), "dur": float(d["format"]["duration"]), } def scene_cuts(path: str, threshold: float) -> list[float]: """Return sorted internal cut timestamps (excludes 0 and end).""" p = subprocess.run( ["ffmpeg", "-nostdin", "-hide_banner", "-i", path, "-filter_complex", f"select='gt(scene,{threshold})',metadata=print:file=-", "-an", "-f", "null", "-"], capture_output=True, text=True, ) out = (p.stdout or "") + "\n" + (p.stderr or "") # file=- can land on either cuts = [] for line in out.splitlines(): if "pts_time:" in line: try: cuts.append(float(line.split("pts_time:")[1].split()[0])) except (IndexError, ValueError): pass return sorted(cuts) def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--master", required=True) ap.add_argument("--replacement", required=True) ap.add_argument("--output", required=True) ap.add_argument("--window-start", type=float) ap.add_argument("--window-end", type=float) ap.add_argument("--replace-beat", type=int, help="1-indexed segment between detected scene cuts") ap.add_argument("--scene-threshold", type=float, default=0.3) ap.add_argument("--fit", choices=["stretch", "trim", "freeze"], default="stretch") ap.add_argument("--dry-run", action="store_true") args = ap.parse_args() m = probe(args.master) r = probe(args.replacement) total = m["dur"] # Resolve the replacement window. if args.window_start is not None and args.window_end is not None: start, end = args.window_start, args.window_end elif args.replace_beat is not None: cuts = scene_cuts(args.master, args.scene_threshold) bounds = [0.0] + cuts + [total] n = args.replace_beat if n < 1 or n >= len(bounds): sys.exit(f"FATAL: --replace-beat {n} out of range; " f"detected {len(bounds)-1} segments at cuts {cuts}") start, end = bounds[n - 1], bounds[n] print(f"[stitch] detected cuts {', '.join(f'{c:.2f}' for c in cuts)}") print(f"[stitch] beat {n} → window {start:.2f}-{end:.2f}s") else: sys.exit("FATAL: pass --window-start/--window-end or --replace-beat") hole = end - start if hole <= 0: sys.exit(f"FATAL: empty window {start:.2f}-{end:.2f}") print(f"[stitch] hole={hole:.3f}s replacement={r['dur']:.3f}s fit={args.fit}") # Build the replacement video filter to fit the hole exactly. if args.fit == "stretch": factor = hole / r["dur"] rep = f"trim=0:{r['dur']:.3f},setpts=(PTS-STARTPTS)*{factor:.5f}" elif args.fit == "trim": rep = f"trim=0:{min(hole, r['dur']):.3f},setpts=PTS-STARTPTS" else: # freeze rep = (f"trim=0:{min(hole, r['dur']):.3f},setpts=PTS-STARTPTS," f"tpad=stop_mode=clone:stop_duration={max(0.0, hole - r['dur']):.3f}") W, H, FPS = m["w"], m["h"], m["fps"] sc = f"scale={W}:{H},setsar=1,fps={FPS:g}" fc = ( f"[0:v]trim=0:{start:.3f},setpts=PTS-STARTPTS,{sc}[a];" f"[1:v]{rep},{sc}[b];" f"[0:v]trim={end:.3f}:{total:.3f},setpts=PTS-STARTPTS,{sc}[c];" f"[a][b][c]concat=n=3:v=1:a=0[outv]" ) cmd = [ "ffmpeg", "-nostdin", "-loglevel", "error", "-y", "-i", args.master, "-i", args.replacement, "-filter_complex", fc, "-map", "[outv]", "-map", "0:a?", "-c:v", "libx264", "-pix_fmt", "yuv420p", "-r", f"{FPS:g}", "-c:a", "aac", "-b:a", "192k", args.output, ] if args.dry_run: print("[stitch] DRY RUN — would run:\n " + " ".join(cmd)) return 0 run(cmd) o = probe(args.output) print(f"[stitch] wrote {args.output} {o['w']}x{o['h']} {o['fps']:g}fps {o['dur']:.2f}s") if abs(o["dur"] - total) > 0.15: print(f"[stitch] WARNING: output {o['dur']:.2f}s vs master {total:.2f}s " f"(>0.15s drift — check audio sync)") return 0 if __name__ == "__main__": raise SystemExit(main()) -
vet_seedance_prompt.py 5.2 KB
#!/usr/bin/env python3 """Cross-model review of a Seedance 2.0 reference-to-video prompt by GPT — a deliberately NON-Claude second opinion before you spend the render. Portable: routes through the GooseWorks **openai-proxy** (bills the Ads agent, real key never touches this machine) — never a direct api.openai.com call. Reads creds from ~/.gooseworks/credentials.json (the CLI writes it). Takes the prompt to review as an ARGUMENT (nothing baked in). vet_seedance_prompt.py --prompt-file working/seedance-prompt.txt \ [--brief "one-line intent"] [--refs "@Image1=avatar; @Image2=product; @Image3=env"] \ [--words 28] [--model gpt-5.5] [--out working/seedance-review.md] Exit 0 + prints/saves the review. Exit 3 if the proxy/creds are unavailable (so the recipe can fall back to an inline self-review — the review is advisory, not a gate). """ import argparse import json import os import pathlib import sys import urllib.request import urllib.error from urllib.parse import urlencode SYSTEM_PROMPT = """\ You are a senior AI-video prompt engineer who has shipped hundreds of short-form UGC ads on ByteDance Seedance 2.0, Kling, and Veo. You know Seedance 2.0 reference-to-video intimately: how it binds reference images addressed as @Image1/@Image2/@Image3, native lip-synced dialogue (generate_audio=true), where it drifts (identity, product geometry, small text, multi-cut consistency over a single ~15s render), and how spoken-word budget maps to clip duration. You have strong opinions and you don't hedge — when you spot a problem you name it and give the specific rewrite, not vague encouragement.""" USER_TEMPLATE = """\ Review this Seedance 2.0 reference-to-video prompt for a single vertical (9:16) UGC ad before we render it. {brief}{refs}Native lip-sync means a tight spoken-word budget — our rule of thumb is ~{words} words for a ~15s clip; push back if you disagree. # The Seedance prompt to review --- {prompt} --- # What I want (be concrete; don't soft-pedal — if it will produce mush, say why) 1. **Verdict** — ship-as-is / ship-with-tweaks / needs-rewrite, one sentence. 2. **Specific line edits** — quote the phrase, give the replacement, why in <=15 words. Focus on what changes Seedance's output: @Image ref binding, camera-switch language, how hard cuts are signalled, consistency anchors, wording that invites drift. 0-8 edits. 3. **Word/duration budget** — will every spoken line land at ~15s with the cuts, or cut a line? which? 4. **Consistency risk** — rank the top 3 things most likely to drift across cuts (face, colors, product geometry, small text, camera switch) + the single best prompt-side mitigation for each. 5. **Reusability note** — 1-2 rules to bake into the general prompt recipe so the next brief inherits them.""" def _cfg(): p = pathlib.Path(os.path.expanduser("~/.gooseworks/credentials.json")) if not p.exists(): sys.exit(3) # no creds → recipe falls back to inline self-review c = json.loads(p.read_text()) return c["api_base"].rstrip("/"), c["api_key"], c.get("agent_id") def main(): ap = argparse.ArgumentParser() ap.add_argument("--prompt-file", help="path to the Seedance prompt to review") ap.add_argument("--prompt", help="the Seedance prompt inline (alternative to --prompt-file)") ap.add_argument("--brief", default="") ap.add_argument("--refs", default="") ap.add_argument("--words", type=int, default=28) ap.add_argument("--model", default="gpt-5.5") ap.add_argument("--out", default="working/seedance-review.md") a = ap.parse_args() prompt = a.prompt or (pathlib.Path(a.prompt_file).read_text() if a.prompt_file else "") if not prompt.strip(): sys.exit("give the prompt via --prompt-file or --prompt") api_base, tok, agent = _cfg() user = USER_TEMPLATE.format( prompt=prompt.strip(), words=a.words, brief=(a.brief.strip() + " ") if a.brief else "", refs=("Reference slots: " + a.refs.strip() + ". ") if a.refs else "", ) body = {"model": a.model, "messages": [ {"role": "system", "content": SYSTEM_PROMPT}, {"role": "user", "content": user}, ]} q = urlencode({"token": tok, **({"agent_id": agent} if agent else {})}) url = f"{api_base}/api/internal/openai-proxy/v1/chat/completions?{q}" req = urllib.request.Request(url, data=json.dumps(body).encode(), headers={"Content-Type": "application/json"}, method="POST") try: with urllib.request.urlopen(req, timeout=180) as resp: out = json.loads(resp.read().decode()) except urllib.error.HTTPError as e: print(f"HTTP {e.code} {e.reason}\n{e.read().decode(errors='replace')}", file=sys.stderr) sys.exit(3) # proxy error → advisory step, let the recipe self-review inline except Exception as e: print(f"openai-proxy unreachable: {e}", file=sys.stderr) sys.exit(3) review = out["choices"][0]["message"]["content"] outp = pathlib.Path(a.out) outp.parent.mkdir(parents=True, exist_ok=True) outp.write_text(review) print(f"[vet] model={out.get('model')} usage={out.get('usage', {})} saved={outp}\n") print(review) if __name__ == "__main__": main()
-
-
tests
-
smoke-test.md 1.8 KB
# ugc-fixloop smoke test ## Purpose Confirm the fix-loop toolkit fetches and both scripts are runnable on a fresh machine. ## Steps 1. **Fetch the capability.** From a one-shot UGC recipe's fix-loop step, run: ``` gooseworks fetch ugc-fixloop ``` Expect the scripts under `/tmp/gooseworks-scripts/ugc-fixloop/scripts/`. 2. **stitch_replacement.py present + arg-parses.** ``` python3 /tmp/gooseworks-scripts/ugc-fixloop/scripts/stitch_replacement.py --help ``` Expect the usage block listing `--master`, `--replacement`, `--output`, `--window-start`, `--window-end`, `--replace-beat`, `--scene-threshold`, `--fit`, `--dry-run`. Requires `ffmpeg` + `ffprobe` on PATH (no API key). 3. **stitch dry-run** (with any two short mp4s): ``` python3 /tmp/gooseworks-scripts/ugc-fixloop/scripts/stitch_replacement.py \ --master M.mp4 --replacement R.mp4 --output O.mp4 --replace-beat 1 --dry-run ``` Expect a printed ffmpeg command and exit 0; no file written. 4. **vet_seedance_prompt.py present.** ``` python3 /tmp/gooseworks-scripts/ugc-fixloop/scripts/vet_seedance_prompt.py ``` On a machine WITHOUT `/Users/0xhbam/Desktop/Cursor/gtm-goose/.env` (cloud/CI/teammate), expect a clean `FATAL: ... not found` exit — this script is a direct-OpenAI, operator-machine-only helper and the fix loop should skip it there. On the operator machine with `OPENAI_API_KEY` set in that env file, expect a written `gpt55_seedance_review.md` and the review printed to stdout. ## Pass criteria - Both scripts resolve under `/tmp/gooseworks-scripts/ugc-fixloop/scripts/` after fetch. - `stitch_replacement.py --help` and `--dry-run` succeed with no network/API dependency. - `vet_seedance_prompt.py` either produces a review (key present) or exits FATAL cleanly (key/env absent) — it never blocks the fix loop.
-
-
SKILL.md 3.4 KB
--- name: ugc-fixloop description: the UGC fix-loop toolkit — surgically re-render a bad window/beat of a single-take UGC master (stitch_replacement.py, pure FFmpeg) and GPT cross-model review a Seedance prompt before render (vet_seedance_prompt.py, routed through the openai-proxy). Fetch it into a one-shot UGC recipe so both scripts resolve on any machine and the vet call bills the Ads agent. status: active --- # ugc-fixloop The UGC fix-loop toolkit. The one-shot UGC video recipes (`create-ugc-*-video-from-refs`) render a single continuous Seedance 2.0 reference-to-video master with native lip-synced audio. This capability ships the two scripts those recipes run, so they exist on the remote machine (fetched into `/tmp/gooseworks-scripts/ugc-fixloop/`). > Any re-render of a replacement clip goes through the same proxy path the recipe uses for > the take (`create-video-fal` / fal-proxy), NEVER a direct `fal.run` call. ## Env / deps - **`stitch_replacement.py`** — no API key, no network. Needs **`ffmpeg` + `ffprobe` on PATH** (all local FFmpeg). - **`vet_seedance_prompt.py`** — routes through the GooseWorks **openai-proxy** (`<api_base>/api/internal/openai-proxy/v1/chat/completions`), reading creds from `~/.gooseworks/credentials.json` — **no direct OpenAI call, no local key**; the call **bills the Ads agent**. Exits 3 if the proxy/creds are unavailable so the recipe can fall back to an inline self-review (the vet is advisory, not a gate). ## Run — vet_seedance_prompt.py (GPT cross-model prompt review) A deliberately NON-Claude second opinion on the Seedance prompt before you spend the render (Claude reviewing its own prompt is a weaker signal). Takes the prompt as an argument: ``` vet_seedance_prompt.py --prompt-file working/seedance-prompt.txt \ [--brief "one-line intent"] [--refs "@Image1=avatar; @Image2=product; @Image3=env"] \ [--words 28] [--out working/seedance-review.md] ``` Prints + saves the structured review (verdict, line edits, word budget, consistency risk). ## Run — stitch_replacement.py (surgical beat/window swap, deterministic) Replaces one segment of the master on the VIDEO track only; the master's audio (VO + ambience) plays straight through, so lip-sync on talking beats is never touched. Output is re-encoded H.264 / yuv420p at the master's fps + resolution. Required: `--master M.mp4 --replacement R.mp4 --output O.mp4`. Pick the window ONE of two ways: ``` # By beat (1-indexed segment between auto-detected scene cuts): stitch_replacement.py --master M.mp4 --replacement R.mp4 --output O.mp4 --replace-beat 2 # By explicit window (seconds): stitch_replacement.py --master M.mp4 --replacement R.mp4 --output O.mp4 \ --window-start 4.21 --window-end 8.75 --fit stretch ``` All args: - `--master` (required) — the single-take master mp4. - `--replacement` (required) — the re-rendered silent replacement clip (generated via `create-video-fal`). - `--output` (required) — output mp4 path. - `--window-start` / `--window-end` (float seconds) — explicit hole to replace. - `--replace-beat` (int, 1-indexed) — pick the segment between detected scene cuts. - `--scene-threshold` (float, default `0.3`) — scene-cut sensitivity for `--replace-beat`. - `--fit {stretch,trim,freeze}` (default `stretch`) — reconcile replacement length to the hole. - `--dry-run` — print the ffmpeg command without running. Warns if output duration drifts >0.15s from the master (audio-sync check). -
skill.meta.json 259 B
{ "slug": "ugc-fixloop", "category": "capabilities", "domain": "ads", "tags": ["ads"], "installation": { "base_command": "npx goose-skills install ugc-fixloop", "supports": ["claude", "cursor", "codex"] }, "requires_skills": ["watch"] }
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.