
Heygen
- 356 installs
- 42.8k repo stars
- Updated July 24, 2026
- calesthio/openmontage
This is a copy of heygen by heygen-com - installs and ranking accrue to the original listing.
Use this skill when working with heygen.
About
Skill for working with heygen. Use when you need heygen functionality in your application.
- Specialized for heygen
- Integrated with Claude Code
- Streamlines workflow
Heygen by the numbers
- 356 all-time installs (skills.sh)
- Data as of Jul 28, 2026 (Skillselion catalog sync)
npx skills add https://github.com/calesthio/openmontage --skill heygenAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 356 |
|---|---|
| repo stars | ★ 42.8k |
| Last updated | July 24, 2026 |
| Repository | calesthio/openmontage ↗ |
What it does
Use this skill when working with heygen.
Files
HeyGen API (Deprecated)
This skill is deprecated. Use the focused skills instead:
- `create-video` — Generate videos from a text prompt (Video Agent API)
- `avatar-video` — Build videos with specific avatars, voices, scripts, and scenes (v2 API)
This skill remains for backward compatibility but will be removed in a future release.
---
AI avatar video creation API for generating talking-head videos, explainers, and presentations.
Tool Selection
If HeyGen MCP tools are available (mcp__heygen__*), prefer them over direct HTTP API calls — they handle authentication and request formatting automatically.
| Task | MCP Tool | Fallback (Direct API) |
|---|---|---|
| Generate video from prompt | mcp__heygen__generate_video_agent | POST /v1/video_agent/generate |
| Check video status / get URL | mcp__heygen__get_video | GET /v2/videos/{video_id} |
| List account videos | mcp__heygen__list_videos | GET /v2/videos |
| Delete a video | mcp__heygen__delete_video | DELETE /v2/videos/{video_id} |
If no HeyGen MCP tools are available, use direct HTTP API calls with X-Api-Key: $HEYGEN_API_KEY header as documented in the reference files.
Default Workflow
Prefer Video Agent for most video requests. Always use prompt-optimizer.md guidelines to structure prompts with scenes, timing, and visual styles.
With MCP tools: 1. Write an optimized prompt using prompt-optimizer.md → visual-styles.md 2. Call mcp__heygen__generate_video_agent with prompt and config (duration_sec, orientation, avatar_id) 3. Call mcp__heygen__get_video with the returned video_id to poll status and get the download URL
Without MCP tools (direct API): 1. Write an optimized prompt using prompt-optimizer.md → visual-styles.md 2. POST /v1/video_agent/generate — see video-agent.md 3. GET /v2/videos/<id> — see video-status.md
Only use v2/video/generate when user explicitly needs:
- Exact script without AI modification
- Specific voice_id selection
- Different avatars/backgrounds per scene
- Precise per-scene timing control
- Programmatic/batch generation with exact specs
Quick Reference
| Task | MCP Tool | Read |
|---|---|---|
| Generate video from prompt (easy) | mcp__heygen__generate_video_agent | prompt-optimizer.md → visual-styles.md → video-agent.md |
| Generate video with precise control | — | video-generation.md, avatars.md, voices.md |
| Check video status / get download URL | mcp__heygen__get_video | video-status.md |
| Add captions or text overlays | — | captions.md, text-overlays.md |
| Transparent video for compositing | — | video-generation.md (WebM section) |
| Use with Remotion | — | remotion-integration.md |
Reference Files
Foundation
- references/authentication.md - API key setup and X-Api-Key header
- references/quota.md - Credit system and usage limits
- references/video-status.md - Polling patterns and download URLs
- references/assets.md - Uploading images, videos, audio
Core Video Creation
- references/avatars.md - Listing avatars, styles, avatar_id selection
- references/voices.md - Listing voices, locales, speed/pitch
- references/scripts.md - Writing scripts, pauses, pacing
- references/video-generation.md - POST /v2/video/generate and multi-scene videos
- references/video-agent.md - One-shot prompt video generation
- references/prompt-optimizer.md - Writing effective Video Agent prompts (core workflow + rules)
- references/visual-styles.md - 20 named visual styles with full specs
- references/prompt-examples.md - Full production prompt example + ready-to-use templates
- references/dimensions.md - Resolution and aspect ratios
Video Customization
- references/backgrounds.md - Solid colors, images, video backgrounds
- references/text-overlays.md - Adding text with fonts and positioning
- references/captions.md - Auto-generated captions and subtitles
Advanced Features
- references/templates.md - Template listing and variable replacement
- references/photo-avatars.md - Creating avatars from photos
- references/webhooks.md - Webhook endpoints and events
Integration
- references/remotion-integration.md - Using HeyGen in Remotion compositions
Asset Upload and Management
HeyGen allows you to upload custom assets (images, videos, audio) for use in video generation, such as backgrounds, talking photo sources, and custom audio.
Upload Flow
Asset uploads are a single-step process: POST the raw file binary directly to the upload endpoint. The Content-Type header must match the file's MIME type.
Uploading an Asset
Endpoint: POST https://upload.heygen.com/v1/asset
Request
| Header | Required | Description |
|---|---|---|
X-Api-Key | ✓ | Your HeyGen API key |
Content-Type | ✓ | MIME type of the file (e.g. image/jpeg) |
The request body is the raw binary file data. No JSON or form fields are needed.
Response
| Field | Type | Description |
|---|---|---|
code | number | Status code (100 = success) |
data.id | string | Unique asset ID for use in video generation |
data.name | string | Asset name |
data.file_type | string | image, video, or audio |
data.url | string | Accessible URL for the uploaded file |
data.image_key | string \ | null |
data.folder_id | string | Folder ID (empty if not in a folder) |
data.meta | string \ | null |
data.created_ts | number | Unix timestamp of creation |
curl
curl -X POST "https://upload.heygen.com/v1/asset" \
-H "X-Api-Key: $HEYGEN_API_KEY" \
-H "Content-Type: image/jpeg" \
--data-binary '@./background.jpg'TypeScript
import fs from "fs";
import path from "path";
interface AssetUploadResponse {
code: number;
data: {
id: string;
name: string;
file_type: string;
url: string;
image_key: string | null;
folder_id: string;
meta: string | null;
created_ts: number;
};
msg: string | null;
message: string | null;
}
async function uploadAsset(filePath: string, contentType: string): Promise<AssetUploadResponse["data"]> {
const resolvedPath = path.resolve(filePath);
const fileBuffer = fs.readFileSync(resolvedPath);
const response = await fetch("https://upload.heygen.com/v1/asset", {
method: "POST",
headers: {
"X-Api-Key": process.env.HEYGEN_API_KEY!,
"Content-Type": contentType,
},
body: fileBuffer,
});
const json: AssetUploadResponse = await response.json();
if (json.code !== 100) {
throw new Error(json.message ?? "Upload failed");
}
return json.data;
}
// Usage
const asset = await uploadAsset("./background.jpg", "image/jpeg");
console.log(`Uploaded asset: ${asset.id}`);
console.log(`Asset URL: ${asset.url}`);TypeScript (with streams for large files)
import fs from "fs";
import path from "path";
import { stat } from "fs/promises";
async function uploadLargeAsset(filePath: string, contentType: string): Promise<AssetUploadResponse["data"]> {
const resolvedPath = path.resolve(filePath);
const fileStats = await stat(resolvedPath);
const fileStream = fs.createReadStream(resolvedPath);
const response = await fetch("https://upload.heygen.com/v1/asset", {
method: "POST",
headers: {
"X-Api-Key": process.env.HEYGEN_API_KEY!,
"Content-Type": contentType,
"Content-Length": fileStats.size.toString(),
},
body: fileStream as any,
// @ts-ignore - duplex is needed for streaming
duplex: "half",
});
const json: AssetUploadResponse = await response.json();
if (json.code !== 100) {
throw new Error(json.message ?? "Upload failed");
}
return json.data;
}Python
import requests
import os
def upload_asset(file_path: str, content_type: str) -> dict:
with open(file_path, "rb") as f:
response = requests.post(
"https://upload.heygen.com/v1/asset",
headers={
"X-Api-Key": os.environ["HEYGEN_API_KEY"],
"Content-Type": content_type
},
data=f
)
data = response.json()
if data.get("code") != 100:
raise Exception(data.get("message", "Upload failed"))
return data["data"]
# Usage
asset = upload_asset("./background.jpg", "image/jpeg")
print(f"Uploaded asset: {asset['id']}")
print(f"Asset URL: {asset['url']}")Supported Content Types
| Type | Content-Type | Use Case |
|---|---|---|
| JPEG | image/jpeg | Backgrounds, talking photos |
| PNG | image/png | Backgrounds, overlays |
| MP4 | video/mp4 | Video backgrounds |
| WebM | video/webm | Video backgrounds |
| MP3 | audio/mpeg | Custom audio input |
| WAV | audio/wav | Custom audio input |
Uploading from URL
If your asset is already hosted online:
async function uploadFromUrl(sourceUrl: string, contentType: string): Promise<AssetUploadResponse["data"]> {
// 1. Validate and download the file
const url = new URL(sourceUrl);
if (url.protocol !== "https:") {
throw new Error("Only HTTPS URLs are supported");
}
const sourceResponse = await fetch(sourceUrl);
const buffer = Buffer.from(await sourceResponse.arrayBuffer());
// 2. Upload directly to HeyGen
const response = await fetch("https://upload.heygen.com/v1/asset", {
method: "POST",
headers: {
"X-Api-Key": process.env.HEYGEN_API_KEY!,
"Content-Type": contentType,
},
body: buffer,
});
const json: AssetUploadResponse = await response.json();
if (json.code !== 100) {
throw new Error(json.message ?? "Upload failed");
}
return json.data;
}Using Uploaded Assets
As Background Image
const videoConfig = {
video_inputs: [
{
character: {
type: "avatar",
avatar_id: "josh_lite3_20230714",
avatar_style: "normal",
},
voice: {
type: "text",
input_text: "Hello, this is a video with a custom background!",
voice_id: "1bd001e7e50f421d891986aad5158bc8",
},
background: {
type: "image",
url: asset.url, // Use the URL from the upload response
},
},
],
};As Talking Photo Source
const talkingPhotoConfig = {
video_inputs: [
{
character: {
type: "talking_photo",
talking_photo_id: asset.id, // Use the ID from the upload response
},
voice: {
type: "text",
input_text: "Hello from my talking photo!",
voice_id: "1bd001e7e50f421d891986aad5158bc8",
},
},
],
};As Audio Input
const audioConfig = {
video_inputs: [
{
character: {
type: "avatar",
avatar_id: "josh_lite3_20230714",
avatar_style: "normal",
},
voice: {
type: "audio",
audio_url: asset.url, // Use the URL from the upload response
},
},
],
};Complete Upload Workflow
async function createVideoWithCustomBackground(
backgroundPath: string,
script: string
): Promise<string> {
// 1. Upload background
console.log("Uploading background...");
const background = await uploadAsset(backgroundPath, "image/jpeg");
// 2. Create video config
const config = {
video_inputs: [
{
character: {
type: "avatar",
avatar_id: "josh_lite3_20230714",
avatar_style: "normal",
},
voice: {
type: "text",
input_text: script,
voice_id: "1bd001e7e50f421d891986aad5158bc8",
},
background: {
type: "image",
url: background.url,
},
},
],
dimension: { width: 1920, height: 1080 },
};
// 3. Generate video
console.log("Generating video...");
const response = await fetch("https://api.heygen.com/v2/video/generate", {
method: "POST",
headers: {
"X-Api-Key": process.env.HEYGEN_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify(config),
});
const { data } = await response.json();
return data.video_id;
}Asset Limitations
- File size: 10MB maximum
- Image dimensions: Recommended to match video dimensions
- Audio duration: Should match expected video length
- Retention: Assets may be deleted after a period of inactivity
Best Practices
1. Optimize images - Resize to match video dimensions before uploading 2. Use appropriate formats - JPEG for photos, PNG for graphics with transparency 3. Validate before upload - Check file type and size locally first 4. Handle upload errors - Implement retry logic for failed uploads 5. Cache asset IDs - Reuse assets across multiple video generations
HeyGen Authentication
All HeyGen API requests require authentication using an API key passed in the X-Api-Key header.
Getting Your API Key
1. Go to https://app.heygen.com/settings?from=&nav=API 2. Log in if prompted 3. Copy your API key
Environment Setup
Store your API key securely as an environment variable:
export HEYGEN_API_KEY="your-api-key-here"For .env files:
HEYGEN_API_KEY=your-api-key-hereMaking Authenticated Requests
curl
curl -X GET "https://api.heygen.com/v2/avatars" \
-H "X-Api-Key: $HEYGEN_API_KEY"TypeScript/JavaScript (fetch)
const response = await fetch("https://api.heygen.com/v2/avatars", {
headers: {
"X-Api-Key": process.env.HEYGEN_API_KEY!,
},
});
const { data } = await response.json();TypeScript/JavaScript (axios)
import axios from "axios";
const client = axios.create({
baseURL: "https://api.heygen.com",
headers: {
"X-Api-Key": process.env.HEYGEN_API_KEY,
},
});
const { data } = await client.get("/v2/avatars");Python (requests)
import os
import requests
response = requests.get(
"https://api.heygen.com/v2/avatars",
headers={"X-Api-Key": os.environ["HEYGEN_API_KEY"]}
)
data = response.json()Python (httpx)
import os
import httpx
async with httpx.AsyncClient() as client:
response = await client.get(
"https://api.heygen.com/v2/avatars",
headers={"X-Api-Key": os.environ["HEYGEN_API_KEY"]}
)
data = response.json()Creating a Reusable API Client
TypeScript
class HeyGenClient {
private baseUrl = "https://api.heygen.com";
private apiKey: string;
constructor(apiKey: string) {
this.apiKey = apiKey;
}
async request<T>(endpoint: string, options: RequestInit = {}): Promise<T> {
const response = await fetch(`${this.baseUrl}${endpoint}`, {
...options,
headers: {
"X-Api-Key": this.apiKey,
"Content-Type": "application/json",
...options.headers,
},
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.message || `HTTP ${response.status}`);
}
return response.json();
}
get<T>(endpoint: string): Promise<T> {
return this.request<T>(endpoint);
}
post<T>(endpoint: string, body: unknown): Promise<T> {
return this.request<T>(endpoint, {
method: "POST",
body: JSON.stringify(body),
});
}
}
// Usage
const client = new HeyGenClient(process.env.HEYGEN_API_KEY!);
const avatars = await client.get("/v2/avatars");API Response Format
All HeyGen API responses follow this structure:
interface ApiResponse<T> {
error: null | string;
data: T;
}Successful response example:
{
"error": null,
"data": {
"avatars": [...]
}
}Error response example:
{
"error": "Invalid API key",
"data": null
}Error Handling
Common authentication errors:
| Status Code | Error | Cause |
|---|---|---|
| 401 | Invalid API key | API key is missing or incorrect |
| 403 | Forbidden | API key doesn't have required permissions |
| 429 | Rate limit exceeded | Too many requests |
Handling Errors
async function makeRequest(endpoint: string) {
const response = await fetch(`https://api.heygen.com${endpoint}`, {
headers: { "X-Api-Key": process.env.HEYGEN_API_KEY! },
});
const json = await response.json();
if (!response.ok || json.error) {
throw new Error(json.error || `HTTP ${response.status}`);
}
return json.data;
}Rate Limiting
HeyGen enforces rate limits on API requests:
- Standard rate limits apply per API key
- Some endpoints (like video generation) have stricter limits
- Use exponential backoff when receiving 429 errors
async function requestWithRetry(
fn: () => Promise<Response>,
maxRetries = 3
): Promise<Response> {
for (let i = 0; i < maxRetries; i++) {
const response = await fn();
if (response.status === 429) {
const waitTime = Math.pow(2, i) * 1000;
await new Promise((resolve) => setTimeout(resolve, waitTime));
continue;
}
return response;
}
throw new Error("Max retries exceeded");
}Security Best Practices
1. Never expose API keys in client-side code - Always make API calls from a backend server 2. Use environment variables - Don't hardcode API keys in source code 3. Rotate keys periodically - Generate new API keys regularly 4. Monitor usage - Check your HeyGen dashboard for unusual activity
HeyGen Avatars
Avatars are the AI-generated presenters in HeyGen videos. You can use public avatars provided by HeyGen or create custom avatars.
Previewing Avatars Before Generation
Always preview avatars before generating a video to ensure they match user preferences. Each avatar has preview URLs that can be opened directly in the browser - no downloading required.
List Avatars and Show Previews
async function listAndPreviewAvatars(openInBrowser = true): Promise<void> {
const response = await fetch("https://api.heygen.com/v2/avatars", {
headers: { "X-Api-Key": process.env.HEYGEN_API_KEY! },
});
const { data } = await response.json();
for (const avatar of data.avatars.slice(0, 5)) {
console.log(`\n${avatar.avatar_name} (${avatar.gender})`);
console.log(` ID: ${avatar.avatar_id}`);
console.log(` Preview: ${avatar.preview_image_url}`);
}
// Preview URLs can be opened directly in any browser
for (const avatar of data.avatars.slice(0, 3)) {
console.log(`Open in browser: ${avatar.preview_image_url}`);
}
}Workflow: Preview Before Generate
1. List available avatars - get names, genders, and preview URLs 2. Show preview URLs to user - share preview_image_url for visual check 3. User selects preferred avatar by name or ID 4. Get avatar details for default_voice_id 5. Generate video with selected avatar
Preview Fields in API Response
| Field | Description |
|---|---|
preview_image_url | Static image of the avatar (JPG) - publicly accessible URL |
preview_video_url | Short video clip showing avatar animation |
Both URLs are publicly accessible - no authentication needed to view.
Listing Available Avatars
curl
curl -X GET "https://api.heygen.com/v2/avatars" \
-H "X-Api-Key: $HEYGEN_API_KEY"TypeScript
interface Avatar {
avatar_id: string;
avatar_name: string;
gender: "male" | "female";
preview_image_url: string;
preview_video_url: string;
}
interface AvatarsResponse {
error: null | string;
data: {
avatars: Avatar[];
talking_photos: TalkingPhoto[];
};
}
async function listAvatars(): Promise<Avatar[]> {
const response = await fetch("https://api.heygen.com/v2/avatars", {
headers: { "X-Api-Key": process.env.HEYGEN_API_KEY! },
});
const json: AvatarsResponse = await response.json();
if (json.error) {
throw new Error(json.error);
}
return json.data.avatars;
}Python
import requests
import os
def list_avatars() -> list:
response = requests.get(
"https://api.heygen.com/v2/avatars",
headers={"X-Api-Key": os.environ["HEYGEN_API_KEY"]}
)
data = response.json()
if data.get("error"):
raise Exception(data["error"])
return data["data"]["avatars"]Response Format
{
"error": null,
"data": {
"avatars": [
{
"avatar_id": "josh_lite3_20230714",
"avatar_name": "Josh",
"gender": "male",
"preview_image_url": "https://files.heygen.ai/...",
"preview_video_url": "https://files.heygen.ai/..."
},
{
"avatar_id": "angela_expressive_20231010",
"avatar_name": "Angela",
"gender": "female",
"preview_image_url": "https://files.heygen.ai/...",
"preview_video_url": "https://files.heygen.ai/..."
}
],
"talking_photos": []
}
}Avatar Types
Public Avatars
HeyGen provides a library of public avatars that anyone can use:
// List only public avatars
const avatars = await listAvatars();
const publicAvatars = avatars.filter((a) => !a.avatar_id.startsWith("custom_"));Private/Custom Avatars
Custom avatars created from your own training footage:
const customAvatars = avatars.filter((a) => a.avatar_id.startsWith("custom_"));Avatar Styles
Avatars support different rendering styles:
| Style | Description |
|---|---|
normal | Full body shot, standard framing |
closeUp | Close-up on face, more expressive |
circle | Avatar in circular frame (talking head) |
voice_only | Audio only, no video rendering |
When to Use Each Style
| Use Case | Recommended Style |
|---|---|
| Full-screen presenter video | normal |
| Personal/intimate content | closeUp |
| Picture-in-picture overlay | circle |
| Small corner widget | circle |
| Podcast/audio content | voice_only |
| Motion graphics with avatar overlay | normal or closeUp + transparent bg |
Using Avatar Styles
const videoConfig = {
video_inputs: [
{
character: {
type: "avatar",
avatar_id: "josh_lite3_20230714",
avatar_style: "normal", // "normal" | "closeUp" | "circle" | "voice_only"
},
voice: {
type: "text",
input_text: "Hello, world!",
voice_id: "1bd001e7e50f421d891986aad5158bc8",
},
},
],
};Circle Style for Talking Heads
Circle style is ideal for overlay compositions:
// Circle avatar for picture-in-picture
{
character: {
type: "avatar",
avatar_id: "josh_lite3_20230714",
avatar_style: "circle",
},
voice: { ... },
background: {
type: "color",
value: "#00FF00", // Green for chroma key, or use webm endpoint
},
}Searching and Filtering Avatars
By Gender
function filterByGender(avatars: Avatar[], gender: "male" | "female"): Avatar[] {
return avatars.filter((a) => a.gender === gender);
}
const maleAvatars = filterByGender(avatars, "male");
const femaleAvatars = filterByGender(avatars, "female");By Name
function searchByName(avatars: Avatar[], query: string): Avatar[] {
const lowerQuery = query.toLowerCase();
return avatars.filter((a) =>
a.avatar_name.toLowerCase().includes(lowerQuery)
);
}
const results = searchByName(avatars, "josh");Avatar Groups
Avatars are organized into groups for better management.
List Avatar Groups
curl -X GET "https://api.heygen.com/v2/avatar_group.list?include_public=true" \
-H "X-Api-Key: $HEYGEN_API_KEY"Query Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
include_public | bool | false | Include public avatars in results |
TypeScript
interface AvatarGroupItem {
id: string;
name: string;
created_at: number;
num_looks: number;
preview_image: string;
group_type: string;
train_status: string;
default_voice_id: string | null;
}
interface AvatarGroupListResponse {
error: null | string;
data: {
avatar_group_list: AvatarGroupItem[];
};
}
async function listAvatarGroups(
includePublic = true
): Promise<AvatarGroupListResponse["data"]> {
const params = new URLSearchParams({
include_public: includePublic.toString(),
});
const response = await fetch(
`https://api.heygen.com/v2/avatar_group.list?${params}`,
{ headers: { "X-Api-Key": process.env.HEYGEN_API_KEY! } }
);
const json: AvatarGroupListResponse = await response.json();
if (json.error) {
throw new Error(json.error);
}
return json.data;
}Get Avatars in a Group
curl -X GET "https://api.heygen.com/v2/avatar_group/{group_id}/avatars" \
-H "X-Api-Key: $HEYGEN_API_KEY"Using Avatars in Video Generation
Basic Avatar Usage
const videoConfig = {
video_inputs: [
{
character: {
type: "avatar",
avatar_id: "josh_lite3_20230714",
avatar_style: "normal",
},
voice: {
type: "text",
input_text: "Welcome to our product demo!",
voice_id: "1bd001e7e50f421d891986aad5158bc8",
},
},
],
dimension: { width: 1920, height: 1080 },
};Multiple Scenes with Different Avatars
const multiSceneConfig = {
video_inputs: [
{
character: {
type: "avatar",
avatar_id: "josh_lite3_20230714",
avatar_style: "normal",
},
voice: {
type: "text",
input_text: "Hi, I'm Josh. Let me introduce my colleague.",
voice_id: "1bd001e7e50f421d891986aad5158bc8",
},
},
{
character: {
type: "avatar",
avatar_id: "angela_expressive_20231010",
avatar_style: "normal",
},
voice: {
type: "text",
input_text: "Hello! I'm Angela. Nice to meet you!",
voice_id: "2d5b0e6a8c3f47d9a1b2c3d4e5f60718",
},
},
],
};Using Avatar's Default Voice
Many avatars have a default_voice_id that's pre-matched for natural results. This is the recommended approach rather than manually selecting voices.
Recommended Flow
1. GET /v2/avatars → Get list of avatar_ids
2. GET /v2/avatar/{id}/details → Get default_voice_id for chosen avatar
3. POST /v2/video/generate → Use avatar_id + default_voice_idGet Avatar Details (v2 API)
Given an avatar_id, fetch its details including the default voice:
curl -X GET "https://api.heygen.com/v2/avatar/{avatar_id}/details" \
-H "X-Api-Key: $HEYGEN_API_KEY"Response Format
{
"error": null,
"data": {
"type": "avatar",
"id": "josh_lite3_20230714",
"name": "Josh",
"gender": "male",
"preview_image_url": "https://files.heygen.ai/...",
"preview_video_url": "https://files.heygen.ai/...",
"premium": false,
"is_public": true,
"default_voice_id": "1bd001e7e50f421d891986aad5158bc8",
"tags": ["AVATAR_IV"]
}
}TypeScript
interface AvatarDetails {
type: "avatar";
id: string;
name: string;
gender: "male" | "female";
preview_image_url: string;
preview_video_url: string;
premium: boolean;
is_public: boolean;
default_voice_id: string | null;
tags: string[];
}
async function getAvatarDetails(avatarId: string): Promise<AvatarDetails> {
const response = await fetch(
`https://api.heygen.com/v2/avatar/${avatarId}/details`,
{ headers: { "X-Api-Key": process.env.HEYGEN_API_KEY! } }
);
const json = await response.json();
if (json.error) {
throw new Error(json.error);
}
return json.data;
}
// Usage: Get default voice for a known avatar
const details = await getAvatarDetails("josh_lite3_20230714");
if (details.default_voice_id) {
console.log(`Using ${details.name} with default voice: ${details.default_voice_id}`);
} else {
console.log(`${details.name} has no default voice, select manually`);
}Complete Example: Generate Video with Any Avatar's Default Voice
async function generateWithAvatarDefaultVoice(
avatarId: string,
script: string
): Promise<string> {
// 1. Get avatar details to find default voice
const avatar = await getAvatarDetails(avatarId);
if (!avatar.default_voice_id) {
throw new Error(`Avatar ${avatar.name} has no default voice`);
}
// 2. Generate video with the avatar's default voice
const videoId = await generateVideo({
video_inputs: [{
character: {
type: "avatar",
avatar_id: avatar.id,
avatar_style: "normal",
},
voice: {
type: "text",
input_text: script,
voice_id: avatar.default_voice_id,
},
}],
dimension: { width: 1920, height: 1080 },
});
return videoId;
}Why Use Default Voice?
1. Guaranteed gender match - Avatar and voice are pre-paired 2. Natural lip sync - Default voices are optimized for the avatar 3. Simpler code - No need to fetch and match voices separately 4. Better quality - HeyGen has tested this combination
Selecting the Right Avatar
Avatar Categories
HeyGen avatars fall into distinct categories. Match the category to your use case:
| Category | Examples | Best For |
|---|---|---|
| Business/Professional | Josh, Angela, Wayne | Corporate videos, product demos, training |
| Casual/Friendly | Lily, various lifestyle avatars | Social media, informal content |
| Themed/Seasonal | Holiday-themed, costume avatars | Specific campaigns, seasonal content |
| Expressive | Avatars with "expressive" in name | Engaging storytelling, dynamic content |
Selection Guidelines
For business/professional content:
- Choose avatars with neutral attire (business casual or formal)
- Avoid themed or seasonal avatars (holiday costumes, casual clothing)
- Preview the avatar to verify professional appearance
- Consider your audience demographics when selecting gender and appearance
For casual/social content:
- More flexibility in avatar choice
- Themed avatars can work for specific campaigns
- Match avatar energy to content tone
Common Mistakes to Avoid
1. Using themed avatars for business content - A holiday-themed avatar looks unprofessional in a product demo 2. Not previewing before generation - Always check the preview URL to verify appearance 3. Ignoring avatar style - A circle style avatar may not work for full-screen presentations 4. Mismatched voice gender - Always use the avatar's default_voice_id or match genders manually
Selection Checklist
Before generating a video:
- [ ] Previewed avatar image/video in browser
- [ ] Avatar appearance matches content tone (professional vs casual)
- [ ] Avatar style (
normal,closeUp,circle) fits the video format - [ ] Voice gender matches avatar gender
- [ ] Using
default_voice_idwhen available
Helper Functions
Get Avatar by ID
async function getAvatarById(avatarId: string): Promise<Avatar | null> {
const avatars = await listAvatars();
return avatars.find((a) => a.avatar_id === avatarId) || null;
}Validate Avatar ID
async function isValidAvatarId(avatarId: string): Promise<boolean> {
const avatar = await getAvatarById(avatarId);
return avatar !== null;
}Get Random Avatar
async function getRandomAvatar(gender?: "male" | "female"): Promise<Avatar> {
let avatars = await listAvatars();
if (gender) {
avatars = avatars.filter((a) => a.gender === gender);
}
const randomIndex = Math.floor(Math.random() * avatars.length);
return avatars[randomIndex];
}Common Avatar IDs
Some commonly used public avatar IDs (availability may vary):
| Avatar ID | Name | Gender |
|---|---|---|
josh_lite3_20230714 | Josh | Male |
angela_expressive_20231010 | Angela | Female |
wayne_20240422 | Wayne | Male |
lily_20230614 | Lily | Female |
Always verify avatar availability by calling the list endpoint before using.
Video Backgrounds
HeyGen supports various background types to customize the appearance of your avatar videos.
Background Types
| Type | Description |
|---|---|
color | Solid color background |
image | Static image background |
video | Looping video background |
Color Backgrounds
The simplest option - use a solid color:
const videoConfig = {
video_inputs: [
{
character: {
type: "avatar",
avatar_id: "josh_lite3_20230714",
avatar_style: "normal",
},
voice: {
type: "text",
input_text: "Hello with a colored background!",
voice_id: "1bd001e7e50f421d891986aad5158bc8",
},
background: {
type: "color",
value: "#FFFFFF", // White background
},
},
],
};Common Color Values
| Color | Hex Value | Use Case |
|---|---|---|
| White | #FFFFFF | Clean, professional |
| Black | #000000 | Dramatic, cinematic |
| Blue | #0066CC | Corporate, trustworthy |
| Green | #00FF00 | Chroma key (for compositing) |
| Gray | #808080 | Neutral, modern |
Using Transparent/Green Screen
For compositing in post-production:
background: {
type: "color",
value: "#00FF00", // Green screen
}Image Backgrounds
Use a static image as background:
From URL
const videoConfig = {
video_inputs: [
{
character: {
type: "avatar",
avatar_id: "josh_lite3_20230714",
avatar_style: "normal",
},
voice: {
type: "text",
input_text: "Check out this custom background!",
voice_id: "1bd001e7e50f421d891986aad5158bc8",
},
background: {
type: "image",
url: "https://example.com/my-background.jpg",
},
},
],
};From Uploaded Asset
First upload your image, then use the asset URL:
// 1. Upload the image
const assetId = await uploadFile("./background.jpg", "image/jpeg");
// 2. Use in video config
const videoConfig = {
video_inputs: [
{
character: {...},
voice: {...},
background: {
type: "image",
url: `https://files.heygen.ai/asset/${assetId}`,
},
},
],
};Image Requirements
- Formats: JPEG, PNG
- Recommended size: Match video dimensions (e.g., 1920x1080 for 1080p)
- Aspect ratio: Should match video aspect ratio
- File size: Under 10MB recommended
Video Backgrounds
Use a looping video as background:
const videoConfig = {
video_inputs: [
{
character: {
type: "avatar",
avatar_id: "josh_lite3_20230714",
avatar_style: "normal",
},
voice: {
type: "text",
input_text: "Dynamic video background!",
voice_id: "1bd001e7e50f421d891986aad5158bc8",
},
background: {
type: "video",
url: "https://example.com/background-loop.mp4",
},
},
],
};Video Requirements
- Format: MP4 (H.264 codec recommended)
- Looping: Video will loop if shorter than avatar content
- Audio: Background video audio is typically muted
- File size: Under 100MB recommended
Different Backgrounds Per Scene
Use different backgrounds for each scene:
const multiBackgroundConfig = {
video_inputs: [
// Scene 1: Office background
{
character: {
type: "avatar",
avatar_id: "josh_lite3_20230714",
avatar_style: "normal",
},
voice: {
type: "text",
input_text: "Let me start with an introduction.",
voice_id: "1bd001e7e50f421d891986aad5158bc8",
},
background: {
type: "image",
url: "https://example.com/office-bg.jpg",
},
},
// Scene 2: Product showcase
{
character: {
type: "avatar",
avatar_id: "josh_lite3_20230714",
avatar_style: "closeUp",
},
voice: {
type: "text",
input_text: "Now let me show you our product.",
voice_id: "1bd001e7e50f421d891986aad5158bc8",
},
background: {
type: "image",
url: "https://example.com/product-bg.jpg",
},
},
// Scene 3: Call to action
{
character: {
type: "avatar",
avatar_id: "josh_lite3_20230714",
avatar_style: "normal",
},
voice: {
type: "text",
input_text: "Get started today!",
voice_id: "1bd001e7e50f421d891986aad5158bc8",
},
background: {
type: "color",
value: "#1a1a2e",
},
},
],
};Background Helper Functions
TypeScript
type BackgroundType = "color" | "image" | "video";
interface Background {
type: BackgroundType;
value?: string;
url?: string;
}
function createColorBackground(hexColor: string): Background {
return { type: "color", value: hexColor };
}
function createImageBackground(imageUrl: string): Background {
return { type: "image", url: imageUrl };
}
function createVideoBackground(videoUrl: string): Background {
return { type: "video", url: videoUrl };
}
// Preset backgrounds
const backgrounds = {
white: createColorBackground("#FFFFFF"),
black: createColorBackground("#000000"),
greenScreen: createColorBackground("#00FF00"),
corporate: createColorBackground("#0066CC"),
};Best Practices
1. Match dimensions - Background should match video dimensions 2. Consider avatar position - Leave space where avatar will appear 3. Use contrasting colors - Ensure avatar is visible against background 4. Optimize file sizes - Compress images/videos for faster processing 5. Test with green screen - For professional post-production workflows 6. Keep backgrounds simple - Avoid distracting elements behind the avatar
Common Issues
Background Not Showing
// Wrong: missing url/value
background: {
type: "image"
}
// Correct
background: {
type: "image",
url: "https://example.com/bg.jpg"
}Aspect Ratio Mismatch
If your background doesn't match the video dimensions, it may be cropped or stretched. Always match your background aspect ratio to your video dimensions:
// For 1920x1080 video
// Use 1920x1080 background image
// For 1080x1920 portrait video
// Use 1080x1920 background imageVideo Background Audio
Background video audio is typically muted to avoid conflicting with the avatar's voice. If you need background music, add it as a separate audio track in post-production.
Video Captions
HeyGen can automatically generate captions (subtitles) for your videos, improving accessibility and engagement.
Enabling Captions
Captions can be enabled when generating a video:
const videoConfig = {
video_inputs: [
{
character: {
type: "avatar",
avatar_id: "josh_lite3_20230714",
avatar_style: "normal",
},
voice: {
type: "text",
input_text: "Hello! This video will have automatic captions.",
voice_id: "1bd001e7e50f421d891986aad5158bc8",
},
},
],
// Caption settings (availability varies by plan)
caption: true,
};Caption Configuration Options
interface CaptionConfig {
// Enable/disable captions
enabled: boolean;
// Caption style
style?: {
font_family?: string;
font_size?: number;
font_color?: string;
background_color?: string;
position?: "top" | "bottom";
};
// Language for caption generation
language?: string;
}Caption Styles
Basic Captions
const config = {
video_inputs: [...],
caption: true, // Enable with default styling
};Styled Captions
const config = {
video_inputs: [...],
caption: {
enabled: true,
style: {
font_family: "Arial",
font_size: 32,
font_color: "#FFFFFF",
background_color: "rgba(0, 0, 0, 0.7)",
position: "bottom",
},
},
};Multi-Language Captions
For videos in different languages, captions are generated based on the voice language:
// Spanish video with Spanish captions
const spanishConfig = {
video_inputs: [
{
character: {
type: "avatar",
avatar_id: "josh_lite3_20230714",
avatar_style: "normal",
},
voice: {
type: "text",
input_text: "¡Hola! Este video tendrá subtítulos en español.",
voice_id: "spanish_voice_id",
},
},
],
caption: true,
};Working with SRT Files
SRT File Format
Standard SRT format:
1
00:00:00,000 --> 00:00:03,000
Hello! This video will have
2
00:00:03,000 --> 00:00:06,000
automatic captions generated.
3
00:00:06,000 --> 00:00:09,000
They sync with the audio.Using Custom SRT
For video translation, you can provide your own SRT:
const translationConfig = {
input_video_id: "original_video_id",
output_languages: ["es-ES", "fr-FR"],
srt_key: "path/to/custom.srt", // Custom SRT file
srt_role: "input", // "input" or "output"
};Caption Positioning
Bottom (Default)
Standard position for most videos:
caption: {
enabled: true,
style: {
position: "bottom"
}
}Top
For videos where bottom space is occupied:
caption: {
enabled: true,
style: {
position: "top"
}
}Accessibility Best Practices
1. Always enable captions - Improves accessibility for deaf/hard-of-hearing viewers 2. Use high contrast - White text on dark background or vice versa 3. Readable font size - At least 24px for standard video, larger for mobile 4. Don't cover important content - Position captions away from key visual elements 5. Sync timing - Ensure captions match audio timing accurately
Caption Helper Functions
interface CaptionStyle {
font_family: string;
font_size: number;
font_color: string;
background_color: string;
position: "top" | "bottom";
}
const captionPresets: Record<string, CaptionStyle> = {
default: {
font_family: "Arial",
font_size: 32,
font_color: "#FFFFFF",
background_color: "rgba(0, 0, 0, 0.7)",
position: "bottom",
},
minimal: {
font_family: "Arial",
font_size: 28,
font_color: "#FFFFFF",
background_color: "transparent",
position: "bottom",
},
bold: {
font_family: "Arial",
font_size: 36,
font_color: "#FFFFFF",
background_color: "rgba(0, 0, 0, 0.9)",
position: "bottom",
},
branded: {
font_family: "Roboto",
font_size: 30,
font_color: "#00D1FF",
background_color: "rgba(26, 26, 46, 0.9)",
position: "bottom",
},
};
function createCaptionConfig(preset: keyof typeof captionPresets) {
return {
enabled: true,
style: captionPresets[preset],
};
}Social Media Caption Considerations
TikTok / Instagram Reels
- Position captions in center or upper portion
- Avoid bottom 20% (covered by UI elements)
- Use larger font sizes for mobile viewing
const socialCaptions = {
enabled: true,
style: {
font_size: 42,
position: "top", // Avoid bottom UI elements
},
};YouTube
- Standard bottom captions work well
- YouTube also supports closed captions upload
- Captions highly recommended (many watch without sound)
- Professional styling preferred
Limitations
- Caption styles may be limited depending on your subscription tier
- Some advanced caption features may require the web interface
- Multi-speaker caption detection may have limited availability
- Caption accuracy depends on audio quality and speech clarity
Integration with Video Translation
When using video translation, captions are automatically handled:
// Video translation includes caption generation
const translationConfig = {
input_video_id: "original_video_id",
output_languages: ["es-ES"],
// Captions generated in target language
};See video-translation.md for more details.
Video Dimensions and Resolution
HeyGen supports various video dimensions and aspect ratios to fit different platforms and use cases.
Standard Resolutions
Landscape (16:9)
| Resolution | Width | Height | Use Case |
|---|---|---|---|
| 720p | 1280 | 720 | Standard quality, faster processing |
| 1080p | 1920 | 1080 | High quality, most common |
Portrait (9:16)
| Resolution | Width | Height | Use Case |
|---|---|---|---|
| 720p | 720 | 1280 | Mobile-first content |
| 1080p | 1080 | 1920 | High quality vertical |
Square (1:1)
| Resolution | Width | Height | Use Case |
|---|---|---|---|
| 720p | 720 | 720 | Social media posts |
| 1080p | 1080 | 1080 | High quality square |
Setting Dimensions
TypeScript
// Landscape 1080p
const landscapeConfig = {
video_inputs: [...],
dimension: {
width: 1920,
height: 1080
}
};
// Portrait 1080p
const portraitConfig = {
video_inputs: [...],
dimension: {
width: 1080,
height: 1920
}
};
// Square 1080p
const squareConfig = {
video_inputs: [...],
dimension: {
width: 1080,
height: 1080
}
};curl
# Landscape 1080p
curl -X POST "https://api.heygen.com/v2/video/generate" \
-H "X-Api-Key: $HEYGEN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"video_inputs": [...],
"dimension": {
"width": 1920,
"height": 1080
}
}'Dimension Helper Functions
type AspectRatio = "16:9" | "9:16" | "1:1" | "4:3" | "4:5";
type Quality = "720p" | "1080p";
interface Dimensions {
width: number;
height: number;
}
function getDimensions(aspectRatio: AspectRatio, quality: Quality): Dimensions {
const configs: Record<AspectRatio, Record<Quality, Dimensions>> = {
"16:9": {
"720p": { width: 1280, height: 720 },
"1080p": { width: 1920, height: 1080 },
},
"9:16": {
"720p": { width: 720, height: 1280 },
"1080p": { width: 1080, height: 1920 },
},
"1:1": {
"720p": { width: 720, height: 720 },
"1080p": { width: 1080, height: 1080 },
},
"4:3": {
"720p": { width: 960, height: 720 },
"1080p": { width: 1440, height: 1080 },
},
"4:5": {
"720p": { width: 576, height: 720 },
"1080p": { width: 864, height: 1080 },
},
};
return configs[aspectRatio][quality];
}
// Usage
const youTubeDimensions = getDimensions("16:9", "1080p");
const tikTokDimensions = getDimensions("9:16", "1080p");
const instagramDimensions = getDimensions("1:1", "1080p");Platform-Specific Recommendations
YouTube
const youtubeConfig = {
video_inputs: [...],
dimension: { width: 1920, height: 1080 }, // 16:9 landscape
};TikTok / Instagram Reels / YouTube Shorts
const shortFormConfig = {
video_inputs: [...],
dimension: { width: 1080, height: 1920 }, // 9:16 portrait
};Instagram Feed Post
const instagramFeedConfig = {
video_inputs: [...],
dimension: { width: 1080, height: 1080 }, // 1:1 square
};const linkedinConfig = {
video_inputs: [...],
dimension: { width: 1920, height: 1080 }, // 16:9 landscape preferred
};Twitter/X
const twitterConfig = {
video_inputs: [...],
dimension: { width: 1280, height: 720 }, // 16:9, 720p is common
};Avatar IV Dimensions
For Avatar IV (photo-based avatars), dimensions are set via orientation:
type VideoOrientation = "portrait" | "landscape" | "square";
function getAvatarIVDimensions(orientation: VideoOrientation): Dimensions {
switch (orientation) {
case "portrait":
return { width: 720, height: 1280 };
case "landscape":
return { width: 1280, height: 720 };
case "square":
return { width: 720, height: 720 };
}
}Custom Dimensions
HeyGen supports custom dimensions within limits:
const customConfig = {
video_inputs: [...],
dimension: {
width: 1600,
height: 900 // Custom 16:9 at non-standard resolution
}
};Dimension Constraints
- Minimum: 128px on any side
- Maximum: 4096px on any side
- Must be even numbers: Both width and height must be divisible by 2
function validateDimensions(width: number, height: number): boolean {
if (width < 128 || height < 128) {
throw new Error("Dimensions must be at least 128px");
}
if (width > 4096 || height > 4096) {
throw new Error("Dimensions cannot exceed 4096px");
}
if (width % 2 !== 0 || height % 2 !== 0) {
throw new Error("Dimensions must be even numbers");
}
return true;
}Resolution vs. Credit Cost
Higher resolutions may consume more credits:
| Resolution | Relative Cost |
|---|---|
| 720p | Base rate |
| 1080p | ~1.5x base rate |
Consider using 720p for drafts and testing, then 1080p for final output.
Background Considerations
Match background image/video dimensions to your video dimensions:
// For 1080p landscape video
const config = {
video_inputs: [
{
character: {...},
voice: {...},
background: {
type: "image",
url: "https://example.com/1920x1080-background.jpg" // Match video dimensions
}
}
],
dimension: { width: 1920, height: 1080 }
};Creating a Video Config Factory
interface VideoConfigOptions {
script: string;
avatarId: string;
voiceId: string;
platform: "youtube" | "tiktok" | "instagram_feed" | "instagram_story" | "linkedin";
quality?: "720p" | "1080p";
}
function createVideoConfig(options: VideoConfigOptions) {
const platformDimensions: Record<string, Dimensions> = {
youtube: { width: 1920, height: 1080 },
tiktok: { width: 1080, height: 1920 },
instagram_feed: { width: 1080, height: 1080 },
instagram_story: { width: 1080, height: 1920 },
linkedin: { width: 1920, height: 1080 },
};
const dimension = platformDimensions[options.platform];
// Scale down for 720p if requested
if (options.quality === "720p") {
dimension.width = Math.round((dimension.width * 720) / 1080);
dimension.height = Math.round((dimension.height * 720) / 1080);
}
return {
video_inputs: [
{
character: {
type: "avatar",
avatar_id: options.avatarId,
avatar_style: "normal",
},
voice: {
type: "text",
input_text: options.script,
voice_id: options.voiceId,
},
},
],
dimension,
};
}
// Usage
const tiktokVideo = createVideoConfig({
script: "Hey everyone! Check this out!",
avatarId: "josh_lite3_20230714",
voiceId: "1bd001e7e50f421d891986aad5158bc8",
platform: "tiktok",
quality: "1080p",
});Photo Avatars (Talking Photos)
Photo avatars allow you to animate a static photo and make it speak. This is useful for creating personalized video content from portraits, headshots, or any suitable image.
Creating a Photo Avatar from an Uploaded Image
The workflow is: Upload Image → Create Avatar Group → Use in Video
Step 1: Upload the Image
Upload a portrait photo using the asset upload endpoint. The response includes an image_key which you'll use in the next step.
curl -X POST "https://upload.heygen.com/v1/asset" \
-H "X-Api-Key: $HEYGEN_API_KEY" \
-H "Content-Type: image/jpeg" \
--data-binary '@./portrait.jpg'Response:
{
"code": 100,
"data": {
"id": "741299e941764988b432ed3a6757878f",
"name": "741299e941764988b432ed3a6757878f",
"file_type": "image",
"url": "https://resource2.heygen.ai/image/.../original.jpg",
"image_key": "image/741299e941764988b432ed3a6757878f/original.jpg"
}
}Important: Save theimage_keyfield (not theid). Theimage_keyis the S3 path used to create the photo avatar.
See assets.md for full upload details.
Step 2: Create Photo Avatar Group
Use the image_key from the upload response to create a photo avatar group. This processes the image and creates a usable photo avatar.
Endpoint: POST https://api.heygen.com/v2/photo_avatar/avatar_group/create
curl -X POST "https://api.heygen.com/v2/photo_avatar/avatar_group/create" \
-H "X-Api-Key: $HEYGEN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"image_key": "image/741299e941764988b432ed3a6757878f/original.jpg",
"name": "My Photo Avatar"
}'| Field | Type | Req | Description |
|---|---|---|---|
image_key | string | ✓ | S3 image key from upload response |
name | string | ✓ | Display name for the avatar |
generation_id | string | If using AI-generated photo (see below) |
Response:
{
"error": null,
"data": {
"id": "045c260bc0364727b2cbe50442c3a5bf",
"image_url": "https://files2.heygen.ai/...",
"created_at": 1771798135.777256,
"name": "My Photo Avatar",
"status": "pending",
"group_id": "045c260bc0364727b2cbe50442c3a5bf",
"is_motion": false,
"business_type": "uploaded"
}
}The id (same as group_id) is your talking_photo_id for video generation.
Step 3: Wait for Processing
The photo avatar starts with status: "pending" and transitions to "completed" within seconds. Poll the status endpoint:
Endpoint: GET https://api.heygen.com/v2/photo_avatar/{id}
curl "https://api.heygen.com/v2/photo_avatar/045c260bc0364727b2cbe50442c3a5bf" \
-H "X-Api-Key: $HEYGEN_API_KEY"Wait until status is "completed" before using in video generation.
Step 4: Use in Video Generation
Use the photo avatar id as talking_photo_id:
const videoConfig = {
video_inputs: [
{
character: {
type: "talking_photo",
talking_photo_id: "045c260bc0364727b2cbe50442c3a5bf",
},
voice: {
type: "text",
input_text: "Hello! This is my photo avatar speaking.",
voice_id: "1bd001e7e50f421d891986aad5158bc8",
},
},
],
dimension: { width: 1920, height: 1080 },
};TypeScript: Complete Workflow
import fs from "fs";
import path from "path";
interface AssetUploadResponse {
code: number;
data: {
id: string;
image_key: string;
url: string;
};
}
interface PhotoAvatarResponse {
error: string | null;
data: {
id: string;
group_id: string;
image_url: string;
name: string;
status: string;
is_motion: boolean;
business_type: string;
};
}
async function createPhotoAvatar(
imagePath: string,
name: string
): Promise<string> {
// 1. Upload image
const resolvedPath = path.resolve(imagePath);
const fileBuffer = fs.readFileSync(resolvedPath);
const uploadResponse = await fetch("https://upload.heygen.com/v1/asset", {
method: "POST",
headers: {
"X-Api-Key": process.env.HEYGEN_API_KEY!,
"Content-Type": "image/jpeg",
},
body: fileBuffer,
});
const uploadJson: AssetUploadResponse = await uploadResponse.json();
if (uploadJson.code !== 100) {
throw new Error("Upload failed");
}
const imageKey = uploadJson.data.image_key;
// 2. Create avatar group
const createResponse = await fetch(
"https://api.heygen.com/v2/photo_avatar/avatar_group/create",
{
method: "POST",
headers: {
"X-Api-Key": process.env.HEYGEN_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({ image_key: imageKey, name }),
}
);
const createJson: PhotoAvatarResponse = await createResponse.json();
if (createJson.error) {
throw new Error(createJson.error);
}
const photoAvatarId = createJson.data.id;
// 3. Wait for processing
await waitForPhotoAvatar(photoAvatarId);
return photoAvatarId;
}
async function waitForPhotoAvatar(id: string): Promise<void> {
for (let i = 0; i < 30; i++) {
const response = await fetch(
`https://api.heygen.com/v2/photo_avatar/${id}`,
{ headers: { "X-Api-Key": process.env.HEYGEN_API_KEY! } }
);
const json: PhotoAvatarResponse = await response.json();
if (json.data.status === "completed") return;
if (json.data.status === "failed") {
throw new Error("Photo avatar processing failed");
}
await new Promise((r) => setTimeout(r, 2000));
}
throw new Error("Photo avatar processing timed out");
}
async function createVideoFromPhoto(
photoPath: string,
script: string,
voiceId: string
): Promise<string> {
// 1. Create photo avatar
const talkingPhotoId = await createPhotoAvatar(photoPath, "Video Avatar");
// 2. Generate video
const response = await fetch("https://api.heygen.com/v2/video/generate", {
method: "POST",
headers: {
"X-Api-Key": process.env.HEYGEN_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
video_inputs: [
{
character: {
type: "talking_photo",
talking_photo_id: talkingPhotoId,
},
voice: {
type: "text",
input_text: script,
voice_id: voiceId,
},
},
],
dimension: { width: 1920, height: 1080 },
}),
});
const { data } = await response.json();
return data.video_id;
}Python: Complete Workflow
import requests
import os
import time
def create_photo_avatar(image_path: str, name: str) -> str:
api_key = os.environ["HEYGEN_API_KEY"]
# 1. Upload image
with open(image_path, "rb") as f:
upload_resp = requests.post(
"https://upload.heygen.com/v1/asset",
headers={
"X-Api-Key": api_key,
"Content-Type": "image/jpeg",
},
data=f,
)
upload_data = upload_resp.json()
if upload_data.get("code") != 100:
raise Exception("Upload failed")
image_key = upload_data["data"]["image_key"]
# 2. Create avatar group
create_resp = requests.post(
"https://api.heygen.com/v2/photo_avatar/avatar_group/create",
headers={
"X-Api-Key": api_key,
"Content-Type": "application/json",
},
json={"image_key": image_key, "name": name},
)
create_data = create_resp.json()
if create_data.get("error"):
raise Exception(create_data["error"])
photo_avatar_id = create_data["data"]["id"]
# 3. Wait for processing
for _ in range(30):
status_resp = requests.get(
f"https://api.heygen.com/v2/photo_avatar/{photo_avatar_id}",
headers={"X-Api-Key": api_key},
)
status = status_resp.json()["data"]["status"]
if status == "completed":
return photo_avatar_id
if status == "failed":
raise Exception("Photo avatar processing failed")
time.sleep(2)
raise Exception("Photo avatar processing timed out")Listing Existing Talking Photos
Retrieve all talking photos in your account:
Endpoint: GET https://api.heygen.com/v1/talking_photo.list
curl "https://api.heygen.com/v1/talking_photo.list" \
-H "X-Api-Key: $HEYGEN_API_KEY"Response:
{
"code": 100,
"data": [
{
"id": "ef0ed70f72c6497793e5e36e434d2aea",
"image_url": "https://files2.heygen.ai/talking_photo/.../image.WEBP",
"circle_image": ""
}
]
}Each id can be used as talking_photo_id in video generation.
Adding Photos to an Existing Group
Add additional photo looks to an existing avatar group:
Endpoint: POST https://api.heygen.com/v2/photo_avatar/avatar_group/add
async function addPhotosToGroup(
groupId: string,
imageKeys: string[],
name: string
): Promise<void> {
const response = await fetch(
"https://api.heygen.com/v2/photo_avatar/avatar_group/add",
{
method: "POST",
headers: {
"X-Api-Key": process.env.HEYGEN_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
group_id: groupId,
image_keys: imageKeys,
name,
}),
}
);
const json = await response.json();
if (json.error) {
throw new Error(json.error);
}
}Training a Photo Avatar Group
Train the avatar group for improved animation quality:
Endpoint: POST https://api.heygen.com/v2/photo_avatar/train
curl -X POST "https://api.heygen.com/v2/photo_avatar/train" \
-H "X-Api-Key: $HEYGEN_API_KEY" \
-H "Content-Type: application/json" \
-d '{"group_id": "045c260bc0364727b2cbe50442c3a5bf"}'Check training status:
Endpoint: GET https://api.heygen.com/v2/photo_avatar/train/status/{group_id}
Avatar IV Video Generation
Avatar IV is HeyGen's latest photo avatar technology with improved quality and natural motion. It generates a video directly from an uploaded image, bypassing the avatar group creation step.
Endpoint: POST https://api.heygen.com/v2/video/av4/generate
curl -X POST "https://api.heygen.com/v2/video/av4/generate" \
-H "X-Api-Key: $HEYGEN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"image_key": "image/741299e941764988b432ed3a6757878f/original.jpg",
"script": "Hello! This is Avatar IV with enhanced quality.",
"voice_id": "1bd001e7e50f421d891986aad5158bc8",
"video_orientation": "landscape",
"video_title": "My Avatar IV Video"
}'| Field | Type | Req | Description |
|---|---|---|---|
image_key | string | ✓ | S3 image key from asset upload |
script | string | ✓ | Text for the avatar to speak |
voice_id | string | ✓ | Voice to use |
video_orientation | string | "portrait", "landscape", or "square" | |
video_title | string | Title for the video | |
fit | string | "cover" or "contain" | |
custom_motion_prompt | string | Motion/expression description | |
enhance_custom_motion_prompt | boolean | Enhance the motion prompt with AI |
TypeScript
interface AvatarIVRequest {
image_key: string;
script: string;
voice_id: string;
video_orientation?: "portrait" | "landscape" | "square";
video_title?: string;
fit?: "cover" | "contain";
custom_motion_prompt?: string;
enhance_custom_motion_prompt?: boolean;
}
interface AvatarIVResponse {
error: null | string;
data: {
video_id: string;
};
}
async function generateAvatarIVVideo(
config: AvatarIVRequest
): Promise<string> {
const response = await fetch(
"https://api.heygen.com/v2/video/av4/generate",
{
method: "POST",
headers: {
"X-Api-Key": process.env.HEYGEN_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify(config),
}
);
const json: AvatarIVResponse = await response.json();
if (json.error) {
throw new Error(json.error);
}
return json.data.video_id;
}Avatar IV Options
| Orientation | Dimensions | Use Case |
|---|---|---|
portrait | 720x1280 | TikTok, Stories |
landscape | 1280x720 | YouTube, Web |
square | 720x720 | Instagram Feed |
| Fit | Description |
|---|---|
cover | Fill the frame, may crop edges |
contain | Fit entire image, may show background |
Custom Motion Prompts
const videoId = await generateAvatarIVVideo({
image_key: "image/.../original.jpg",
script: "Let me tell you about our product.",
voice_id: "1bd001e7e50f421d891986aad5158bc8",
custom_motion_prompt: "nodding head and smiling",
enhance_custom_motion_prompt: true,
});Generating AI Photo Avatars
Generate synthetic photo avatars from text descriptions instead of uploading a photo.
Endpoint: POST https://api.heygen.com/v2/photo_avatar/photo/generate
IMPORTANT: All 8 fields are REQUIRED. The API will reject requests missing any field.
When a user asks to "generate an AI avatar of a professional man", you need to ask for or select values for ALL fields below.
Required Fields (ALL must be provided)
| Field | Type | Allowed Values |
|---|---|---|
name | string | Name for the generated avatar |
age | enum | "Young Adult", "Early Middle Age", "Late Middle Age", "Senior", "Unspecified" |
gender | enum | "Woman", "Man", "Unspecified" |
ethnicity | enum | "White", "Black", "Asian American", "East Asian", "South East Asian", "South Asian", "Middle Eastern", "Pacific", "Hispanic", "Unspecified" |
orientation | enum | "square", "horizontal", "vertical" |
pose | enum | "half_body", "close_up", "full_body" |
style | enum | "Realistic", "Pixar", "Cinematic", "Vintage", "Noir", "Cyberpunk", "Unspecified" |
appearance | string | Text prompt describing appearance (clothing, mood, lighting, etc). Max 1000 chars |
curl Example
curl -X POST "https://api.heygen.com/v2/photo_avatar/photo/generate" \
-H "X-Api-Key: $HEYGEN_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"name": "Sarah Product Demo",
"age": "Young Adult",
"gender": "Woman",
"ethnicity": "White",
"orientation": "horizontal",
"pose": "half_body",
"style": "Realistic",
"appearance": "Professional woman with a friendly smile, wearing a navy blue blazer over a white blouse, soft studio lighting, clean neutral background"
}'Response:
{
"error": null,
"data": {
"generation_id": "6a7f7f2795de4599bec7cf1e06babe30"
}
}Check Generation Status
Endpoint: GET https://api.heygen.com/v2/photo_avatar/generation/{generation_id}
The response includes multiple generated images to choose from:
{
"error": null,
"data": {
"id": "6a7f7f2795de4599bec7cf1e06babe30",
"status": "success",
"image_url_list": [
"https://resource2.heygen.ai/photo_generation/.../image1.jpg",
"https://resource2.heygen.ai/photo_generation/.../image2.jpg",
"https://resource2.heygen.ai/photo_generation/.../image3.jpg",
"https://resource2.heygen.ai/photo_generation/.../image4.jpg"
],
"image_key_list": [
"photo_generation/.../image1.jpg",
"photo_generation/.../image2.jpg",
"photo_generation/.../image3.jpg",
"photo_generation/.../image4.jpg"
]
}
}TypeScript
interface GeneratePhotoAvatarRequest {
name: string;
age: "Young Adult" | "Early Middle Age" | "Late Middle Age" | "Senior" | "Unspecified";
gender: "Woman" | "Man" | "Unspecified";
ethnicity: "White" | "Black" | "Asian American" | "East Asian" | "South East Asian" | "South Asian" | "Middle Eastern" | "Pacific" | "Hispanic" | "Unspecified";
orientation: "square" | "horizontal" | "vertical";
pose: "half_body" | "close_up" | "full_body";
style: "Realistic" | "Pixar" | "Cinematic" | "Vintage" | "Noir" | "Cyberpunk" | "Unspecified";
appearance: string;
}
interface GeneratePhotoAvatarResponse {
error: string | null;
data: {
generation_id: string;
};
}
interface PhotoGenerationStatus {
error: string | null;
data: {
id: string;
status: "pending" | "processing" | "success" | "failed";
msg: string | null;
image_url_list?: string[];
image_key_list?: string[];
};
}
async function generatePhotoAvatar(
config: GeneratePhotoAvatarRequest
): Promise<string> {
const response = await fetch(
"https://api.heygen.com/v2/photo_avatar/photo/generate",
{
method: "POST",
headers: {
"X-Api-Key": process.env.HEYGEN_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify(config),
}
);
const json: GeneratePhotoAvatarResponse = await response.json();
if (json.error) {
throw new Error(`Photo avatar generation failed: ${json.error}`);
}
return json.data.generation_id;
}
async function waitForPhotoGeneration(
generationId: string
): Promise<string[]> {
for (let i = 0; i < 60; i++) {
const response = await fetch(
`https://api.heygen.com/v2/photo_avatar/generation/${generationId}`,
{ headers: { "X-Api-Key": process.env.HEYGEN_API_KEY! } }
);
const json: PhotoGenerationStatus = await response.json();
if (json.error) throw new Error(json.error);
if (json.data.status === "success") {
return json.data.image_key_list!;
}
if (json.data.status === "failed") {
throw new Error(json.data.msg ?? "Photo generation failed");
}
await new Promise((r) => setTimeout(r, 5000));
}
throw new Error("Photo generation timed out");
}AI Photo → Avatar Group → Video
Use a generated AI photo to create an avatar group, then generate a video:
// 1. Generate AI photo
const generationId = await generatePhotoAvatar({
name: "Product Demo Host",
age: "Young Adult",
gender: "Woman",
ethnicity: "Unspecified",
orientation: "horizontal",
pose: "half_body",
style: "Realistic",
appearance: "Professional woman, navy blazer, friendly smile, soft lighting",
});
// 2. Wait for generation and pick first result
const imageKeys = await waitForPhotoGeneration(generationId);
const selectedImageKey = imageKeys[0];
// 3. Create avatar group from the AI photo
const createResponse = await fetch(
"https://api.heygen.com/v2/photo_avatar/avatar_group/create",
{
method: "POST",
headers: {
"X-Api-Key": process.env.HEYGEN_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
image_key: selectedImageKey,
name: "Product Demo Host",
generation_id: generationId,
}),
}
);
const { data } = await createResponse.json();
const talkingPhotoId = data.id;
// 4. Generate video (after status is "completed")
const videoId = await generateVideo({
video_inputs: [{
character: {
type: "talking_photo",
talking_photo_id: talkingPhotoId,
},
voice: {
type: "text",
input_text: "Welcome to our product demo!",
voice_id: "1bd001e7e50f421d891986aad5158bc8",
},
}],
dimension: { width: 1920, height: 1080 },
});Pre-Generation Checklist
Before calling the AI generation API, ensure you have values for ALL fields:
| # | Field | Question to Ask / Default |
|---|---|---|
| 1 | name | What should we call this avatar? |
| 2 | age | Young Adult / Early Middle Age / Late Middle Age / Senior? |
| 3 | gender | Woman / Man? |
| 4 | ethnicity | Which ethnicity? (see enum values above) |
| 5 | orientation | horizontal (landscape) / vertical (portrait) / square? |
| 6 | pose | half_body (recommended) / close_up / full_body? |
| 7 | style | Realistic (recommended) / Cinematic / other? |
| 8 | appearance | Describe clothing, expression, lighting, background |
If the user only provides a vague request like "create a professional looking man", ask them to specify the missing fields OR make reasonable defaults (e.g., "Early Middle Age", "Realistic" style, "half_body" pose, "horizontal" orientation).
Appearance Prompt Tips
The appearance field is a text prompt - be descriptive:
Good prompts:
- "Professional woman with shoulder-length brown hair, wearing a light blue button-down shirt, warm friendly smile, soft studio lighting, clean white background"
- "Young man with short black hair, casual tech startup style, wearing a dark hoodie, confident expression, modern office background with plants"
Avoid:
- Vague descriptions: "a nice person"
- Conflicting attributes
- Requesting specific real people
Managing Photo Avatars
Get Photo Avatar Details
Endpoint: GET https://api.heygen.com/v2/photo_avatar/{id}
async function getPhotoAvatar(id: string): Promise<PhotoAvatarResponse> {
const response = await fetch(
`https://api.heygen.com/v2/photo_avatar/${id}`,
{ headers: { "X-Api-Key": process.env.HEYGEN_API_KEY! } }
);
return response.json();
}Delete Photo Avatar
Endpoint: DELETE https://api.heygen.com/v2/photo_avatar/{id}
async function deletePhotoAvatar(id: string): Promise<void> {
const response = await fetch(
`https://api.heygen.com/v2/photo_avatar/${id}`,
{
method: "DELETE",
headers: { "X-Api-Key": process.env.HEYGEN_API_KEY! },
}
);
if (!response.ok) {
throw new Error("Failed to delete photo avatar");
}
}Delete Photo Avatar Group
Endpoint: DELETE https://api.heygen.com/v2/photo_avatar_group/{group_id}
async function deletePhotoAvatarGroup(groupId: string): Promise<void> {
const response = await fetch(
`https://api.heygen.com/v2/photo_avatar_group/${groupId}`,
{
method: "DELETE",
headers: { "X-Api-Key": process.env.HEYGEN_API_KEY! },
}
);
if (!response.ok) {
throw new Error("Failed to delete photo avatar group");
}
}API Reference
| Endpoint | Method | Description |
|---|---|---|
upload.heygen.com/v1/asset | POST | Upload image (returns image_key) |
/v2/photo_avatar/avatar_group/create | POST | Create photo avatar from image_key |
/v2/photo_avatar/avatar_group/add | POST | Add photos to existing group |
/v2/photo_avatar/train | POST | Train avatar group |
/v2/photo_avatar/train/status/{group_id} | GET | Check training status |
/v2/photo_avatar/{id} | GET | Get photo avatar details/status |
/v2/photo_avatar/{id} | DELETE | Delete photo avatar |
/v2/photo_avatar_group/{id} | DELETE | Delete avatar group |
/v2/photo_avatar/photo/generate | POST | Generate AI photo from text |
/v2/photo_avatar/generation/{id} | GET | Check AI generation status |
/v2/video/av4/generate | POST | Avatar IV video from image_key |
/v1/talking_photo.list | GET | List all existing talking photos |
/v2/video/generate | POST | Generate video with talking_photo_id |
Photo Requirements
Technical Requirements
| Aspect | Requirement |
|---|---|
| Format | JPEG, PNG |
| Resolution | Minimum 512x512px |
| File size | Under 10MB |
| Face visibility | Clear, front-facing |
Quality Guidelines
1. Lighting - Even, natural lighting on face 2. Expression - Neutral or slight smile 3. Background - Simple, uncluttered 4. Face position - Centered, not cut off 5. Clarity - Sharp, in focus 6. Angle - Straight-on or slight angle
Best Practices
1. Use high-quality photos - Better input = better output 2. Front-facing portraits - Work best for animation 3. Neutral expressions - Allow for more natural animation 4. Use Avatar IV for best quality - Latest generation technology 5. Train avatar groups - Improves animation quality 6. Reuse photo avatar IDs - Once created, use the same talking_photo_id across multiple videos
Limitations
- Photo quality significantly affects output
- Side-profile photos have limited support
- Full-body photos may not animate properly
- Some expressions may look unnatural
- Processing time varies by complexity
Video Agent Prompt Examples
Full Example: Brief to Production Prompt
Input Brief
Topic: Monthly company report for a SaaS startup
Key data: $141M ARR (up from $54M), 1.85M signups (+28%), 3M paid videos/month
Customer story: Creator built AI character, 2.5M followers, 20 min/video
Challenge: Organic traffic volatile, -16% last week
Duration: ~90 seconds
Tone: Confident CEO, data-backedOutput Prompt
FORMAT: Bloomberg-style company report. 90 seconds. Fast-paced, data-dense.
Record-breaking month. Proud but analytical.
TONE: Confident, direct, data-backed. Highlights hit hard with numbers.
Customer stories are the emotional core. Challenges are honest — no spin.
AVATAR: Man in simple black crew-neck tee, standing in a modern glass-walled
office at golden hour. Behind him, a wall-mounted display shows the company logo
in soft blue glow. Monitor to his right shows a dashboard with upward-trending
charts. Desk beside him: laptop, half-empty flat white, scattered sticky notes.
Warm afternoon light through floor-to-ceiling windows, long shadows on polished
concrete. Minimal, focused startup HQ.
STYLE — SWISS PULSE (Müller-Brockmann): Grid-locked compositions. Black (#1a1a1a),
white, electric blue (#0066FF), warm amber (#FF9500) for records. Helvetica Bold
headlines, Regular labels. Numbers LARGE. Animated counters count up from 0.
Diagonal compositions on accent moments. Grid wipe transitions. No dissolves.
CRITICAL ON-SCREEN TEXT (display literally):
- "1.85M SIGNUPS — +28% MoM"
- "$2.12M NEW SUBSCRIPTION REVENUE"
- "$54M → $141M ARR"
- "2.5M FOLLOWERS" and "20 MIN / VIDEO"
- Quote: "Use technology to serve the message, not distract from it."
- "ORGANIC: 65% OF SUBS — VOLATILE"
MUSIC: Upbeat electronic with a driving beat. Tycho meets Bloomberg opening theme.
Builds through highlights, warms for customer story, softens for challenges, peaks
on close.
---
SCENE 1 — A-ROLL (8s)
[Avatar center-frame, energetic, leaning slightly forward]
VOICEOVER: "January was a record month. New highs across acquisition, revenue,
and product velocity. Here's the full picture."
Lower-third SLIDES in: "COMPANY NAME | JANUARY 2026" white on blue bar.
Grid wipe.
SCENE 2 — FULL SCREEN B-ROLL (12s)
[NO AVATAR — motion graphic only]
VOICEOVER: "One-point-eight-five million signups — twenty-eight percent month
over month. Two-point-one-two million in new subscription revenue. Both all-time
highs."
LAYER 1: Dark #1a1a1a background with thin grid lines pulsing at 8% opacity.
LAYER 2: "1.85M" SLAMS in from left, white Bold 140pt. "SIGNUPS" types on
in electric blue 32pt uppercase. "+28% MoM" appears in amber.
LAYER 3: Three stat cards CASCADE from top-right, staggered 0.3s:
"$2.12M New Revenue" — "$3.4M Business ARR" — "$3M Pro ARR."
Each number COUNTS UP from 0.
LAYER 4: Bottom ticker scrolls: "Non-brand search +36% • Brand impressions 9.2M
• Weekly subs +20.5%"
LAYER 5: Grid lines RIPPLE outward on "1.85M" slam. Diagonal amber bar behind
stat cards.
Hard cut.
SCENE 3 — FULL SCREEN B-ROLL (12s)
[NO AVATAR — motion graphic only]
VOICEOVER: "Zoom out. Twelve months ago — fifty-four million ARR. Today —
one hundred forty-one million. Nearly three X in a single year."
LAYER 1: Dark background, subtle grid scrolling upward.
LAYER 2: Animated line chart DRAWS ITSELF left to right. Y-axis: $50M to $150M.
Final point "$140.84M" glows amber and pulses.
LAYER 3: Milestone annotations float in at key data points.
LAYER 4: Second smaller chart below — "Paid Videos" 0.91M to 2.97M, same style.
LAYER 5: Thin grid lines converge toward final data point. Scan line sweeps.
Grid wipe.
SCENE 4 — A-ROLL (8s)
[Avatar center-frame, warm tone, genuine smile]
VOICEOVER: "But the numbers only tell half the story. The other half is the
people building on the platform."
Lower-third: "Customer Spotlight"
SCENE 5 — FULL SCREEN B-ROLL (12s)
[NO AVATAR — warm palette]
VOICEOVER: "An AI character built entirely on the platform. Twenty minutes
per video. Two-point-five million Instagram followers. The creator's principle:
use technology to serve the message, not distract from it."
LAYER 1: Dark background with warm amber grid lines at low opacity.
LAYER 2: "CHARACTER NAME" in large white, center-top, 80pt.
LAYER 3: Stats cascade from right: "2.5M Followers" COUNTS UP in amber —
"20 min/video" — "7x Faster." Each a glowing node.
LAYER 4: Quote card SLIDES UP: "Use technology to serve the message, not
distract from it." Types on word by word.
LAYER 5: Warm light bloom. Grid lines soften into curved arcs.
Grid wipe.
SCENE 6 — A-ROLL (10s)
[Avatar center-frame, serious/candid]
VOICEOVER: "Now the honest part. Organic drives sixty-five percent of
subscriptions and it's volatile. Non-brand traffic dropped sixteen percent
last week. We've rebuilt attribution and we're investing in SEO."
Lower-third: "Challenges"
SCENE 7 — A-ROLL (7s)
[Avatar center-frame, energy lifts, direct eye contact]
VOICEOVER: "Fifty-four million to one-forty-one in twelve months. Three million
paid videos a month. January set the bar — now we raise it."
End card: Logo centered, blue glow fade-in. Grid lines converge. Music peaks.
---
NARRATION STYLE: CEO energy — conviction backed by data. Fast on highlights.
Warm on customer stories. Candid on challenges. Close with forward momentum.Ready-to-Use Templates
Tech News Briefing
FORMAT: 75-second high-energy tech briefing. Think: Bloomberg meets Vice.
AVATAR: [Presenter in tech-casual at a multi-monitor station.
Describe clothing, monitor content, desk items, lighting.]
STYLE — DECONSTRUCTED (Brody): Dark grey #1a1a1a, rust orange #D4501E.
Type at angles, overlapping. Gritty textures. Smash cut transitions.
CRITICAL ON-SCREEN TEXT:
- [List every stat, quote, handle that must appear]
SCENE 1 — A-ROLL (8s): Hook with energy. State what's happening.
SCENE 2 — B-ROLL (12s): First story with layered visuals (L1-L5).
SCENE 3 — A-ROLL + OVERLAY (10s): Second story, split frame.
SCENE 4 — B-ROLL (10s): Third story or dramatic data point.
SCENE 5 — A-ROLL (8s): Wrap-up and forward look.Product Comparison
FORMAT: 60-second comparison. [Product A] vs [Product B]. Data-driven.
AVATAR: [Presenter in review studio. Desk with both products visible.]
STYLE — DIGITAL GRID (Crouwel): Dark #0a0a0a, cyan #00D4FF and amber #FFB800.
Two-color coding: cyan = Product A, amber = Product B. Monospaced type.
CRITICAL ON-SCREEN TEXT:
- [Key stats for each product]
- [Pricing, features, differentiators]
Use SPLIT FRAME B-roll: Product A left, Product B right.Strategy Presentation
FORMAT: 90-second strategy briefing. Bloomberg meets board meeting.
AVATAR: [Executive in blazer over tee. Conference room with whiteboard frameworks.]
STYLE — SWISS PULSE (Müller-Brockmann): Black/white + blue #0066FF.
Grid-locked. Helvetica. Animated counters. Grid wipe transitions.
CRITICAL ON-SCREEN TEXT:
- [Framework labels, quadrant labels, key quotes]
Build frameworks visually: draw axes, plot positions, animate labels.Social Ad (30 seconds)
FORMAT: 30-second social ad. Maximum energy. Portrait 9:16.
AVATAR: [Creator-style presenter. Ring light, colorful background.]
STYLE — CARNIVAL SURGE (Lins): Hot pink, yellow, teal. Collage layering.
Text MASSIVE at angles. Confetti. Smash cuts.
Three scenes: Hook (8s) → Value prop (12s) → CTA (10s).
Text fills 50-80% of every frame. Numbers SLAM.Premium Report
FORMAT: 120-second investor-grade report. Understated authority.
AVATAR: [Tailored merino sweater. Architectural room, diffused natural light.]
STYLE — VELVET STANDARD (Vignelli): Black, white, gold #c9a84c.
Thin ALL CAPS, wide spacing. Generous negative space.
Slow cross-dissolves. Numbers fade in with weight.Video Agent Prompt Optimizer
Write effective prompts for the HeyGen Video Agent API. Based on patterns from 40+ produced videos.
The core insight: Video Agent is an HTML interpreter. It renders layouts, typography, and structured content natively. Describe B-roll as layered text motion graphics with action verbs ("slams in," "types on," "counts up") — not layout specs ("upper-left, 48pt").
Reference Files
| File | Load when... |
|---|---|
| visual-styles.md | Choosing a visual style (20 styles with full specs) |
| prompt-examples.md | Writing a prompt from scratch (full production example + templates) |
Workflow: Brief to Prompt
1. Pull data — Research the topic: web search, APIs, internal docs. Gather real quotes, stats, handles 2. Synthesize a thesis — Not a list. A story. "X is happening because Y — here's the proof." Group into 3-5 themes with a narrative arc 3. Choose a style — Match mood first, content second. Ask: "What should the viewer FEEL?" See visual-styles.md 4. Write the avatar — Thematic wardrobe matching content's emotional context. Brand logos and content-specific props in the set (see Avatar Guide below) 5. Extract critical text — List every number, quote, handle, and label that must appear literally 6. Break into scenes — One concept per scene. Rotate scene types. Never 3+ of same type in a row. At least 2 pure B-roll scenes 7. Write voiceover — Spell out numbers in VO ("one-point-eight-five million"), use figures on screen ("1.85M"). Narration on EVERY scene including B-roll 8. Layer each B-roll scene — L1 background, L2 hero, L3 supporting, L4 info bar, L5 effects. Every element must MOVE 9. Add music direction — Reference artists, describe energy arc 10. Add narration style — How to deliver: fast/slow, where to pause, emotional register per section
Prompt Anatomy
Every production-quality prompt follows this structure:
FORMAT: What kind of video, how long, what energy
TONE: Emotional register, references
AVATAR: Detailed physical + environment description (60-100 words)
STYLE: Named aesthetic with colors, typography, motion rules, transitions
CRITICAL ON-SCREEN TEXT: Exact strings that must appear
SCENE-BY-SCENE: Individual scene breakdowns with VO and layered visuals
MUSIC: Genre, reference artists, energy arc
NARRATION STYLE: How to deliver the voiceoverFORMAT
FORMAT: 75-second high-energy tech daily briefing. Think: a creator who just got amazing news.
FORMAT: Bloomberg-style strategy briefing. 100-120 seconds. CEO-delivered.TONE
TONE: Confident, direct, data-backed. Highlights hit hard. Lowlights are honest — no spin.
TONE: Edgy, punk tech commentary. Vice News meets The Face magazine — raw, confrontational.CRITICAL ON-SCREEN TEXT
List every exact string that must appear on screen. Without this, the agent may summarize, round numbers, or rephrase quotes.
CRITICAL ON-SCREEN TEXT (display literally):
- "$141M ARR — All-Time High"
- "1.85M Signups — +28% MoM"
- Quote: "Use technology to serve the message, not distract from it." — Shalev Hani
- "@username" — exact social handleMUSIC & NARRATION
MUSIC: Driving electronic, heavy bass drops on key numbers. Run the Jewels meets
a tech keynote. Builds relentlessly, only softens for customer stories.
NARRATION STYLE: High energy throughout. Let numbers PUNCH — pause before big ones,
then deliver hard. Customer stories get warmth. The close should feel like a mic drop.Avatar Description Guide
The avatar is NOT a fixed headshot — design it for each video like a movie character. Think costume designer + set designer.
Thematic Wardrobe Rule
The avatar's outfit and environment MUST match the content's emotional/cultural context:
| Content Type | Avatar Design | NOT This |
|---|---|---|
| Chinese New Year | Red qipao with gold embroidery, lantern-lit courtyard | "Reporter in a blazer" |
| Breaking tech news | Field reporter, windswept hair, earpiece, city skyline | "Anchor at a desk" |
| Sleep science | Oversized cream knit, cross-legged on bed, warm lamp | "Analyst in a lab" |
| Reddit community | Messy desk, Reddit alien on monitors, upvote arrows on wall | "Researcher in a studio" |
What to Specify
| Element | Weak | Strong |
|---|---|---|
| Clothing | "Business casual" | "Black ribbed merino turtleneck, high collar framing jaw" |
| Environment | "An office" | "Glass-walled conference room. Whiteboard with hand-drawn tier pyramid" |
| Monitor content | "Computer screens" | "Monitor shows scrolling green terminal text and red security alerts" |
| Lighting | "Well lit" | "Cool blue monitor glow from left, warm amber desk lamp from right" |
Template
AVATAR: [Clothing — fabric, color, fit, accessories, posture].
[Setting — specific props, brand logos, what's on the walls].
[Monitors/desk — content visible on screens, items on desk].
[Lighting — direction, color temperature]. [Mood of the space].
60-100 words. 3+ content-specific props. Brand elements visible.Scene Types
| Type | Format | When to Use |
|---|---|---|
| A-ROLL | Avatar speaking to camera | Intros, key insights, CTAs, emotional beats |
| FULL SCREEN B-ROLL | No avatar — motion graphics only | Data visualization, information-dense content |
| A-ROLL + OVERLAY | Split frame: avatar + content | Presenting data while maintaining human connection |
Rotation is mandatory. Never 3+ of the same type in a row. Every prompt needs at least 2 pure B-roll scenes.
Voiceover on EVERY scene. Every B-roll scene MUST include a VOICEOVER: line. Silent B-roll = broken video.
Scene Anatomy
A-ROLL:
SCENE 1 — A-ROLL (10s)
[Avatar center-frame, excited, hands gesturing]
VOICEOVER: "The exact script for this scene."
Lower-third: "TITLE TEXT" white on blue bar.B-ROLL with layers:
SCENE 2 — FULL SCREEN B-ROLL (12s)
[NO AVATAR — motion graphic only]
VOICEOVER: "The exact script for this scene."
LAYER 1: Dark #1a1a1a background with subtle grid lines pulsing.
LAYER 2: "HEADLINE" SLAMS in from left in white Bold 100pt at -5 degrees.
LAYER 3: Three data cards CASCADE from right, staggered 0.3s.
LAYER 4: Bottom ticker SLIDES in: "supporting text scrolling continuously."
LAYER 5: Grid lines RIPPLE outward from impact point.
Hard cut.A-ROLL + OVERLAY:
SCENE 3 — A-ROLL + OVERLAY (10s)
[SPLIT — Avatar LEFT 35%. Content RIGHT 65%. NO overlap.]
Avatar gestures toward content side.
VOICEOVER: "The exact script for this scene."
RIGHT SIDE: "HEADLINE" in cyan 60pt. Three stats COUNT UP below.Alternate which side the avatar appears on between overlay scenes.
The Visual Layer System
Break B-roll into 5 stacked layers. This is the most powerful technique for motion graphics scenes.
| Layer | Purpose | Examples |
|---|---|---|
| L1 | Background | Textured surface, grid, gradient, color field |
| L2 | Hero content | Main headline/number that dominates the frame |
| L3 | Supporting data | Cards, stats, bullet points, secondary information |
| L4 | Information bar | Tickers, labels, source attributions, quotes |
| L5 | Effects | Particles, glitches, grid animations, ambient motion |
Every B-roll: 4+ layers. Every overlay content side: 3+ layers. Every element must MOVE.
Motion Vocabulary
High Energy
| Verb | Example |
|---|---|
| SLAMS | "$95M" SLAMS in from left at -5 degrees |
| CRASHES | Title CRASHES in from right, screen-shake on impact |
| PUNCHES | Quote card PUNCHES up from bottom |
| STAMPS | Data blocks STAMP in staggered 0.4s |
| SHATTERS | Text SHATTERS after 1.5s, revealing number underneath |
Medium Energy
| Verb | Example |
|---|---|
| CASCADE | Three cards CASCADE from top, staggered 0.3s |
| SLIDES | Ticker SLIDES in from right — continuous scroll |
| DROPS | "TIER 1" DROPS in with white flash |
| FILLS | Progress bar FILLS 0 to 90% in orange |
| DRAWS | Chart line DRAWS itself left to right |
Low Energy
| Verb | Example |
|---|---|
| types on | Quote types on word by word in italic white |
| fades in | Logo fades in at center, held for 3 seconds |
| FLOATS | Bokeh orbs FLOAT across frame at different speeds |
| morphs | Number morphs from 17 to 18.9 |
| COUNTS UP | "1.85M" COUNTS UP from 0 in amber 96pt |
Transition Types
| Transition | Energy | Styles It Fits |
|---|---|---|
| Smash cut | Aggressive | Deconstructed, Maximalist, Carnival Surge |
| White flash frame | Punchy | Deconstructed, Maximalist |
| Grid wipe | Systematic | Swiss Pulse, Digital Grid |
| Hard cut | Clean | Swiss Pulse, Shadow Cut |
| Liquid dissolve | Elegant | Data Drift, Dream State |
| Slow cross-dissolve | Refined | Velvet Standard |
| Pop cut / bounce | Fun | Play Mode, Carnival Surge |
| Snap cut | Urgent | Red Wire, Contact Sheet |
| Soft dissolve | Warm | Soft Signal, Warm Grain, Quiet Drama |
| Iris wipe | Nostalgic | Heritage Reel |
Timing Guidelines
| Content Type | Duration |
|---|---|
| Hook/Intro (A-roll) | 6-10 seconds |
| Data-heavy B-roll | 10-15 seconds (NEVER ≤5s — causes black frames) |
| A-roll + Overlay | 8-12 seconds |
| CTA / Close (A-roll) | 6-8 seconds |
Common video lengths: Social clip: 30-45s (5-7 scenes) | Briefing: 60-75s (7-9 scenes) | Deep dive: 90-120s (10-13 scenes)
Speaking pace: ~150 words/minute. Calculate: words / 150 * 60 = seconds
What Doesn't Work
Patterns that consistently produce poor results:
Layout language — Screen coordinates cause empty/black B-roll:
❌ "UPPER-LEFT: headline in 48pt Helvetica"
❌ "CENTER-SCREEN: display at coordinates (400, 300)"
✅ "135K" SLAMS in from left, white Impact 120pt, fills 40% of frame.Named artists without specs — "Ikko Tanaka style" means nothing to Video Agent. Translate to concrete rules:
❌ "Use an Ikko Tanaka style"
✅ "Flat color blocks, maximum 3 colors per frame, 60% negative space, typography as primary element"Style examples injected into prompts — Full example scenes from a style library confuse the agent. Use the style's rules, not example scenes.
Forced short B-roll (≤5 seconds) — Too short for rendering. Every tested video with 5s B-roll had empty/black screens. Use 10-15s.
Content as a list, not a story — "Here are 5 tweets" produces flat videos. Always synthesize: "X is happening because Y — here's the proof."
Production Insights
Style Performance (from 40+ videos)
| Rank | Style | Strength |
|---|---|---|
| 1 | Deconstructed (Brody) | Most reliable across all topics |
| 2 | Swiss Pulse (Müller-Brockmann) | Best for data-heavy content |
| 3 | Digital Grid (Crouwel) | Strong for tech topics |
| 4 | Geometric Bold (Tanaka) | Elegant and versatile |
| 5 | Maximalist Type (Scher) | High energy, use sparingly |
Duration by Approach
| Approach | Avg Duration | Quality |
|---|---|---|
| Natural storyboard + custom avatar | ~106s | Best |
| Natural storyboard, no custom avatar | ~69s | Good |
| Forced short scenes + custom avatar | ~71s | Mixed |
| Layout language prompts | ~48s | Poor |
Quality Checklist
- [ ] Thesis-driven — story, not bullet points
- [ ] Style named with colors, typography, motion, transitions (see visual-styles.md)
- [ ] Avatar has thematic wardrobe + branded environment (60-100 words)
- [ ] Critical text listed — every stat, quote, label
- [ ] Scenes rotate types — never 3+ same type. At least 2 B-roll scenes
- [ ] Every scene has VOICEOVER — including B-roll
- [ ] B-roll scenes have 4+ layers, every element has motion verbs
- [ ] B-roll scenes are 10-15 seconds (never ≤5s)
- [ ] Brand logos appear when discussing companies
- [ ] Every element moves — no static frames
HeyGen Quota and Credits
HeyGen uses a credit-based system for video generation. Understanding quota management helps prevent failed video generation requests.
Checking Remaining Quota
curl
curl -X GET "https://api.heygen.com/v2/user/remaining_quota" \
-H "X-Api-Key: $HEYGEN_API_KEY"TypeScript
interface QuotaResponse {
error: null | string;
data: {
remaining_quota: number;
used_quota: number;
};
}
const response = await fetch("https://api.heygen.com/v2/user/remaining_quota", {
headers: { "X-Api-Key": process.env.HEYGEN_API_KEY! },
});
const { data }: QuotaResponse = await response.json();
console.log(`Remaining credits: ${data.remaining_quota}`);Python
import requests
import os
response = requests.get(
"https://api.heygen.com/v2/user/remaining_quota",
headers={"X-Api-Key": os.environ["HEYGEN_API_KEY"]}
)
data = response.json()["data"]
print(f"Remaining credits: {data['remaining_quota']}")Response Format
{
"error": null,
"data": {
"remaining_quota": 450,
"used_quota": 50
}
}Credit Consumption
Different operations consume different amounts of credits:
| Operation | Credit Cost | Notes |
|---|---|---|
| Standard video (1 min) | ~1 credit per minute | Varies by resolution |
| 720p video | Base rate | Standard quality |
| 1080p video | ~1.5x base rate | Higher quality |
| Video translation | Varies | Depends on video length |
| Streaming avatar | Per session | Real-time usage |
Pre-Generation Quota Check
Always verify sufficient quota before generating videos:
async function generateVideoWithQuotaCheck(videoConfig: VideoConfig) {
// Check quota first
const quotaResponse = await fetch(
"https://api.heygen.com/v2/user/remaining_quota",
{ headers: { "X-Api-Key": process.env.HEYGEN_API_KEY! } }
);
const { data: quota } = await quotaResponse.json();
// Estimate required credits (rough estimate: 1 credit per minute)
const estimatedMinutes = videoConfig.estimatedDuration / 60;
const requiredCredits = Math.ceil(estimatedMinutes);
if (quota.remaining_quota < requiredCredits) {
throw new Error(
`Insufficient credits. Need ${requiredCredits}, have ${quota.remaining_quota}`
);
}
// Proceed with video generation
return generateVideo(videoConfig);
}Quota Management Best Practices
1. Monitor Usage Regularly
async function logQuotaUsage() {
const response = await fetch(
"https://api.heygen.com/v2/user/remaining_quota",
{ headers: { "X-Api-Key": process.env.HEYGEN_API_KEY! } }
);
const { data } = await response.json();
console.log({
remaining: data.remaining_quota,
used: data.used_quota,
percentUsed: (
(data.used_quota / (data.remaining_quota + data.used_quota)) *
100
).toFixed(1),
});
}2. Set Up Alerts
const QUOTA_WARNING_THRESHOLD = 50;
async function checkQuotaWithAlert() {
const response = await fetch(
"https://api.heygen.com/v2/user/remaining_quota",
{ headers: { "X-Api-Key": process.env.HEYGEN_API_KEY! } }
);
const { data } = await response.json();
if (data.remaining_quota < QUOTA_WARNING_THRESHOLD) {
// Send alert (email, Slack, etc.)
await sendAlert(`Low HeyGen quota: ${data.remaining_quota} credits remaining`);
}
return data;
}3. Use Test Mode for Development
When available, use test mode to avoid consuming credits during development:
const videoConfig = {
test: true, // Use test mode during development
video_inputs: [...],
};
// Test videos may have watermarks but don't consume creditsSubscription Tiers
Different subscription tiers have different quota allocations and features:
| Tier | Features |
|---|---|
| Free | Limited credits, basic features |
| Creator | More credits, standard avatars |
| Team | Higher limits, team collaboration |
| Enterprise | Custom limits, API access, priority support |
API access typically requires Enterprise tier or higher.
Error Handling for Quota Issues
async function handleQuotaError(error: any) {
if (error.message.includes("quota") || error.message.includes("credit")) {
console.error("Quota exceeded. Consider:");
console.error("1. Upgrading your subscription");
console.error("2. Waiting for quota reset");
console.error("3. Purchasing additional credits");
// Check current quota
const quota = await getQuota();
console.error(`Current remaining: ${quota.remaining_quota}`);
}
throw error;
}HeyGen + Remotion Integration
This guide covers workflows for generating HeyGen avatar videos and using them in Remotion compositions.
Quick Start
// 1. Get avatar with default voice
const avatar = await getAvatarDetails(avatarId);
// 2. Generate video (MP4 with background - most common)
const videoId = await generateVideo({
video_inputs: [{
character: { type: "avatar", avatar_id: avatar.id, avatar_style: "normal" },
voice: { type: "text", input_text: script, voice_id: avatar.default_voice_id },
background: { type: "color", value: "#1a1a2e" },
}],
dimension: { width: 1920, height: 1080 },
});
// 3. Poll for completion (10-15+ min)
// 4. Use in Remotion with motion graphics overlaid on topOverview
A typical workflow: 1. Generate avatar video with HeyGen 2. Wait for completion and get video URL 3. Download or use URL directly in Remotion 4. Compose with other elements (backgrounds, overlays, animations)
Choosing the Right Output Format
| Your Composition | Recommended | Why |
|---|---|---|
| Avatar as presenter with overlays | MP4 + background | Simpler, overlays go on top |
| Loom-style (avatar over screen recording) | WebM + closeUp, mask in Remotion | Need transparency, apply circle mask in CSS |
| Avatar overlaid ON other video/content | WebM (transparent) | Need to see through to content behind |
| Full-screen avatar | MP4 + background | Standard approach |
Use MP4 with background for most cases. Use WebM when you need to see content behind the avatar.
Note: WebM only supports normal and closeUp styles. For circular framing, use CSS border-radius: 50% in Remotion.
Recommended: Parallel Development Workflow
HeyGen video generation takes 10-15+ minutes. Don't wait - work in parallel:
1. Start HeyGen generation - save video_id to a file, exit immediately 2. Build Remotion composition - use a placeholder or the avatar's preview_video_url (a short loop) 3. Check HeyGen status periodically or when done building 4. Swap placeholder for real video URL once ready
Estimate duration from script: ~150 words/minute speech rate, so wordCount / 150 * 60 * fps gives approximate frames.
Composition tip: Design components to work with or without the avatar video, so motion graphics can be tested independently.
Dimension Alignment
Critical: Match HeyGen output dimensions to your Remotion composition.
Common Dimension Presets
// Shared dimension constants for both HeyGen and Remotion
const DIMENSIONS = {
landscape_1080p: { width: 1920, height: 1080 },
landscape_720p: { width: 1280, height: 720 },
portrait_1080p: { width: 1080, height: 1920 },
portrait_720p: { width: 720, height: 1280 },
square_1080p: { width: 1080, height: 1080 },
square_720p: { width: 720, height: 720 },
} as const;
type DimensionPreset = keyof typeof DIMENSIONS;HeyGen Video Generation
// Generate HeyGen video with specific dimensions
async function generateHeyGenVideo(
script: string,
avatarId: string,
voiceId: string,
preset: DimensionPreset
): Promise<string> {
const dimension = DIMENSIONS[preset];
const response = await fetch("https://api.heygen.com/v2/video/generate", {
method: "POST",
headers: {
"X-Api-Key": process.env.HEYGEN_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
video_inputs: [
{
character: {
type: "avatar",
avatar_id: avatarId,
avatar_style: "normal",
},
voice: {
type: "text",
input_text: script,
voice_id: voiceId,
},
background: {
type: "color",
value: "#00FF00", // Green screen for compositing
},
},
],
dimension,
}),
});
const { data } = await response.json();
return data.video_id;
}Remotion Composition Setup
// remotion/src/Root.tsx
import { Composition } from "remotion";
import { AvatarComposition } from "./AvatarComposition";
const DIMENSIONS = {
landscape_1080p: { width: 1920, height: 1080 },
// ... same as above
};
export const RemotionRoot: React.FC = () => {
return (
<>
<Composition
id="AvatarVideo"
component={AvatarComposition}
durationInFrames={300} // Will be set dynamically
fps={30}
width={DIMENSIONS.landscape_1080p.width}
height={DIMENSIONS.landscape_1080p.height}
defaultProps={{
avatarVideoUrl: "",
}}
/>
</>
);
};Generating Avatar Video for Remotion
Standard: MP4 with Background
Most Remotion compositions work best with MP4 + background. Overlays and motion graphics go on top:
async function generateAvatarForRemotion(
script: string,
avatarId: string,
voiceId: string,
options: {
style?: "normal" | "closeUp" | "circle";
backgroundColor?: string;
} = {}
): Promise<string> {
const { style = "normal", backgroundColor = "#1a1a2e" } = options;
const response = await fetch("https://api.heygen.com/v2/video/generate", {
method: "POST",
headers: {
"X-Api-Key": process.env.HEYGEN_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
video_inputs: [{
character: {
type: "avatar",
avatar_id: avatarId,
avatar_style: style,
},
voice: {
type: "text",
input_text: script,
voice_id: voiceId,
},
background: {
type: "color",
value: backgroundColor,
},
}],
dimension: { width: 1920, height: 1080 },
}),
});
const { data } = await response.json();
return data.video_id;
}Transparent Background (WebM)
Only use when you need to see content behind the avatar (e.g., avatar overlaid on screen recording):
// Use /v1/video.webm endpoint for transparent background
// Note: Different structure than /v2/video/generate
const response = await fetch("https://api.heygen.com/v1/video.webm", {
method: "POST",
headers: {
"X-Api-Key": process.env.HEYGEN_API_KEY!,
"Content-Type": "application/json",
},
body: JSON.stringify({
avatar_pose_id: avatarPoseId, // Required: avatar pose ID
avatar_style: "normal", // Required: "normal" or "closeUp" only
input_text: script, // Required (with voice_id)
voice_id: voiceId, // Required (with input_text)
dimension: { width: 1920, height: 1080 },
}),
});Using HeyGen Video in Remotion
Important: Use OffthreadVideo for Frame-Accurate Rendering
Always use `OffthreadVideo` instead of `Video` for HeyGen avatar videos. The basic Video component uses the browser's video decoder which isn't frame-accurate, causing jitter during rendering. OffthreadVideo extracts frames via FFmpeg for smooth, accurate playback.
OffthreadVideo is included in the core remotion package - no additional install needed.
Basic Usage
// remotion/src/AvatarComposition.tsx
import { OffthreadVideo, useVideoConfig } from "remotion";
interface AvatarCompositionProps {
avatarVideoUrl: string;
}
export const AvatarComposition: React.FC<AvatarCompositionProps> = ({
avatarVideoUrl,
}) => {
return (
<div style={{ flex: 1, backgroundColor: "#1a1a2e" }}>
<OffthreadVideo
src={avatarVideoUrl}
style={{
width: "100%",
height: "100%",
objectFit: "contain",
}}
/>
</div>
);
};WebM with Transparent Background (Recommended)
Using WebM from /v1/video.webm - no chroma keying needed:
import { OffthreadVideo, AbsoluteFill, Sequence } from "remotion";
export const AvatarWithMotionGraphics: React.FC<{
avatarWebmUrl: string
}> = ({ avatarWebmUrl }) => {
return (
<AbsoluteFill>
{/* Layer 1: Your background/content */}
<AbsoluteFill style={{ backgroundColor: "#1a1a2e" }}>
<YourMotionGraphics />
</AbsoluteFill>
{/* Layer 2: Avatar with transparent background - use OffthreadVideo for frame-accurate rendering */}
<OffthreadVideo
src={avatarWebmUrl}
transparent
style={{
position: "absolute",
bottom: 0,
right: 0,
width: "50%",
height: "auto",
}}
/>
{/* Layer 3: Overlays on top of avatar */}
<Sequence from={30}>
<AnimatedTitle text="Welcome!" />
</Sequence>
</AbsoluteFill>
);
};Loom-Style: Circle Avatar Over Screen Recording
Use closeUp style + WebM, then apply circular mask in Remotion:
import { OffthreadVideo, AbsoluteFill } from "remotion";
export const LoomStyleComposition: React.FC<{
screenRecordingUrl: string;
avatarWebmUrl: string; // Generated with avatar_style: "closeUp" via /v1/video.webm
}> = ({ screenRecordingUrl, avatarWebmUrl }) => {
return (
<AbsoluteFill>
{/* Screen recording fills the frame */}
<OffthreadVideo src={screenRecordingUrl} style={{ width: "100%", height: "100%" }} />
{/* Avatar with circular mask - transparent bg shows screen behind */}
<OffthreadVideo
src={avatarWebmUrl}
transparent
style={{
position: "absolute",
bottom: 40,
left: 40,
width: 180,
height: 180,
borderRadius: "50%", // Circular mask applied in CSS
overflow: "hidden",
objectFit: "cover",
}}
/>
</AbsoluteFill>
);
};Note: WebM doesn't support circle style - use normal or closeUp and apply circular masking via CSS.
Legacy: Green Screen with Chroma Key
If using MP4 with green background (not recommended - use WebM instead):
// Note: True chroma key requires WebGL or post-processing
// WebM transparent background is much simpler
<OffthreadVideo
src={avatarVideoUrl}
style={{
mixBlendMode: "multiply", // Basic compositing only
}}
/>Layered Composition
import { OffthreadVideo, Sequence, useVideoConfig, Img } from "remotion";
interface LayeredAvatarProps {
avatarVideoUrl: string;
backgroundUrl: string;
logoUrl: string;
title: string;
}
export const LayeredAvatarComposition: React.FC<LayeredAvatarProps> = ({
avatarVideoUrl,
backgroundUrl,
logoUrl,
title,
}) => {
const { fps } = useVideoConfig();
return (
<div style={{ position: "relative", width: "100%", height: "100%" }}>
{/* Layer 1: Background */}
<Img
src={backgroundUrl}
style={{
position: "absolute",
width: "100%",
height: "100%",
objectFit: "cover",
}}
/>
{/* Layer 2: Avatar video - use OffthreadVideo to prevent jitter */}
<OffthreadVideo
src={avatarVideoUrl}
style={{
position: "absolute",
bottom: 0,
right: 0,
width: "40%",
height: "auto",
}}
/>
{/* Layer 3: Title (appears after 1 second) */}
<Sequence from={fps}>
<div
style={{
position: "absolute",
top: 50,
left: 50,
color: "white",
fontSize: 48,
fontWeight: "bold",
}}
>
{title}
</div>
</Sequence>
{/* Layer 4: Logo */}
<Img
src={logoUrl}
style={{
position: "absolute",
top: 20,
right: 20,
width: 100,
height: "auto",
}}
/>
</div>
);
};Complete Workflow
Generate and Compose
import { bundle } from "@remotion/bundler";
import { renderMedia, selectComposition } from "@remotion/renderer";
async function generateAvatarVideoForRemotion(
script: string,
outputPath: string
) {
// 1. Generate HeyGen video
console.log("Generating HeyGen avatar video...");
const videoId = await generateHeyGenVideo(
script,
"josh_lite3_20230714",
"1bd001e7e50f421d891986aad5158bc8",
"landscape_1080p"
);
// 2. Wait for completion
console.log("Waiting for HeyGen video...");
const avatarVideoUrl = await waitForVideo(videoId);
console.log(`HeyGen video ready: ${avatarVideoUrl}`);
// 3. Get video duration for Remotion
const avatarDuration = await getVideoDuration(avatarVideoUrl);
const durationInFrames = Math.ceil(avatarDuration * 30); // 30 fps
// 4. Bundle Remotion project
console.log("Bundling Remotion project...");
const bundleLocation = await bundle({
entryPoint: "./remotion/src/index.ts",
});
// 5. Select composition
const composition = await selectComposition({
serveUrl: bundleLocation,
id: "AvatarVideo",
inputProps: {
avatarVideoUrl,
},
});
// 6. Render final video
console.log("Rendering final composition...");
await renderMedia({
composition: {
...composition,
durationInFrames,
},
serveUrl: bundleLocation,
codec: "h264",
outputLocation: outputPath,
inputProps: {
avatarVideoUrl,
},
});
console.log(`Final video rendered: ${outputPath}`);
return outputPath;
}Dynamic Duration with calculateMetadata
// remotion/src/AvatarComposition.tsx
import { CalculateMetadataFunction } from "remotion";
export const calculateAvatarMetadata: CalculateMetadataFunction<
AvatarCompositionProps
> = async ({ props }) => {
// Fetch video duration from HeyGen video
const duration = await getVideoDurationInSeconds(props.avatarVideoUrl);
return {
durationInFrames: Math.ceil(duration * 30),
fps: 30,
width: 1920,
height: 1080,
};
};
// In Root.tsx
<Composition
id="AvatarVideo"
component={AvatarComposition}
calculateMetadata={calculateAvatarMetadata}
defaultProps={{
avatarVideoUrl: "",
}}
/>Best Practices
1. Use Green Screen for Flexibility
Generate HeyGen videos with green screen background when you want to composite:
background: {
type: "color",
value: "#00FF00", // Pure green for chroma key
}2. Match Frame Rates
HeyGen default is 25 fps. Consider this when setting Remotion fps:
// Option 1: Match HeyGen's 25 fps
fps: 25
// Option 2: Use 30 fps with playback rate adjustment
<OffthreadVideo
src={avatarVideoUrl}
playbackRate={25/30} // Slow down slightly to match
/>3. URL vs Download: When to Use Each
Use URL directly when:
- Previewing in Remotion Studio (
npm run dev) - URL won't expire before render completes
- You want faster iteration during development
// Direct URL usage - simpler, faster for dev
<OffthreadVideo src={avatarVideoUrl} />Download first when:
- URL has expiration (HeyGen URLs expire after ~24 hours)
- Rendering will happen later or repeatedly
- Network reliability is a concern
- You need offline rendering
// Download with retry for reliability
async function downloadVideoWithRetry(
url: string,
outputPath: string,
maxRetries = 5
): Promise<string> {
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await fetch(url);
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const buffer = await response.arrayBuffer();
await fs.promises.writeFile(outputPath, Buffer.from(buffer));
return outputPath;
} catch (error) {
const delay = 2000 * Math.pow(2, attempt);
console.log(`Retry ${attempt + 1}/${maxRetries} in ${delay}ms...`);
await new Promise((r) => setTimeout(r, delay));
}
}
throw new Error("Download failed after retries");
}
// Use local file in Remotion
const localPath = await downloadVideoWithRetry(avatarVideoUrl, "./public/avatar.mp4");Hybrid approach (recommended for production):
// Save both URL and local path in metadata
const metadata = {
videoUrl: result.video_url, // For quick preview
localPath: "./public/avatar.mp4", // For reliable rendering
expiresAt: Date.now() + 24 * 60 * 60 * 1000, // URL expiration
};
// In Remotion component, prefer local if available
const videoSrc = fs.existsSync(localPath) ? staticFile("avatar.mp4") : avatarVideoUrl;4. Handle Avatar Positioning
Common avatar positions in compositions:
const AVATAR_POSITIONS = {
fullscreen: { width: "100%", height: "100%", position: "center" },
bottomRight: { width: "40%", bottom: 0, right: 0 },
bottomLeft: { width: "40%", bottom: 0, left: 0 },
pictureInPicture: { width: "25%", bottom: 20, right: 20 },
leftThird: { width: "33%", left: 0, height: "100%" },
};Output Formats
HeyGen Output
- Format: MP4 (H.264)
- Audio: AAC
- Resolution: As specified in request
Remotion Output
- Codec: H.264 (default), VP8, VP9, ProRes
- Match or exceed HeyGen quality settings
await renderMedia({
codec: "h264",
crf: 18, // High quality
// ...
});Troubleshooting
Video Not Playing in Remotion
1. Check URL accessibility (CORS issues) 2. Verify video format compatibility 3. Try downloading locally first
Dimension Mismatch
Ensure both HeyGen and Remotion use identical dimensions:
// Shared config
const VIDEO_CONFIG = {
width: 1920,
height: 1080,
fps: 30,
};
// HeyGen
dimension: { width: VIDEO_CONFIG.width, height: VIDEO_CONFIG.height }
// Remotion
<Composition width={VIDEO_CONFIG.width} height={VIDEO_CONFIG.height} />Video Jitter During Rendering
If avatar video appears jittery or stuttery in rendered output:
1. Use `OffthreadVideo` instead of `Video` - The basic Video component uses the browser's video decoder which isn't frame-accurate 2. Update imports (no additional install needed - it's in core remotion):
// Before (causes jitter)
import { Video } from "remotion";
// After (frame-accurate)
import { OffthreadVideo } from "remotion";3. For WebM with transparency, add the transparent prop:
<OffthreadVideo src={avatarWebmUrl} transparent />Audio Sync Issues
If avatar audio drifts:
- Verify source video frame rate
- Check for encoding issues
- Consider re-encoding with consistent settings
Text Overlays
Add text overlays to your HeyGen videos for titles, captions, lower thirds, and other on-screen text elements.
Basic Text Overlay
const videoConfig = {
video_inputs: [
{
character: {
type: "avatar",
avatar_id: "josh_lite3_20230714",
avatar_style: "normal",
},
voice: {
type: "text",
input_text: "Welcome to our presentation!",
voice_id: "1bd001e7e50f421d891986aad5158bc8",
},
background: {
type: "color",
value: "#1a1a2e",
},
},
],
// Text overlay configuration (if supported in your API tier)
// Note: Availability varies by plan
};Text Overlay Configuration
Text overlays typically support these properties:
interface TextOverlay {
text: string;
x: number; // X position (pixels or percentage)
y: number; // Y position (pixels or percentage)
width?: number; // Text box width
height?: number; // Text box height
font_family?: string;
font_size?: number;
font_color?: string;
background_color?: string;
text_align?: "left" | "center" | "right";
duration?: {
start: number; // Start time in seconds
end: number; // End time in seconds
};
}Positioning Text
Coordinate System
- Origin: Top-left corner (0, 0)
- X-axis: Increases to the right
- Y-axis: Increases downward
- Units: Typically pixels or percentage of video dimensions
Common Positions
For a 1920x1080 video:
| Position | X | Y | Description |
|---|---|---|---|
| Top-left | 50 | 50 | Upper left corner |
| Top-center | 960 | 50 | Top center |
| Top-right | 1870 | 50 | Upper right corner |
| Center | 960 | 540 | Dead center |
| Bottom-left | 50 | 1030 | Lower third left |
| Bottom-center | 960 | 1030 | Lower third center |
Position Helper Function
interface Position {
x: number;
y: number;
}
function getTextPosition(
location: "top-left" | "top-center" | "top-right" | "center" | "bottom-left" | "bottom-center" | "bottom-right",
videoWidth: number,
videoHeight: number,
padding: number = 50
): Position {
const positions: Record<string, Position> = {
"top-left": { x: padding, y: padding },
"top-center": { x: videoWidth / 2, y: padding },
"top-right": { x: videoWidth - padding, y: padding },
"center": { x: videoWidth / 2, y: videoHeight / 2 },
"bottom-left": { x: padding, y: videoHeight - padding },
"bottom-center": { x: videoWidth / 2, y: videoHeight - padding },
"bottom-right": { x: videoWidth - padding, y: videoHeight - padding },
};
return positions[location];
}Font Styling
Available Font Properties
const textStyle = {
font_family: "Arial",
font_size: 48,
font_color: "#FFFFFF",
font_weight: "bold",
background_color: "rgba(0, 0, 0, 0.5)",
text_align: "center",
};Common Font Families
| Font | Style | Use Case |
|---|---|---|
| Arial | Sans-serif | Clean, universal |
| Helvetica | Sans-serif | Modern, professional |
| Times New Roman | Serif | Traditional, formal |
| Georgia | Serif | Elegant, readable |
| Roboto | Sans-serif | Modern, digital |
| Open Sans | Sans-serif | Friendly, accessible |
Common Text Overlay Patterns
Title Card
const titleOverlay = {
text: "Product Demo",
x: 960,
y: 540,
font_family: "Arial",
font_size: 72,
font_color: "#FFFFFF",
text_align: "center",
duration: {
start: 0,
end: 3,
},
};Lower Third (Name/Title)
const lowerThirdOverlay = {
text: "John Smith\nCEO, Company Inc.",
x: 100,
y: 900,
font_family: "Arial",
font_size: 36,
font_color: "#FFFFFF",
background_color: "rgba(0, 102, 204, 0.9)",
text_align: "left",
duration: {
start: 2,
end: 8,
},
};Call to Action
const ctaOverlay = {
text: "Visit example.com",
x: 960,
y: 1000,
font_family: "Arial",
font_size: 42,
font_color: "#FFD700",
text_align: "center",
duration: {
start: 25,
end: 30,
},
};Creating Text Overlay Templates
interface TextOverlayTemplate {
name: string;
style: Partial<TextOverlay>;
}
const templates: TextOverlayTemplate[] = [
{
name: "title",
style: {
font_family: "Arial",
font_size: 72,
font_color: "#FFFFFF",
text_align: "center",
},
},
{
name: "subtitle",
style: {
font_family: "Arial",
font_size: 42,
font_color: "#CCCCCC",
text_align: "center",
},
},
{
name: "lower-third",
style: {
font_family: "Arial",
font_size: 36,
font_color: "#FFFFFF",
background_color: "rgba(0, 0, 0, 0.7)",
text_align: "left",
},
},
{
name: "caption",
style: {
font_family: "Arial",
font_size: 32,
font_color: "#FFFFFF",
background_color: "rgba(0, 0, 0, 0.5)",
text_align: "center",
},
},
];
function createTextOverlay(
text: string,
templateName: string,
position: Position,
duration?: { start: number; end: number }
): TextOverlay {
const template = templates.find((t) => t.name === templateName);
if (!template) {
throw new Error(`Template "${templateName}" not found`);
}
return {
text,
x: position.x,
y: position.y,
...template.style,
duration,
};
}Timing Text Overlays
Coordinate text appearance with your script:
// Script with timing markers
const script = `
Hello and welcome. [0:00 - 0:03]
Let me show you our features. [0:03 - 0:08]
First, we have analytics. [0:08 - 0:15]
Get started today! [0:15 - 0:20]
`;
// Matching text overlays
const overlays = [
{
text: "Welcome",
duration: { start: 0, end: 3 },
...titleStyle,
},
{
text: "Feature Overview",
duration: { start: 3, end: 8 },
...subtitleStyle,
},
{
text: "Analytics Dashboard",
duration: { start: 8, end: 15 },
...lowerThirdStyle,
},
{
text: "www.example.com",
duration: { start: 15, end: 20 },
...ctaStyle,
},
];Best Practices
1. Readability - Use sufficient contrast between text and background 2. Size - Ensure text is large enough to read on mobile devices 3. Duration - Give viewers enough time to read (rule of thumb: 3 seconds minimum) 4. Positioning - Don't overlap with the avatar's face 5. Consistency - Use consistent fonts and styles throughout 6. Accessibility - Consider color-blind friendly palettes
Limitations
- Text overlay support varies by subscription tier
- Some advanced styling options may not be available via API
- Complex animations may require post-production tools
- For auto-generated captions, see captions.md