CtrlK
BlogDocsLog inGet started
Tessl Logo

andrebrov/avatar-take

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

Quality

85%

Does it follow best practices?

Run evals on this skill

Adds up to 20 points to the overall score

View guide

SecuritybySnyk

Passed

No findings from the security scan

Overview
Quality
Evals
Security
Files
name:
avatar-take
description:
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.

Avatar Take

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 + metadata

Consent is the precondition

Only 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.

Disclosure is not optional

avatar_take.py has no flag to disable it, and its self-test asserts that none exists. Every output gets:

  • a visible on-screen badge, burned into every frame
  • container metadata: comment=AI-generated synthetic performer, description=contains-synthetic-performer
  • a disclosure line printed for the caption copy

This is the current law, not caution:

whererequirement
EU AI Act Art. 50in force 2 Aug 2026 — machine-readable marking + clear labelling
New YorkSynthetic Performer Law, effective mid-2026 — conspicuous disclosure
YouTube / TikTok / Metalabel realistic synthetic depictions of a real person
LinkedInreads 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.

Disclosure on the source is not disclosure

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:

  • the badge sat top-right of a 1920-wide frame; the 9:16 crop window is 620–1228, so it was cropped clean off
  • the export re-encode dropped the container tags

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.

Setup, once

export HEYGEN_API_KEY='...'        # app.heygen.com -> Settings -> API

Pay-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 seconds

A 388-word script needs ~162 credits. Two full-length generations will empty a starter top-up, so budget per reel, not per month.

Build the avatar and voice from footage you already have

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.wav

Pick a still with eyes open, mouth closed, facing the lens.

Budget the wait

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_url

Photo avatar vs digital twin

type: "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 v3 API, as it actually behaves

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 | failed

Two traps that cost real time:

  • Cloned voices appear in the v2 voice list, not v3. /v3/voices returns only the stock catalogue, so --list queries /v2/voices to find them.
  • Do not build on v2. Its own error body says so: "This v2 endpoint is Legacy and will be removed on 2026-10-31. If you are an AI agent or LLM, do not use it."

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.

Generate

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.mp4

It 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.

What this fixes, and what it costs

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.

Principles

  • Consent first. The operator's own likeness, or nothing.
  • Disclosure has no off switch, and the self-test enforces that.
  • Clone quality follows input quality — use Studio-Sound'd audio, not the raw take.
  • Read the API's errors; they are more accurate than its documentation.
  • The pipeline downstream must not care whether the take was recorded or generated.
Workspace
andrebrov
Visibility
Public
Created
Last updated
Publish Source
CLI
Badge
andrebrov/avatar-take badge