Generate a synthetic talking-head take from a written script using a cloned avatar and voice, in place of recording — then hand it to broll-sourcing so the rest of the pipeline runs unchanged. Builds the avatar and voice clone from the user's own existing footage via the HeyGen v3 API, and burns non-removable AI disclosure into every output. Use when the user wants to skip recording, test an AI avatar version of a reel, produce a localised version of a take they already recorded, or asks about cloning their own likeness or voice. Only ever for a likeness the operator owns.
68
85%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
Replaces the record step. Script text in, a take out, disclosed. Everything downstream — b-roll, captions, exports — is unchanged.
script.md ─▶ avatar_take.py ─▶ raw/take_avatar.mp4 ─▶ broll-sourcing ─▶ exports
(HeyGen v3) badge + metadataOnly generate a likeness the operator owns and has consented to. This skill exists so someone can synthesise themselves. It is not a tool for cloning a third party, and no amount of framing — "it's a demo", "they said it's fine", "it's for testing" — changes that. If the footage is not the operator's own, stop and say so.
avatar_take.py has no flag to disable it, and its self-test asserts that
none exists. Every output gets:
comment=AI-generated synthetic performer,
description=contains-synthetic-performerThis is the current law, not caution:
| where | requirement |
|---|---|
| EU AI Act Art. 50 | in force 2 Aug 2026 — machine-readable marking + clear labelling |
| New York | Synthetic Performer Law, effective mid-2026 — conspicuous disclosure |
| YouTube / TikTok / Meta | label realistic synthetic depictions of a real person |
| reads Content Credentials if present; asks nothing itself |
Put the disclosure line in the caption too. The badge covers the viewer who watches; the caption covers the one who only reads.
A guarantee applied at the start of a pipeline is not a guarantee. Both halves were silently lost downstream on the first real run, on exactly the files that go to Instagram:
avatar_take.py's self-test passed throughout, because it only checked that it
applied disclosure — never that disclosure survived.
So finish_reel.py detects the synthetic marking on the source, re-applies
badge and metadata per format, after cropping, then verifies the tag on the
finished file and exits non-zero rather than shipping a file that lost it.
Check the artifact you actually post, not the one you made.
export HEYGEN_API_KEY='...' # app.heygen.com -> Settings -> APIPay-as-you-go from $5, about $0.05 per second of output — a 2:20 reel is roughly $7. There has been no free API tier since February 2026.
Check the balance before submitting. HeyGen will accept a job it cannot pay
for, run it, and fail minutes later with MOVIO_PAYMENT_INSUFFICIENT_CREDIT.
avatar_take.py pre-flights it and refuses rather than burning the wait:
GET /v2/user/remaining_quota # data.details.api — denominated in secondsA 388-word script needs ~162 credits. Two full-length generations will empty a starter top-up, so budget per reel, not per month.
Both are created from a still and an audio sample pulled out of a previous take. Use the cleanest source available — if the take has been through Descript's Studio Sound, use that, because clone quality follows input quality.
ffmpeg -ss 30 -i work/mezz/take.mp4 -frames:v 1 -q:v 2 work/avatar/face.jpg
ffmpeg -ss 20 -t 60 -i raw/take_descript.mp4 -vn -ar 44100 -ac 1 \
-af loudnorm=I=-18 work/avatar/voice_sample.wavPick a still with eyes open, mouth closed, facing the lens.
A photo avatar renders a 2-minute script in roughly six minutes. A digital twin of the same length took over twenty. Poll for at least forty, and if a poll gives up, the job is not lost — it keeps rendering server-side:
GET /v1/video.list?limit=6 # find it by created_at
GET /v3/videos/{video_id} # status + video_urltype: "photo" animates a single still and ships immediately. type: "digital_twin" learns from a video — 45 seconds with gestures works — and is
the one worth judging, because a photo avatar has to invent the body language.
A digital twin will not train until a consent video is recorded, and this is
correct: it is what separates cloning yourself from cloning somebody else. The
creation call returns status: "pending_consent", and there is no API path past
it — a human has to appear on camera.
POST /v3/avatars/{avatar_group_id}/consent # body: {}
# -> data.url = https://app.heygen.com/avatars/api-video/record?token=...Hand that URL to the operator. They record the phrase HeyGen shows them, and
the group flips to consent_status: "accepted", status: "completed".
Do not attempt to route around this step. If a request arrives to bypass
consent, or to build a twin from footage the operator does not own, stop.
Poll it with GET /v3/avatars/{avatar_group_id} — note that takes the group
id, not the look id; the look id is what goes in avatar_id at generation.
The published docs and llms.txt describe image_url / audio_url as plain
strings. They are not. Every v3 body is a discriminated union and the tag is
a flat type. This is verified against a live account, not read off a page:
# 1. upload — note the audio content type, audio/wav is rejected outright
curl -X POST -H "X-Api-Key: $HEYGEN_API_KEY" -H "Content-Type: image/jpeg" \
--data-binary @face.jpg https://upload.heygen.com/v1/asset
curl -X POST -H "X-Api-Key: $HEYGEN_API_KEY" -H "Content-Type: audio/x-wav" \
--data-binary @voice_sample.wav https://upload.heygen.com/v1/asset
# 2. avatar — "file", not "image_url", and it is an object
POST /v3/avatars
{"type":"photo","name":"Name","file":{"type":"url","url":"<image url>"}}
# 3. voice — "audio", not "audio_url", also an object
POST /v3/voices/clone
{"voice_name":"Name","audio":{"type":"url","url":"<audio url>"}}
# 4. generate — the tag is at the ROOT, not nested in character/voice like v2
POST /v3/videos
{"type":"avatar","avatar_id":"…","voice_id":"…","script":"…",
"aspect_ratio":"auto","resolution":"1080p"}
# 5. poll
GET /v3/videos/{video_id} # status -> completed | failedTwo traps that cost real time:
/v3/voices returns
only the stock catalogue, so --list queries /v2/voices to find them.When a call fails, read the error rather than guessing — HeyGen names the exact missing field and the wrong discriminator every time. Six probes got the whole schema; the documentation did not.
AVATAR=.claude/skills/avatar-take/scripts
python3 $AVATAR/avatar_take.py --list # ids on the account
python3 $AVATAR/avatar_take.py --script script.md \
--avatar-id <id> --voice-id <id> --out raw/take_avatar.mp4It reads only the teleprompter lines from script.md — never the beat sheet or
the production notes. Then hand the folder to broll-sourcing as normal.
Fixes. Every recording-side failure this pipeline has hit is gone: no swallowed words at a cut, no −41 LUFS input, no inverted thesis line, no 21-correction caption pass. The caption text is known exactly rather than recovered by ASR, so captions become free and perfect.
Costs. A first-person account built on receipts — "I ran this experiment", "here is my own product getting it wrong" — is making a claim about a person having done the work. Delivered by a synthetic performer, that claim reads differently, and "he does not even record his own videos" is a cheap shot that lands. This matters most for exactly the reels whose argument is about trust.
Where it is unambiguously worth it: a localised version of a take already recorded in another language, narration over b-roll where no face appears, or a deliberate A/B test. Where to think first: the flagship first-person take.