Skill: michaelwang11394/avatar-video (version 2.23.0)

--- name: avatar-video description: | Create AI avatar videos with precise control over avatars, voices, scripts, and backgrounds using HeyGen's v3 API (POST /v3/videos). Two modes: type="avatar" with avatar_id, or type="image" with an image AssetInput (Avatar IV). Use when: (1) Choosing a specific avatar and voice for a video, (2) Writing exact scripts for an avatar to speak, (3) Animating a photo into a speaking video (type="image"), (4) Transparent background videos with remove_background, (5) Integrating HeyGen avatars with Remotion, (6) Batch video generation with exact specs, (7) Brand-consistent production videos with precise control. homepage: https://docs.heygen.com/reference/create-an-avatar-video allowed-tools: mcp__heygen__* metadata: openclaw: requires: env: - HEYGEN_API_KEY primaryEnv: HEYGEN_API_KEY --- # Avatar Video Create AI avatar videos with full control over avatars, voices, scripts, and backgrounds using `POST /v3/videos`. Two creation modes via discriminated union on `type`: - `"type": "avatar"` + `avatar_id` — use a HeyGen avatar from the library - `"type": "image"` + `image` (AssetInput) — animate any photo via Avatar IV ## Authentication All requests require the `X-Api-Key` header. Set the `HEYGEN_API_KEY` environment variable. ```bash curl -X GET "https://api.heygen.com/v3/avatars" \ -H "X-[REDACTED_SECRET]" ``` ## 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) | |------|----------|----------------------| | Check video status / get URL | `mcp__heygen__get_video` | `GET /v3/videos/{video_id}` | | List account videos | `mcp__heygen__list_videos` | `GET /v3/videos` | | Delete a video | `mcp__heygen__delete_video` | `DELETE /v3/videos/{video_id}` | Video generation (`POST /v3/videos`) and avatar/voice listing are done via direct API calls — see reference files below. ## Default Workflow 1. **List avatar looks** — `GET /v3/avatars/looks` → pick a look, note its `id` (this is the `avatar_id`) and `default_voice_id`. See [avatars.md](references/avatars.md) 2. **List voices** (if needed) — `GET /v3/voices` → pick a voice matching the avatar's gender/language. See [voices.md](references/voices.md) 3. **Write the script** — Structure scenes with one concept each. See [scripts.md](references/scripts.md) 4. **Generate the video** — `POST /v3/videos` with `avatar_id`, `voice_id`, `script`, and optional `background` per scene. See [video-generation.md](references/video-generation.md) 5. **Poll for completion** — `GET /v3/videos/{video_id}` until status is `completed`. See [video-status.md](references/video-status.md) ## Routing: This Skill vs Create Video **This skill** = precise control (specific avatar, exact script, custom background). **create-video** = prompt-based ("make me a video about X", AI handles the rest). ## Reference Files Read these as needed — they contain endpoint details, request/response schemas, and code examples (curl, TypeScript, Python). **Core workflow:** - [references/video-generation.md](references/video-generation.md) — `POST /v3/videos` request fields, avatar input modes, voice settings, backgrounds - [references/avatars.md](references/avatars.md) — `GET /v3/avatars` (groups) and `GET /v3/avatars/looks` (looks → `avatar_id`) - [references/voices.md](references/voices.md) — `GET /v3/voices` with filtering by language, gender, engine - [references/video-status.md](references/video-status.md) — `GET /v3/videos/{id}` polling patterns and download **Customization:** - [references/scripts.md](references/scripts.md) — Script writing, SSML break tags, pacing - [references/backgrounds.md](references/backgrounds.md) — Solid color and image backgrounds - [references/captions.md](references/captions.md) — Auto-generated captions/subtitles - [references/text-overlays.md](references/text-overlays.md) — Text overlays with fonts and positioning **Advanced:** - [references/photo-avatars.md](references/photo-avatars.md) — Animate photos via `type: "image"` (Avatar IV), AI-generated avatars - [references/templates.md](references/templates.md) — Template listing and variable replacement - [references/remotion-integration.md](references/remotion-integration.md) — Using HeyGen avatars in Remotion compositions - [references/webhooks.md](references/webhooks.md) — Webhook endpoints and events - [references/assets.md](references/assets.md) — Uploading images, videos, audio - [references/dimensions.md](references/dimensions.md) — Resolution and aspect ratios - [references/quota.md](references/quota.md) — Credit system and usage limits ## Best Practices 1. **Preview avatars before generating** — Use `GET /v3/avatars/looks` and download `preview_image_url` so the user can see the avatar before committing 2. **Use avatar's default voice** — Most avatars have a `default_voice_id` pre-matched for natural results 3. **Fallback: match gender manually** — If no default voice, ensure avatar and voice genders match 4. **Use test mode for development** — Set `test: true` to avoid consuming credits (output will be watermarked) 5. **Set generous timeouts** — Video generation often takes 5-15 minutes, sometimes longer 6. **Validate inputs** — Check avatar and voice IDs exist before generating

path: references/assets.md
content: ---
name: assets
description: Uploading images, videos, and audio for use in HeyGen video generation
---

# 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 | Key for creating uploaded photo avatars (images only) |
| `data.folder_id` | string | Folder ID (empty if not in a folder) |
| `data.meta` | string \| null | Asset metadata |
| `data.created_ts` | number | Unix timestamp of creation |

### curl

```bash
curl -X POST "https://upload.heygen.com/v1/asset" \
  -H "X-[REDACTED_SECRET]" \
  -H "Content-Type: image/jpeg" \
  --data-binary '@./background.jpg'
```

### TypeScript

```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)

```typescript
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

```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:

```typescript
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

```typescript
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

```typescript
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

```typescript
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

```typescript
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/v3/videos", {
    method: "POST",
    headers: {
      "X-Api-Key": process.env.HEYGEN_API_KEY!,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      type: "avatar" as const,
      avatar_id: avatarId,
      script: script,
      voice_id: voiceId,
      background: { type: "image", asset_id: bgAssetId },
      resolution: "1080p",
      aspect_ratio: "16:9",
    }),
  });

  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


path: references/avatars.md
content: ---
name: avatars
description: Listing avatar groups and looks, filtering by type and ownership, and selecting the right avatar_id for video generation using the v3 API
---

# HeyGen Avatars

The v3 API organizes avatars into two levels:

- **Groups** represent a character (e.g., "Josh", "Angela"). A group has a name, preview images, and a default voice.
- **Looks** represent a specific outfit, style, or pose within a group. Each look has an `id` that you pass as `avatar_id` when creating a video via `POST /v3/videos`.

A single group can have multiple looks. For example, the "Josh" group might have looks for business attire, casual wear, and seasonal outfits.

## Previewing Avatars Before Generation

Always preview avatars before generating a video to ensure they match user preferences. Both groups and looks include preview URLs that can be opened directly in the browser.

### Preview Fields

| Level | Field | Description |
|-------|-------|-------------|
| Group | `preview_image_url` | Static image of the character (publicly accessible) |
| Group | `preview_video_url` | Short video clip showing the character |
| Look | `preview_image_url` | Static image of this specific look/outfit |
| Look | `preview_video_url` | Short video clip of this look in action |

Both URLs are publicly accessible -- no authentication needed to view them.

### Workflow: Preview Before Generate

1. **List avatar groups** -- browse available characters
2. **List looks for a group** -- see available outfits/styles
3. **Show preview URLs to user** -- share `preview_image_url` for visual check
4. **User selects** a look by name or ID
5. **Use `look.id` as `avatar_id`** and `look.default_voice_id` as `voice_id` in `POST /v3/videos`

## Listing Avatar Groups

List all avatar groups (characters) available to your account.

### curl

```bash
# List public avatar groups
curl -X GET "https://api.heygen.com/v3/avatars?ownership=public&limit=10" \
  -H "X-[REDACTED_SECRET]"

# List your private avatar groups
curl -X GET "https://api.heygen.com/v3/avatars?ownership=private&limit=10" \
  -H "X-[REDACTED_SECRET]"
```

### TypeScript

```typescript
interface AvatarGroupItem {
  id: string;
  name: string;
  preview_image_url: string | null;
  preview_video_url: string | null;
  gender: string | null;
  created_at: number;
  looks_count: number;
  default_voice_id: string | null;
}

interface AvatarGroupsResponse {
  data: AvatarGroupItem[];
  has_more: boolean;
  next_token: string | null;
}

async function listAvatarGroups(
  ownership: "public" | "private" = "public",
  limit: number = 50,
  token?: string
): Promise<AvatarGroupsResponse> {
  const params = new URLSearchParams({
    ownership,
    limit: limit.toString(),
  });
  if (token) {
    params.set("token", token);
  }

  const response = await fetch(
    `https://api.heygen.com/v3/avatars?${params}`,
    { headers: { "X-Api-Key": process.env.HEYGEN_API_KEY! } }
  );

  if (!response.ok) {
    throw new Error(`Failed to list avatar groups: ${response.status}`);
  }

  return response.json();
}

// Usage
const { data: groups, has_more, next_token } = await listAvatarGroups("public", 10);
for (const group of groups) {
  console.log(`${group.name} (${group.gender}) - ${group.looks_count} looks`);
  console.log(`  ID: ${group.id}`);
  console.log(`  Preview: ${group.preview_image_url}`);
}
```

### Python

```python
import requests
import os


def list_avatar_groups(
    ownership: str = "public",
    limit: int = 50,
    token: str | None = None,
) -> dict:
    params = {"ownership": ownership, "limit": limit}
    if [REDACTED_SECRET]"token"] = token

    response = requests.get(
        "https://api.heygen.com/v3/avatars",
        headers={"X-Api-Key": os.environ["HEYGEN_API_KEY"]},
        params=params,
    )
    response.raise_for_status()
    return response.json()


# Usage
result = list_avatar_groups("public", limit=10)
for group in result["data"]:
    print(f"{group['name']} ({group['gender']}) - {group['looks_count']} looks")
    print(f"  ID: {group['id']}")
    print(f"  Preview: {group['preview_image_url']}")
```

### Response Format

```json
{
  "data": [
    {
      "id": "ag_abc123",
      "name": "Josh",
      "preview_image_url": "https://files.heygen.ai/...",
      "preview_video_url": "https://files.heygen.ai/...",
      "gender": "male",
      "created_at": 1710000000,
      "looks_count": 3,
      "default_voice_id": "1bd001e7e50f421d891986aad5158bc8"
    },
    {
      "id": "ag_def456",
      "name": "Angela",
      "preview_image_url": "https://files.heygen.ai/...",
      "preview_video_url": "https://files.heygen.ai/...",
      "gender": "female",
      "created_at": 1710100000,
      "looks_count": 2,
      "default_voice_id": "2d5b0e6a8c3f47d9a1b2c3d4e5f60718"
    }
  ],
  "has_more": true,
  "next_token": [REDACTED_SECRET]
}
```

## Get Avatar Group Details

Retrieve details for a single avatar group by its ID.

### curl

```bash
curl -X GET "https://api.heygen.com/v3/avatars/ag_abc123" \
  -H "X-[REDACTED_SECRET]"
```

### TypeScript

```typescript
async function getAvatarGroup(groupId: string): Promise<AvatarGroupItem> {
  const response = await fetch(
    `https://api.heygen.com/v3/avatars/${groupId}`,
    { headers: { "X-Api-Key": process.env.HEYGEN_API_KEY! } }
  );

  if (!response.ok) {
    throw new Error(`Failed to get avatar group: ${response.status}`);
  }

  return response.json();
}
```

### Python

```python
def get_avatar_group(group_id: str) -> dict:
    response = requests.get(
        f"https://api.heygen.com/v3/avatars/{group_id}",
        headers={"X-Api-Key": os.environ["HEYGEN_API_KEY"]},
    )
    response.raise_for_status()
    return response.json()
```

## Listing Avatar Looks

Looks are the specific outfits, styles, or poses within a group. The look `id` is what you pass as `avatar_id` to `POST /v3/videos`.

### curl

```bash
# List all public looks
curl -X GET "https://api.heygen.com/v3/avatars/looks?ownership=public&limit=20" \
  -H "X-[REDACTED_SECRET]"

# List looks for a specific group
curl -X GET "https://api.heygen.com/v3/avatars/looks?group_id=ag_abc123&limit=20" \
  -H "X-[REDACTED_SECRET]"

# List only studio avatars
curl -X GET "https://api.heygen.com/v3/avatars/looks?avatar_type=studio_avatar&ownership=public&limit=20" \
  -H "X-[REDACTED_SECRET]"
```

### Query Parameters

| Parameter | Type | Required | Description |
|-----------|------|----------|-------------|
| `group_id` | string | No | Filter looks by avatar group |
| `avatar_type` | string | No | `"studio_avatar"`, `"video_avatar"`, or `"photo_avatar"` |
| `ownership` | string | No | `"public"` or `"private"` |
| `limit` | integer | No | 1-50, number of results per page |
| `token` | string | No | Cursor token for pagination |

### TypeScript

```typescript
interface AvatarLookItem {
  id: string;
  name: string;
  avatar_type: "studio_avatar" | "video_avatar" | "photo_avatar";
  group_id: string | null;
  preview_image_url: string | null;
  preview_video_url: string | null;
  gender: string | null;
  tags: string[];
  default_voice_id: string | null;
  supported_api_engines: string[];
}

interface AvatarLooksResponse {
  data: AvatarLookItem[];
  has_more: boolean;
  next_token: string | null;
}

async function listAvatarLooks(options: {
  group_id?: string;
  avatar_type?: "studio_avatar" | "video_avatar" | "photo_avatar";
  ownership?: "public" | "private";
  limit?: number;
  token?: string;
} = {}): Promise<AvatarLooksResponse> {
  const params = new URLSearchParams();

  if (options.group_id) params.set("group_id", options.group_id);
  if (options.avatar_type) params.set("avatar_type", options.avatar_type);
  if (options.ownership) params.set("ownership", options.ownership);
  if (options.limit) params.set("limit", options.limit.toString());
  if (options.token) params.set("token", options.token);

  const response = await fetch(
    `https://api.heygen.com/v3/avatars/looks?${params}`,
    { headers: { "X-Api-Key": process.env.HEYGEN_API_KEY! } }
  );

  if (!response.ok) {
    throw new Error(`Failed to list avatar looks: ${response.status}`);
  }

  return response.json();
}

// List all public looks
const { data: looks } = await listAvatarLooks({ ownership: "public", limit: 20 });

// List looks for a specific group
const { data: groupLooks } = await listAvatarLooks({ group_id: "ag_abc123" });

// List only studio avatars
const { data: studioLooks } = await listAvatarLooks({
  avatar_type: "studio_avatar",
  ownership: "public",
});
```

### Python

```python
def list_avatar_looks(
    group_id: str | None = None,
    avatar_type: str | None = None,
    ownership: str | None = None,
    limit: int = 50,
    token: str | None = None,
) -> dict:
    params: dict = {"limit": limit}
    if group_id:
        params["group_id"] = group_id
    if avatar_type:
        params["avatar_type"] = avatar_type
    if ownership:
        params["ownership"] = ownership
    if [REDACTED_SECRET]"token"] = token

    response = requests.get(
        "https://api.heygen.com/v3/avatars/looks",
        headers={"X-Api-Key": os.environ["HEYGEN_API_KEY"]},
        params=params,
    )
    response.raise_for_status()
    return response.json()


# List all public looks
result = list_avatar_looks(ownership="public", limit=20)
for look in result["data"]:
    print(f"{look['name']} ({look['avatar_type']}) - ID: {look['id']}")

# List looks for a specific group
result = list_avatar_looks(group_id="ag_abc123")

# List only studio avatars
result = list_avatar_looks(avatar_type="studio_avatar", ownership="public")
```

### Response Format

```json
{
  "data": [
    {
      "id": "lk_josh_business",
      "name": "Josh - Business",
      "avatar_type": "studio_avatar",
      "group_id": "ag_abc123",
      "preview_image_url": "https://files.heygen.ai/...",
      "preview_video_url": "https://files.heygen.ai/...",
      "gender": "male",
      "tags": ["business", "professional"],
      "default_voice_id": "1bd001e7e50f421d891986aad5158bc8",
      "supported_api_engines": ["avatar_4_quality", "avatar_4_turbo"]
    },
    {
      "id": "lk_josh_casual",
      "name": "Josh - Casual",
      "avatar_type": "studio_avatar",
      "group_id": "ag_abc123",
      "preview_image_url": "https://files.heygen.ai/...",
      "preview_video_url": "https://files.heygen.ai/...",
      "gender": "male",
      "tags": ["casual", "lifestyle"],
      "default_voice_id": "1bd001e7e50f421d891986aad5158bc8",
      "supported_api_engines": ["avatar_4_quality", "avatar_4_turbo"]
    }
  ],
  "has_more": false,
  "next_token": null
}
```

## Get Look Details

Retrieve details for a single look by its ID.

### curl

```bash
curl -X GET "https://api.heygen.com/v3/avatars/looks/lk_josh_business" \
  -H "X-[REDACTED_SECRET]"
```

### TypeScript

```typescript
async function getAvatarLook(lookId: string): Promise<AvatarLookItem> {
  const response = await fetch(
    `https://api.heygen.com/v3/avatars/looks/${lookId}`,
    { headers: { "X-Api-Key": process.env.HEYGEN_API_KEY! } }
  );

  if (!response.ok) {
    throw new Error(`Failed to get avatar look: ${response.status}`);
  }

  return response.json();
}

// Usage
const look = await getAvatarLook("lk_josh_business");
console.log(`${look.name} (${look.avatar_type})`);
console.log(`Engines: ${look.supported_api_engines.join(", ")}`);
console.log(`Default voice: ${look.default_voice_id}`);
```

### Python

```python
def get_avatar_look(look_id: str) -> dict:
    response = requests.get(
        f"https://api.heygen.com/v3/avatars/looks/{look_id}",
        headers={"X-Api-Key": os.environ["HEYGEN_API_KEY"]},
    )
    response.raise_for_status()
    return response.json()


# Usage
look = get_avatar_look("lk_josh_business")
print(f"{look['name']} ({look['avatar_type']})")
print(f"Engines: {', '.join(look['supported_api_engines'])}")
print(f"Default voice: {look['default_voice_id']}")
```

## Avatar Types

The `avatar_type` field on looks distinguishes three categories:

| Type | Description | Best For |
|------|-------------|----------|
| `studio_avatar` | Professionally produced studio-quality avatars | Corporate videos, product demos, training content |
| `video_avatar` | Avatars trained from uploaded video footage | Custom brand representatives, personalized content |
| `photo_avatar` | Avatars generated from a still photo | Quick prototyping, low-effort personalization |

### Filtering by Type

```typescript
// Get only studio avatars (highest quality)
const { data: studioLooks } = await listAvatarLooks({
  avatar_type: "studio_avatar",
  ownership: "public",
});

// Get only photo avatars (from uploaded photos)
const { data: photoLooks } = await listAvatarLooks({
  avatar_type: "photo_avatar",
  ownership: "private",
});
```

```python
# Get only video avatars (trained from video footage)
result = list_avatar_looks(avatar_type="video_avatar", ownership="private")
```

## Using Avatar's Default Voice

Each look has a `default_voice_id` that is pre-matched for natural results. This is the recommended approach rather than manually selecting voices.

### Recommended Flow

```
1. GET /v3/avatars/looks      -> Pick a look
2. Use look.id as avatar_id   -> Pass to POST /v3/videos
3. Use look.default_voice_id  -> Pass as voice_id to POST /v3/videos
```

### Complete Example: Generate Video with a Look's Default Voice

#### TypeScript

```typescript
async function generateWithDefaultVoice(
  lookId: string,
  script: string
): Promise<string> {
  // 1. Get look details to find default voice
  const look = await getAvatarLook(lookId);

  if (!look.default_voice_id) {
    throw new Error(`Look ${look.name} has no default voice`);
  }

  console.log(`Using ${look.name} with default voice: ${look.default_voice_id}`);

  // 2. Generate video with the look's default voice
  const response = await fetch("https://api.heygen.com/v3/videos", {
    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: look.id,       // The look ID is the avatar_id
          avatar_style: "normal",
        },
        voice: {
          type: "text",
          input_text: script,
          voice_id: look.default_voice_id,
        },
      }],
      dimension: { width: 1920, height: 1080 },
    }),
  });

  if (!response.ok) {
    throw new Error(`Video generation failed: ${response.status}`);
  }

  const { data } = await response.json();
  return data.video_id;
}
```

#### Python

```python
def generate_with_default_voice(look_id: str, script: str) -> str:
    # 1. Get look details to find default voice
    look = get_avatar_look(look_id)

    if not look.get("default_voice_id"):
        raise ValueError(f"Look {look['name']} has no default voice")

    print(f"Using {look['name']} with default voice: {look['default_voice_id']}")

    # 2. Generate video with the look's default voice
    response = requests.post(
        "https://api.heygen.com/v3/videos",
        headers={
            "X-Api-Key": os.environ["HEYGEN_API_KEY"],
            "Content-Type": "application/json",
        },
        json={
            "video_inputs": [{
                "character": {
                    "type": "avatar",
                    "avatar_id": look["id"],       # The look ID is the avatar_id
                    "avatar_style": "normal",
                },
                "voice": {
                    "type": "text",
                    "input_text": script,
                    "voice_id": look["default_voice_id"],
                },
            }],
            "dimension": {"width": 1920, "height": 1080},
        },
    )
    response.raise_for_status()
    return response.json()["data"]["video_id"]
```

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

## Filtering and Searching

The v3 API supports server-side filtering across multiple dimensions.

### By Group

```typescript
// Get all looks for a specific character
const { data: looks } = await listAvatarLooks({ group_id: "ag_abc123" });
```

### By Avatar Type

```typescript
// Only studio-quality avatars
const { data: looks } = await listAvatarLooks({ avatar_type: "studio_avatar" });
```

### By Ownership

```typescript
// Public avatars provided by HeyGen
const { data: publicLooks } = await listAvatarLooks({ ownership: "public" });

// Your custom/private avatars
const { data: privateLooks } = await listAvatarLooks({ ownership: "private" });
```

### By Gender (Client-Side)

Gender is available on the response but not as a query parameter. Filter client-side:

```typescript
const { data: looks } = await listAvatarLooks({ ownership: "public" });
const femaleLooks = looks.filter((look) => look.gender === "female");
const maleLooks = looks.filter((look) => look.gender === "male");
```

```python
result = list_avatar_looks(ownership="public")
female_looks = [look for look in result["data"] if look["gender"] == "female"]
male_looks = [look for look in result["data"] if look["gender"] == "male"]
```

### Combining Filters

```typescript
// Private studio avatars for a specific group
const { data: looks } = await listAvatarLooks({
  group_id: "ag_abc123",
  avatar_type: "studio_avatar",
  ownership: "private",
  limit: 10,
});
```

### Search by Name (Client-Side)

```typescript
function searchLooksByName(looks: AvatarLookItem[], query: string): AvatarLookItem[] {
  const lowerQuery = query.toLowerCase();
  return looks.filter((look) =>
    look.name.toLowerCase().includes(lowerQuery)
  );
}

const { data: allLooks } = await listAvatarLooks({ ownership: "public" });
const results = searchLooksByName(allLooks, "josh");
```

## Cursor-Based Pagination

Both the groups and looks endpoints use cursor-based pagination. The response includes `has_more` (boolean) and `next_token` (string or null). Pass `next_token` as the `token` query parameter to fetch the next page.

### TypeScript

```typescript
async function listAllAvatarLooks(
  options: { ownership?: "public" | "private"; avatar_type?: string } = {}
): Promise<AvatarLookItem[]> {
  const allLooks: AvatarLookItem[] = [];
  let [REDACTED_SECRET] | undefined;

  do {
    const response = await listAvatarLooks({
      ...options,
      limit: 50,
      token,
    });

    allLooks.push(...response.data);
    [REDACTED_SECRET] && response.next_token
      ? response.next_token
      : undefined;
  } while (token);

  return allLooks;
}

// Fetch every public look across all pages
const allPublicLooks = await listAllAvatarLooks({ ownership: "public" });
console.log(`Total public looks: ${allPublicLooks.length}`);
```

### Python

```python
def list_all_avatar_looks(
    ownership: str | None = None,
    avatar_type: str | None = None,
) -> list[dict]:
    all_looks: list[dict] = []
    token: str | None = None

    while True:
        result = list_avatar_looks(
            ownership=ownership,
            avatar_type=avatar_type,
            limit=50,
            token=token,
        )

        all_looks.extend(result["data"])

        if not result["has_more"] or not result.get("next_token"):
            break
        [REDACTED_SECRET]"next_token"]

    return all_looks


# Fetch every public look across all pages
all_public_looks = list_all_avatar_looks(ownership="public")
print(f"Total public looks: {len(all_public_looks)}")
```

### curl (Manual Pagination)

```bash
# First page
curl -X GET "https://api.heygen.com/v3/avatars/looks?ownership=public&limit=50" \
  -H "X-[REDACTED_SECRET]"

# Next page (use next_token from previous response)
curl -X GET "https://api.heygen.com/v3/avatars/looks?ownership=public&limit=50&[REDACTED_SECRET]" \
  -H "X-[REDACTED_SECRET]"
```

## Selecting the Right Avatar

### Avatar Categories

HeyGen avatars fall into distinct categories. Match the category to your use case:

| Category | Best For |
|----------|----------|
| **Business/Professional** | Corporate videos, product demos, training |
| **Casual/Friendly** | Social media, informal content |
| **Themed/Seasonal** | Specific campaigns, seasonal content |
| **Expressive** | Engaging storytelling, dynamic content |

### Selection Guidelines

**For business/professional content:**
- Choose looks with neutral attire (business casual or formal)
- Avoid themed or seasonal looks (holiday costumes, casual clothing)
- Preview the look to verify professional appearance
- Consider your audience demographics when selecting gender and appearance
- Prefer `studio_avatar` type for highest quality

**For casual/social content:**
- More flexibility in look choice
- Themed looks can work for specific campaigns
- Match avatar energy to content tone

### Common Mistakes to Avoid

1. **Using themed looks for business content** -- a holiday-themed look appears unprofessional in a product demo
2. **Not previewing before generation** -- always check the preview URL to verify appearance
3. **Mismatched voice gender** -- always use the look's `default_voice_id` or match genders manually
4. **Using a group ID instead of a look ID** -- `POST /v3/videos` requires a look `id`, not a group `id`

### Selection Checklist

Before generating a video:
- [ ] Previewed look image/video in browser
- [ ] Look appearance matches content tone (professional vs casual)
- [ ] Voice gender matches avatar gender
- [ ] Using `default_voice_id` when available
- [ ] Passing the look `id` (not the group `id`) as `avatar_id`
- [ ] Checked `supported_api_engines` if targeting a specific rendering engine

## Best Practices

1. **Always use look IDs for video generation.** The look `id` is the `avatar_id` you pass to `POST /v3/videos`. Group IDs are for browsing and organization only.
2. **Prefer `default_voice_id`.** Each look has a pre-matched voice. Use it unless the user explicitly requests a different voice.
3. **Preview before generating.** Share `preview_image_url` with the user to confirm the look matches their expectations.
4. **Use server-side filters.** Filter by `ownership`, `avatar_type`, and `group_id` at the API level rather than fetching everything and filtering client-side.
5. **Paginate large result sets.** Use `limit` and `token` to page through results. The maximum `limit` is 50.
6. **Check `supported_api_engines`.** Some looks support `avatar_4_quality` (higher quality, slower) and `avatar_4_turbo` (faster). Choose based on your latency and quality requirements.
7. **Cache group and look lists.** Avatar catalogs change infrequently. Cache the results for a reasonable period to reduce API calls.


path: references/backgrounds.md
content: ---
name: backgrounds
description: Solid colors, images, and video backgrounds for HeyGen videos
---

# 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:

```typescript
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:

```typescript
background: {
  type: "color",
  value: "#00FF00", // Green screen
}
```

## Image Backgrounds

Use a static image as background:

### From URL

```typescript
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:

```typescript
// 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:

```typescript
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:

```typescript
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

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

```typescript
// 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:

```typescript
// For 1920x1080 video
// Use 1920x1080 background image

// For 1080x1920 portrait video
// Use 1080x1920 background image
```

### Video 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.


path: references/captions.md
content: ---
name: captions
description: Auto-generated captions and subtitle options for HeyGen videos
---

# 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:

```typescript
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

```typescript
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

```typescript
const config = {
  video_inputs: [...],
  caption: true, // Enable with default styling
};
```

### Styled Captions

```typescript
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:

```typescript
// 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:

```srt
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:

```typescript
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:

```typescript
caption: {
  enabled: true,
  style: {
    position: "bottom"
  }
}
```

### Top

For videos where bottom space is occupied:

```typescript
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

```typescript
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

```typescript
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

### LinkedIn

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

```typescript
// Video translation includes caption generation
const translationConfig = {
  input_video_id: "original_video_id",
  output_languages: ["es-ES"],
  // Captions generated in target language
};
```

See the **video-translate** skill for more details on video translation.


path: references/dimensions.md
content: ---
name: dimensions
description: Resolution options (720p/1080p) and aspect ratios for HeyGen videos
---

# 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

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

```bash
# Landscape 1080p
curl -X POST "https://api.heygen.com/v3/videos" \
  -H "X-[REDACTED_SECRET]" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "avatar",
    "avatar_id": "YOUR_AVATAR_LOOK_ID",
    "script": "Hello!",
    "voice_id": "YOUR_VOICE_ID",
    "resolution": "1080p",
    "aspect_ratio": "16:9"
  }'
```

## Dimension Helper Functions

```typescript
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

```typescript
const youtubeConfig = {
  video_inputs: [...],
  dimension: { width: 1920, height: 1080 }, // 16:9 landscape
};
```

### TikTok / Instagram Reels / YouTube Shorts

```typescript
const shortFormConfig = {
  video_inputs: [...],
  dimension: { width: 1080, height: 1920 }, // 9:16 portrait
};
```

### Instagram Feed Post

```typescript
const instagramFeedConfig = {
  video_inputs: [...],
  dimension: { width: 1080, height: 1080 }, // 1:1 square
};
```

### LinkedIn

```typescript
const linkedinConfig = {
  video_inputs: [...],
  dimension: { width: 1920, height: 1080 }, // 16:9 landscape preferred
};
```

### Twitter/X

```typescript
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:

```typescript
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:

```typescript
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

```typescript
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:

```typescript
// 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

```typescript
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",
});
```


path: references/photo-avatars.md
content: ---
name: photo-avatars
description: Animating photos into high-quality videos using HeyGen's v3 API with Avatar IV technology
---

# Photo Avatars

Animate a static photo into a speaking video. Use `POST /v3/videos` with `"type": "image"` and a nested `image` field (AssetInput discriminated union: `{ "type": "url", "url": "..." }` or `{ "type": "asset_id", "asset_id": "..." }`) — this uses Avatar IV technology automatically for high-quality results, no avatar group creation needed.

## Direct Image-to-Video (v3 API)

### Request Fields

| Field | Type | Req | Description |
|-------|------|:---:|-------------|
| `type` | string | ✓ | Must be `"image"` |
| `image` | AssetInput | ✓ | Image to animate. Either `{ "type": "url", "url": "..." }` for a public URL, or `{ "type": "asset_id", "asset_id": "..." }` for an uploaded asset. |
| `script` | string | ✓ | Text for the avatar to speak |
| `voice_id` | string | ✓ | Voice to use |
| `motion_prompt` | string | | Natural-language prompt controlling body motion (photo avatars only) |
| `expressiveness` | string | | `"high"`, `"medium"`, or `"low"` (photo avatars only, defaults to `"low"`) |
| `aspect_ratio` | string | | `"16:9"` or `"9:16"` |
| `resolution` | string | | `"1080p"` or `"720p"` |
| `remove_background` | boolean | | Remove background from the photo |

**AssetInput** is a discriminated union on the `type` field:
- URL variant: `{ "type": "url", "url": "https://example.com/photo.jpg" }`
- Asset ID variant: `{ "type": "asset_id", "asset_id": "uploaded_asset_id" }`

### curl Example

```bash
curl -X POST "https://api.heygen.com/v3/videos" \
  -H "X-[REDACTED_SECRET]" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "image",
    "image": { "type": "url", "url": "https://example.com/portrait.jpg" },
    "script": "Hello! This is a high-quality photo avatar video.",
    "voice_id": "1bd001e7e50f421d891986aad5158bc8",
    "aspect_ratio": "16:9",
    "resolution": "1080p"
  }'
```

Response:
```json
{
  "error": null,
  "data": {
    "video_id": "abc123def456"
  }
}
```

### TypeScript Example

```typescript
type AssetInput =
  | { type: "url"; url: string }
  | { type: "asset_id"; asset_id: string };

interface PhotoVideoRequest {
  type: "image";
  image: AssetInput;
  script: string;
  voice_id: string;
  motion_prompt?: string;
  expressiveness?: "high" | "medium" | "low";
  aspect_ratio?: "16:9" | "9:16";
  resolution?: "1080p" | "720p";
  remove_background?: boolean;
}

interface VideoResponse {
  error: string | null;
  data: {
    video_id: string;
  };
}

interface VideoStatusResponse {
  error: string | null;
  data: {
    video_id: string;
    status: "pending" | "processing" | "completed" | "failed";
    video_url?: string;
    thumbnail_url?: string;
    duration?: number;
    error?: string;
  };
}

async function createPhotoVideo(config: PhotoVideoRequest): Promise<string> {
  const response = await fetch("https://api.heygen.com/v3/videos", {
    method: "POST",
    headers: {
      "X-Api-Key": process.env.HEYGEN_API_KEY!,
      "Content-Type": "application/json",
    },
    body: JSON.stringify(config),
  });

  const json: VideoResponse = await response.json();
  if (json.error) {
    throw new Error(json.error);
  }

  return json.data.video_id;
}

async function pollVideoStatus(videoId: string): Promise<string> {
  for (let i = 0; i < 120; i++) {
    const response = await fetch(
      `https://api.heygen.com/v3/videos/${videoId}`,
      { headers: { "X-Api-Key": process.env.HEYGEN_API_KEY! } }
    );

    const json: VideoStatusResponse = await response.json();

    if (json.data.status === "completed") {
      return json.data.video_url!;
    }
    if (json.data.status === "failed") {
      throw new Error(json.data.error ?? "Video generation failed");
    }

    await new Promise((r) => setTimeout(r, 5000));
  }

  throw new Error("Video generation timed out");
}

// Usage
const videoId = await createPhotoVideo({
  type: "image",
  image: { type: "url", url: "https://example.com/portrait.jpg" },
  script: "Hello! This is a high-quality photo avatar video.",
  voice_id: "1bd001e7e50f421d891986aad5158bc8",
  aspect_ratio: "16:9",
  resolution: "1080p",
});

const videoUrl = await pollVideoStatus(videoId);
console.log("Video ready:", videoUrl);
```

### Python Example

```python
import requests
import os
import time

def create_photo_video(
    script: str,
    voice_id: str,
    image_url: str | None = None,
    image_asset_id: str | None = None,
    motion_prompt: str | None = None,
    expressiveness: str | None = None,
    aspect_ratio: str = "16:9",
    resolution: str = "1080p",
    remove_background: bool = False,
) -> str:
    [REDACTED_SECRET]"HEYGEN_API_KEY"]

    # Build the AssetInput discriminated union
    if image_url:
        image_input = {"type": "url", "url": image_url}
    elif image_asset_id:
        image_input = {"type": "asset_id", "asset_id": image_asset_id}
    else:
        raise ValueError("Provide either image_url or image_asset_id")

    payload: dict = {
        "type": "image",
        "image": image_input,
        "script": script,
        "voice_id": voice_id,
        "aspect_ratio": aspect_ratio,
        "resolution": resolution,
    }

    if motion_prompt:
        payload["motion_prompt"] = motion_prompt
    if expressiveness:
        payload["expressiveness"] = expressiveness
    if remove_background:
        payload["remove_background"] = True

    resp = requests.post(
        "https://api.heygen.com/v3/videos",
        headers={
            "X-Api-Key": api_key,
            "Content-Type": "application/json",
        },
        json=payload,
    )

    data = resp.json()
    if data.get("error"):
        raise Exception(data["error"])

    return data["data"]["video_id"]


def poll_video_status(video_id: str) -> str:
    [REDACTED_SECRET]"HEYGEN_API_KEY"]

    for _ in range(120):
        resp = requests.get(
            f"https://api.heygen.com/v3/videos/{video_id}",
            headers={"X-Api-Key": api_key},
        )
        data = resp.json()["data"]

        if data["status"] == "completed":
            return data["video_url"]
        if data["status"] == "failed":
            raise Exception(data.get("error", "Video generation failed"))

        time.sleep(5)

    raise Exception("Video generation timed out")


# Usage
video_id = create_photo_video(
    image_url="https://example.com/portrait.jpg",
    script="Hello! This is a high-quality photo avatar video.",
    voice_id="1bd001e7e50f421d891986aad5158bc8",
)
video_url = poll_video_status(video_id)
print(f"Video ready: {video_url}")
```

### Motion Prompts and Expressiveness

Use `motion_prompt` and `expressiveness` to control how the photo avatar moves and emotes:

```bash
curl -X POST "https://api.heygen.com/v3/videos" \
  -H "X-[REDACTED_SECRET]" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "image",
    "image": { "type": "url", "url": "https://example.com/portrait.jpg" },
    "script": "Let me tell you about our exciting new product launch.",
    "voice_id": "1bd001e7e50f421d891986aad5158bc8",
    "motion_prompt": "nodding head and smiling, gesturing with hands while speaking",
    "expressiveness": "high",
    "aspect_ratio": "16:9"
  }'
```

| Expressiveness | Description |
|----------------|-------------|
| `low` | Subtle, minimal movement (default) |
| `medium` | Natural, moderate movement |
| `high` | Energetic, pronounced movement |

## Uploading Images

If your image is not publicly accessible via URL, upload it first using the asset upload endpoint. Then use the returned asset ID with the `asset_id` variant of the `image` field.

**Endpoint:** `POST https://upload.heygen.com/v1/asset`

```bash
curl -X POST "https://upload.heygen.com/v1/asset" \
  -H "X-[REDACTED_SECRET]" \
  -H "Content-Type: image/jpeg" \
  --data-binary '@./portrait.jpg'
```

Response:
```json
{
  "code": 100,
  "data": {
    "id": "741299e941764988b432ed3a6757878f",
    "name": "741299e941764988b432ed3a6757878f",
    "file_type": "image",
    "url": "https://resource2.heygen.ai/image/.../original.jpg",
    "image_key": "image/741299e941764988b432ed3a6757878f/original.jpg"
  }
}
```

Then use the asset `id` in `POST /v3/videos`:

```bash
curl -X POST "https://api.heygen.com/v3/videos" \
  -H "X-[REDACTED_SECRET]" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "image",
    "image": { "type": "asset_id", "asset_id": "741299e941764988b432ed3a6757878f" },
    "script": "Hello from an uploaded photo!",
    "voice_id": "1bd001e7e50f421d891986aad5158bc8"
  }'
```

See [assets.md](assets.md) for full upload details.

## AI-Generated 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`

> **Note:** This endpoint is still v2 (no v3 equivalent). After generating an image, use the resulting image URL in `POST /v3/videos` with `"type": "image"` and `"image": { "type": "url", "url": "..." }` for high-quality video generation.

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

```bash
curl -X POST "https://api.heygen.com/v2/photo_avatar/photo/generate" \
  -H "X-[REDACTED_SECRET]" \
  -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:
```json
{
  "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:

```json
{
  "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"
    ]
  }
}
```

### AI Photo to Video (Recommended Flow)

Generate an AI photo, then use the resulting image URL directly in `POST /v3/videos`:

```typescript
// 1. Generate AI photo
const genResponse = 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({
      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",
    }),
  }
);

const { data: genData } = await genResponse.json();
const generationId = genData.generation_id;

// 2. Poll until generation completes
let imageUrl: string | undefined;
for (let i = 0; i < 60; i++) {
  const statusResp = await fetch(
    `https://api.heygen.com/v2/photo_avatar/generation/${generationId}`,
    { headers: { "X-Api-Key": process.env.HEYGEN_API_KEY! } }
  );
  const statusJson = await statusResp.json();

  if (statusJson.data.status === "success") {
    imageUrl = statusJson.data.image_url_list[0];
    break;
  }
  if (statusJson.data.status === "failed") {
    throw new Error("Photo generation failed");
  }
  await new Promise((r) => setTimeout(r, 5000));
}

if (!imageUrl) throw new Error("Photo generation timed out");

// 3. Create video using v3 API with the generated image URL
const videoResp = await fetch("https://api.heygen.com/v3/videos", {
  method: "POST",
  headers: {
    "X-Api-Key": process.env.HEYGEN_API_KEY!,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    type: "image" as const,
    image: { type: "url" as const, url: imageUrl },
    script: "Welcome to our product demo!",
    voice_id: "1bd001e7e50f421d891986aad5158bc8",
    aspect_ratio: "16:9",
    resolution: "1080p",
  }),
});

const { data: videoData } = await videoResp.json();
console.log("Video ID:", videoData.video_id);
```

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

## Legacy: Creating Photo Avatar Groups

> **Note:** This is the legacy workflow. For most use cases, prefer the direct `"type": "image"` approach with `POST /v3/videos` described above. The avatar group workflow is more complex and does not produce better results.

The legacy workflow is: **Upload Image -> Create Avatar Group -> Use in Video**

### Step 1: Upload the Image

Upload a portrait photo using the asset upload endpoint (see [Uploading Images](#uploading-images) above).

### Step 2: Create Photo Avatar Group

Use the `image_key` from the upload response to create a photo avatar group.

**Endpoint:** `POST https://api.heygen.com/v2/photo_avatar/avatar_group/create`

```bash
curl -X POST "https://api.heygen.com/v2/photo_avatar/avatar_group/create" \
  -H "X-[REDACTED_SECRET]" \
  -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 |

Response:
```json
{
  "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"
  }
}
```

### Step 3: Wait for Processing

Poll `GET /v2/photo_avatar/{id}` until `status` is `"completed"`.

### Step 4: Use in Video Generation

Use the photo avatar look ID as `avatar_id` in `POST /v3/videos`:

```bash
curl -X POST "https://api.heygen.com/v3/videos" \
  -H "X-[REDACTED_SECRET]" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "avatar",
    "avatar_id": "045c260bc0364727b2cbe50442c3a5bf",
    "script": "Hello! This is my photo avatar speaking.",
    "voice_id": "1bd001e7e50f421d891986aad5158bc8",
    "aspect_ratio": "16:9"
  }'
```

### Adding Photos to an Existing Group

**Endpoint:** `POST https://api.heygen.com/v2/photo_avatar/avatar_group/add`

| Field | Type | Req | Description |
|-------|------|:---:|-------------|
| `group_id` | string | ✓ | Existing avatar group ID |
| `image_keys` | string[] | ✓ | Array of S3 image keys |
| `name` | string | ✓ | Name for the new looks |

### Training a Photo Avatar Group

**Endpoint:** `POST https://api.heygen.com/v2/photo_avatar/train`

```bash
curl -X POST "https://api.heygen.com/v2/photo_avatar/train" \
  -H "X-[REDACTED_SECRET]" \
  -H "Content-Type: application/json" \
  -d '{"group_id": "045c260bc0364727b2cbe50442c3a5bf"}'
```

Check training status: `GET /v2/photo_avatar/train/status/{group_id}`

## Managing Photo Avatars

### Get Photo Avatar Details

**Endpoint:** `GET https://api.heygen.com/v2/photo_avatar/{id}`

```typescript
async function getPhotoAvatar(id: string): Promise<any> {
  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}`

```typescript
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}`

```typescript
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");
  }
}
```

## 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 `POST /v3/videos` with `"type": "image"` for the best quality** - This is the recommended path and automatically uses Avatar IV technology
2. **Use `"image": { "type": "asset_id", ... }` for private images** - Upload first via `POST upload.heygen.com/v1/asset`, then pass the asset ID in the `image` field
3. **Use high-quality photos** - Better input = better output
4. **Front-facing portraits work best** - Clear face visibility produces the most natural animation
5. **Use `motion_prompt` for natural movement** - Describe the body language you want (e.g., "nodding and gesturing")
6. **Start with `expressiveness: "low"`** - Increase to `"medium"` or `"high"` only if you want more energetic movement
7. **Avoid the legacy avatar group workflow** - The direct `"type": "image"` approach is simpler and produces equivalent or better results

## API Reference

| Endpoint | Method | Description |
|----------|--------|-------------|
| `/v3/videos` | POST | **Recommended** - Create video from photo with `"type": "image"` and nested `image` AssetInput |
| `/v3/videos/{video_id}` | GET | Poll video generation status |
| `upload.heygen.com/v1/asset` | POST | Upload image (returns asset `id` and `image_key`) |
| `/v2/photo_avatar/photo/generate` | POST | Generate AI photo from text description |
| `/v2/photo_avatar/generation/{id}` | GET | Check AI photo generation 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 |
| [REDACTED_SECRET] | POST | Legacy: Create photo avatar group from `image_key` |
| [REDACTED_SECRET] | POST | Legacy: Add photos to existing group |
| `/v2/photo_avatar/train` | POST | Legacy: Train avatar group |
| `/v2/photo_avatar/train/status/{group_id}` | GET | Legacy: Check training status |

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


path: references/quota.md
content: ---
name: quota
description: Credit system, usage limits, and checking remaining quota for HeyGen
---

# 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

```bash
curl -X GET "https://api.heygen.com/v2/user/remaining_quota" \
  -H "X-[REDACTED_SECRET]"
```

### TypeScript

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

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

```json
{
  "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:

```typescript
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

```typescript
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

```typescript
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:

```typescript
const videoConfig = {
  test: true, // Use test mode during development
  video_inputs: [...],
};

// Test videos may have watermarks but don't consume credits
```

## Subscription 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

```typescript
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;
}
```


path: references/remotion-integration.md
content: ---
name: remotion-integration
description: Using HeyGen avatar videos in Remotion compositions
---

# HeyGen + Remotion Integration

This guide covers workflows for generating HeyGen avatar videos and using them in Remotion compositions.

## Quick Start

```typescript
// 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 top
```

## Overview

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

```typescript
// 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

```typescript
// 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/v3/videos", {
    method: "POST",
    headers: {
      "X-Api-Key": process.env.HEYGEN_API_KEY!,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      type: "avatar" as const,
      avatar_id: avatarId,
      script,
      voice_id: voiceId,
      background: {
        type: "color",
        value: "#00FF00", // Green screen for compositing
      },
      resolution: preset === "landscape_1080p" ? "1080p" : "720p",
      aspect_ratio: preset.startsWith("portrait") ? "9:16" : "16:9",
    }),
  });

  const { data } = await response.json();
  return data.video_id;
}
```

### Remotion Composition Setup

```tsx
// 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:

```typescript
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/v3/videos", {
    method: "POST",
    headers: {
      "X-Api-Key": process.env.HEYGEN_API_KEY!,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({
      type: "avatar" as const,
      avatar_id: avatarId,
      script,
      voice_id: voiceId,
      background: {
        type: "color",
        value: backgroundColor,
      },
      resolution: "1080p",
      aspect_ratio: "16:9",
    }),
  });

  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):

```typescript
// Use POST /v3/videos with remove_background: true for transparent background
const response = await fetch("https://api.heygen.com/v3/videos", {
  method: "POST",
  headers: {
    "X-Api-Key": process.env.HEYGEN_API_KEY!,
    "Content-Type": "application/json",
  },
  body: JSON.stringify({
    type: "avatar" as const,
    avatar_id: avatarId,
    script,
    voice_id: voiceId,
    remove_background: true,       // Transparent background
    resolution: "1080p",
    aspect_ratio: "16:9",
  }),
});
```

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

```tsx
// 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` (this endpoint remains at v1 — no v3 equivalent yet) - no chroma keying needed:

```tsx
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:

```tsx
import { OffthreadVideo, AbsoluteFill } from "remotion";

export const LoomStyleComposition: React.FC<{
  screenRecordingUrl: string;
  avatarWebmUrl: string; // Generated with avatar_style: "closeUp" via /v1/video.webm (still v1)
}> = ({ 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):

```tsx
// 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

```tsx
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

```typescript
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

```tsx
// 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:

```typescript
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:

```typescript
// 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

```tsx
// 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

```typescript
// 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):
```typescript
// 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:

```typescript
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

```typescript
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:

```typescript
// 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`):
   ```tsx
   // Before (causes jitter)
   import { Video } from "remotion";

   // After (frame-accurate)
   import { OffthreadVideo } from "remotion";
   ```
3. For WebM with transparency, add the `transparent` prop:
   ```tsx
   <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


path: references/scripts.md
content: ---
name: scripts
description: Writing effective scripts for HeyGen AI avatar videos
---

# Writing Scripts for HeyGen Videos

Scripts for AI avatar videos have different requirements than scripts for human presenters. This guide covers best practices for writing scripts that sound natural and render well.

## Script Basics

### Speech Rate and Duration

Typical speech is approximately **150 words per minute** at normal speed (1.0x). Use this as a rough estimate for planning script length.

| Script Length | Approximate Duration |
|---------------|---------------------|
| 75 words | 30 seconds |
| 150 words | 1 minute |
| 300 words | 2 minutes |
| 450 words | 3 minutes |
| 750 words | 5 minutes |

```typescript
// Estimate video duration from script
function estimateDuration(script: string, speed: number = 1.0): number {
  const words = script.split(/\s+/).filter(w => w.length > 0).length;
  const wordsPerMinute = 150 * speed;
  return words / wordsPerMinute * 60; // seconds
}

// Estimate frames for Remotion
function estimateFrames(script: string, fps: number = 30, speed: number = 1.0): number {
  const durationSeconds = estimateDuration(script, speed);
  return Math.ceil(durationSeconds * fps);
}
```

### Sentence Structure

**Keep sentences short.** AI voices handle shorter sentences more naturally.

| Guideline | Example |
|-----------|---------|
| **Good**: 10-20 words per sentence | "Our platform helps teams collaborate. It syncs in real-time across all devices." |
| **Avoid**: 30+ word run-on sentences | "Our platform helps teams collaborate more effectively by providing real-time synchronization across all devices while also offering offline support and automatic conflict resolution." |

### Punctuation Affects Delivery

| Punctuation | Effect |
|-------------|--------|
| Period `.` | Full stop, natural pause |
| Comma `,` | Brief pause |
| Question mark `?` | Rising intonation |
| Exclamation `!` | Emphasis (use sparingly) |
| Ellipsis `...` | Trailing off, slight pause |

## Adding Pauses with Break Tags

Use SSML-style `<break>` tags for precise pause control:

```
<break time="Xs"/>
```

Where `X` is seconds (e.g., `0.5s`, `1s`, `1.5s`, `2s`).

### Formatting Rules

| Rule | Correct | Incorrect |
|------|---------|-----------|
| Space before tag | `word <break time="1s"/>` | `word<break time="1s"/>` |
| Space after tag | `<break time="1s"/> word` | `<break time="1s"/>word` |
| Use seconds with "s" | `<break time="1.5s"/>` | `<break time="1500ms"/>` |
| Self-closing tag | `<break time="1s"/>` | `<break time="1s"></break>` |

### When to Use Pauses

| Situation | Recommended Pause | Example |
|-----------|-------------------|---------|
| After greeting | 0.5-1s | `Hello! <break time="0.5s"/> Welcome to...` |
| Between sections | 1-1.5s | `...that's feature one. <break time="1.5s"/> Now let's look at...` |
| Before key point | 0.5s | `The most important thing is <break time="0.5s"/> consistency.` |
| For dramatic effect | 1.5-2s | `And the winner is... <break time="2s"/> you!` |
| After question | 1s | `Sound good? <break time="1s"/> Let's get started.` |
| List items | 0.5s | `First, speed. <break time="0.5s"/> Second, reliability.` |

### Pause Duration Guide

| Duration | Feel | Use For |
|----------|------|---------|
| 0.3-0.5s | Brief breath | Between clauses, light emphasis |
| 0.5-1s | Natural pause | Sentence breaks, transitions |
| 1-1.5s | Deliberate pause | Section changes, setup for key points |
| 1.5-2s | Dramatic | Reveals, important announcements |
| 2s+ | Long pause | Use sparingly, can feel unnatural |

### Examples

```typescript
// Section transitions
const script = `
Welcome to our product overview. <break time="1s"/>

Today I'll cover three key features. <break time="0.5s"/>
First, let's look at the dashboard. <break time="1.5s"/>

As you can see, it's designed for simplicity. <break time="0.5s"/>
Every action is just one click away.
`;

// Building suspense
const announcement = `
We've been working on something special. <break time="1s"/>
After months of development... <break time="1.5s"/>
I'm excited to announce <break time="0.5s"/> our new AI assistant.
`;

// List with rhythm
const features = `
Our platform offers three core benefits. <break time="0.5s"/>
Speed. <break time="0.5s"/>
Reliability. <break time="0.5s"/>
And simplicity. <break time="1s"/>
Let me show you each one.
`;
```

### Consecutive Breaks

Multiple consecutive breaks are combined:

```typescript
// These two breaks:
"Hello <break time=\"1s\"/> <break time=\"0.5s\"/> world"

// Are treated as a single 1.5s pause
```

## Script Structure Templates

### Product Demo (60 seconds, ~150 words)

```typescript
const productDemo = `
Hi, I'm [Name], and I'm excited to show you [Product]. <break time="1s"/>

[Product] helps you [main benefit] in just [timeframe]. <break time="0.5s"/>

Here's how it works. <break time="1s"/>

First, [step 1]. <break time="0.5s"/>
Then, [step 2]. <break time="0.5s"/>
And finally, [step 3]. <break time="1s"/>

What used to take [old time] now takes [new time]. <break time="0.5s"/>

Ready to get started? <break time="0.5s"/>
Visit [website] today.
`;
```

### Tutorial Introduction (90 seconds, ~225 words)

```typescript
const tutorial = `
Welcome to this tutorial on [topic]. <break time="0.5s"/>
I'm [Name], and I'll guide you through everything you need to know. <break time="1s"/>

By the end of this video, you'll be able to [outcome 1], [outcome 2], and [outcome 3]. <break time="1s"/>

Let's start with the basics. <break time="1.5s"/>

[Section 1 content - 2-3 sentences] <break time="1s"/>

Now that you understand [concept], let's move on to [next topic]. <break time="1.5s"/>

[Section 2 content - 2-3 sentences] <break time="1s"/>

And finally, let's cover [last topic]. <break time="1.5s"/>

[Section 3 content - 2-3 sentences] <break time="1s"/>

That's everything you need to get started. <break time="0.5s"/>
If you have questions, leave a comment below. <break time="0.5s"/>
Thanks for watching!
`;
```

### Announcement (30 seconds, ~75 words)

```typescript
const announcement = `
Big news! <break time="0.5s"/>

We're thrilled to announce [announcement]. <break time="1s"/>

This means [benefit 1] and [benefit 2] for all our users. <break time="0.5s"/>

Starting [date], you'll be able to [new capability]. <break time="1s"/>

Head to [location] to learn more. <break time="0.5s"/>
We can't wait to hear what you think!
`;
```

## Writing Tips for AI Voices

### Do

- **Write conversationally** - Read it aloud to check flow
- **Use contractions** - "We're" not "We are", "It's" not "It is"
- **Break up long sentences** - Split at natural pause points
- **Spell out abbreviations** - "API" may sound like "a pee eye"
- **Add pauses for emphasis** - Guide the listener's attention
- **End sections clearly** - Don't trail off mid-thought

### Avoid

- **Jargon without context** - Explain technical terms
- **Long parentheticals** - Move to separate sentences
- **Ambiguous pronunciations** - "read" (present) vs "read" (past)
- **Excessive exclamation marks** - One per script is usually enough
- **Run-on sentences** - Break into digestible chunks
- **Dense information** - Space out facts with pauses

### Pronunciation Hints

For words that might be mispronounced, spell phonetically or add hints:

```typescript
// Technical terms
const script1 = "Our API (A-P-I) handles authentication...";

// Ambiguous words
const script2 = "I read (red) the documentation yesterday...";

// Brand names
const script3 = "Welcome to HeyGen (hey-jen)...";
```

## Multi-Scene Scripts

When splitting scripts across scenes (for different backgrounds or avatars):

```typescript
const multiSceneVideo = {
  video_inputs: [
    {
      // Scene 1: Introduction
      character: { type: "avatar", avatar_id: "josh_lite3_20230714", avatar_style: "normal" },
      voice: {
        type: "text",
        input_text: "Welcome to our quarterly update. <break time=\"1s\"/> I'm Josh, and I'll walk you through the highlights.",
        voice_id: "voice_id_here",
      },
      background: { type: "color", value: "#1a1a2e" },
    },
    {
      // Scene 2: Main content (different background)
      character: { type: "avatar", avatar_id: "josh_lite3_20230714", avatar_style: "normal" },
      voice: {
        type: "text",
        input_text: "Let's start with revenue. <break time=\"0.5s\"/> We grew 25 percent quarter over quarter. <break time=\"1s\"/> Here's what drove that growth.",
        voice_id: "voice_id_here",
      },
      background: { type: "image", url: "https://..." },
    },
    // ... more scenes
  ],
};
```

### Scene Transition Tips

- End each scene with a complete thought
- Start new scenes with brief context
- Maintain consistent tone across scenes
- Use pauses at scene starts to let visuals register

## Testing Your Script

Before generating the full video:

1. **Read aloud** - Time yourself, check for awkward phrasing
2. **Count words** - Verify expected duration
3. **Check break tags** - Ensure proper spacing and syntax
4. **Preview with short clip** - Generate a 10-second test if unsure about pronunciation

```typescript
// Test a small portion first
const testScript = script.split('.').slice(0, 2).join('.') + '.';
const testVideoId = await generateVideo({
  video_inputs: [{
    character: { type: "avatar", avatar_id: avatarId, avatar_style: "normal" },
    voice: { type: "text", input_text: testScript, voice_id: voiceId },
  }],
  dimension: { width: 1280, height: 720 }, // Lower res for test
});
```

## Voice Speed Adjustment

Adjust delivery speed in the voice configuration:

```typescript
voice: {
  type: "text",
  input_text: script,
  voice_id: "voice_id",
  speed: 1.1,  // Slightly faster (range: 0.5 - 2.0)
}
```

| Speed | Effect | Use Case |
|-------|--------|----------|
| 0.8-0.9 | Slower, deliberate | Complex topics, older audiences |
| 1.0 | Normal | General use |
| 1.1-1.2 | Slightly faster | Energetic content, younger audiences |
| 1.3+ | Fast | Use sparingly, may reduce clarity |

See [voices.md](voices.md) for full voice configuration options.


path: references/templates.md
content: ---
name: templates
description: Template listing and variable replacement for HeyGen videos
---

# Video Templates

HeyGen templates allow you to create reusable video structures with variable placeholders, enabling personalized video generation at scale.

## Listing Templates

### curl

```bash
curl -X GET "https://api.heygen.com/v2/templates" \
  -H "X-[REDACTED_SECRET]"
```

### TypeScript

```typescript
interface Template {
  template_id: string;
  name: string;
  thumbnail_url: string;
  variables: TemplateVariable[];
}

interface TemplateVariable {
  name: string;
  type: "text" | "image" | "audio";
  properties?: {
    max_length?: number;
    default_value?: string;
  };
}

interface TemplatesResponse {
  error: null | string;
  data: {
    templates: Template[];
  };
}

async function listTemplates(): Promise<Template[]> {
  const response = await fetch("https://api.heygen.com/v2/templates", {
    headers: { "X-Api-Key": process.env.HEYGEN_API_KEY! },
  });

  const json: TemplatesResponse = await response.json();

  if (json.error) {
    throw new Error(json.error);
  }

  return json.data.templates;
}
```

### Python

```python
import requests
import os

def list_templates() -> list:
    response = requests.get(
        "https://api.heygen.com/v2/templates",
        headers={"X-Api-Key": os.environ["HEYGEN_API_KEY"]}
    )

    data = response.json()
    if data.get("error"):
        raise Exception(data["error"])

    return data["data"]["templates"]
```

## Response Format

```json
{
  "error": null,
  "data": {
    "templates": [
      {
        "template_id": "template_abc123",
        "name": "Product Announcement",
        "thumbnail_url": "https://files.heygen.ai/...",
        "variables": [
          {
            "name": "product_name",
            "type": "text",
            "properties": {
              "max_length": 50
            }
          },
          {
            "name": "presenter_script",
            "type": "text",
            "properties": {
              "max_length": 500
            }
          },
          {
            "name": "product_image",
            "type": "image"
          }
        ]
      }
    ]
  }
}
```

## Getting Template Details

### curl

```bash
curl -X GET "https://api.heygen.com/v2/template/{template_id}" \
  -H "X-[REDACTED_SECRET]"
```

### TypeScript

```typescript
async function getTemplate(templateId: string): Promise<Template> {
  const response = await fetch(
    `https://api.heygen.com/v2/template/${templateId}`,
    { 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;
}
```

## Generating Video from Template

### Request Fields

| Field | Type | Req | Description |
|-------|------|:---:|-------------|
| `variables` | object | ✓ | Key-value pairs matching template variables |
| `test` | boolean | | Test mode (watermarked, no credits) |
| `title` | string | | Video name for organization |
| `callback_id` | string | | Custom ID for webhook tracking |
| `callback_url` | string | | URL for completion notification |

**Note:** The `variables` object keys must match the template's defined variable names. Check template details to see which variables are defined.

### curl

```bash
curl -X POST "https://api.heygen.com/v2/template/{template_id}/generate" \
  -H "X-[REDACTED_SECRET]" \
  -H "Content-Type: application/json" \
  -d '{
    "test": false,
    "variables": {
      "product_name": "SuperWidget Pro",
      "presenter_script": "Introducing our latest innovation!",
      "product_image": "https://example.com/product.jpg"
    }
  }'
```

### TypeScript

```typescript
interface TemplateGenerateRequest {
  variables: Record<string, string>;           // Required
  test?: boolean;
  title?: string;
  callback_id?: string;
  callback_url?: string;
}

interface TemplateGenerateResponse {
  error: null | string;
  data: {
    video_id: string;
  };
}

async function generateFromTemplate(
  templateId: string,
  variables: Record<string, string>,
  test: boolean = false
): Promise<string> {
  const response = await fetch(
    `https://api.heygen.com/v2/template/${templateId}/generate`,
    {
      method: "POST",
      headers: {
        "X-Api-Key": process.env.HEYGEN_API_KEY!,
        "Content-Type": "application/json",
      },
      body: JSON.stringify({ test, variables }),
    }
  );

  const json: TemplateGenerateResponse = await response.json();

  if (json.error) {
    throw new Error(json.error);
  }

  return json.data.video_id;
}
```

### Python

```python
def generate_from_template(template_id: str, variables: dict, test: bool = False) -> str:
    response = requests.post(
        f"https://api.heygen.com/v2/template/{template_id}/generate",
        headers={
            "X-Api-Key": os.environ["HEYGEN_API_KEY"],
            "Content-Type": "application/json"
        },
        json={
            "test": test,
            "variables": variables
        }
    )

    data = response.json()
    if data.get("error"):
        raise Exception(data["error"])

    return data["data"]["video_id"]
```

## Variable Types

### Text Variables

For dynamic text content:

```typescript
const variables = {
  customer_name: "John Smith",
  product_name: "SuperWidget Pro",
  price: "$99.99",
  cta_text: "Order Now!",
};
```

### Image Variables

For dynamic images (backgrounds, product shots):

```typescript
const variables = {
  product_image: "https://example.com/product.jpg",
  logo: "https://example.com/logo.png",
  background: "https://example.com/bg.jpg",
};
```

### Audio Variables

For custom audio content:

```typescript
const variables = {
  background_music: "https://example.com/music.mp3",
  custom_voiceover: "https://example.com/voiceover.mp3",
};
```

## Batch Video Generation

Generate multiple personalized videos from a template:

```typescript
interface PersonalizationData {
  name: string;
  email: string;
  company: string;
  customMessage: string;
}

async function batchGenerateVideos(
  templateId: string,
  recipients: PersonalizationData[]
): Promise<string[]> {
  const videoIds: string[] = [];

  for (const recipient of recipients) {
    const variables = {
      recipient_name: recipient.name,
      company_name: recipient.company,
      personalized_message: recipient.customMessage,
    };

    const videoId = await generateFromTemplate(templateId, variables);
    videoIds.push(videoId);

    // Rate limiting: add delay between requests
    await new Promise((r) => setTimeout(r, 1000));
  }

  return videoIds;
}

// Usage
const recipients = [
  {
    name: "John Smith",
    email: "[REDACTED_SECRET]",
    company: "Acme Inc",
    customMessage: "Thanks for your interest in our product!",
  },
  {
    name: "Jane Doe",
    email: "[REDACTED_SECRET]",
    company: "Tech Corp",
    customMessage: "We'd love to show you a demo!",
  },
];

const videoIds = await batchGenerateVideos("template_abc123", recipients);
```

## Template Validation

Validate variables before generating:

```typescript
function validateTemplateVariables(
  template: Template,
  variables: Record<string, string>
): { valid: boolean; errors: string[] } {
  const errors: string[] = [];

  for (const templateVar of template.variables) {
    const value = variables[templateVar.name];

    // Check if required variable is provided
    if (!value) {
      errors.push(`Missing required variable: ${templateVar.name}`);
      continue;
    }

    // Check text length limits
    if (templateVar.type === "text" && templateVar.properties?.max_length) {
      if (value.length > templateVar.properties.max_length) {
        errors.push(
          `Variable "${templateVar.name}" exceeds max length of ${templateVar.properties.max_length}`
        );
      }
    }

    // Validate image URLs
    if (templateVar.type === "image") {
      try {
        new URL(value);
      } catch {
        errors.push(`Variable "${templateVar.name}" is not a valid URL`);
      }
    }
  }

  return {
    valid: errors.length === 0,
    errors,
  };
}
```

## Complete Template Workflow

```typescript
async function createPersonalizedVideo(
  templateId: string,
  personalization: Record<string, string>
): Promise<string> {
  // 1. Get template details
  const template = await getTemplate(templateId);
  console.log(`Using template: ${template.name}`);

  // 2. Validate variables
  const validation = validateTemplateVariables(template, personalization);
  if (!validation.valid) {
    throw new Error(`Validation errors: ${validation.errors.join(", ")}`);
  }

  // 3. Generate video
  console.log("Generating video...");
  const videoId = await generateFromTemplate(templateId, personalization);
  console.log(`Video ID: ${videoId}`);

  // 4. Wait for completion
  const videoUrl = await waitForVideo(videoId);
  console.log(`Video ready: ${videoUrl}`);

  return videoUrl;
}

// Usage
const videoUrl = await createPersonalizedVideo("template_abc123", {
  customer_name: "John Smith",
  product_name: "SuperWidget Pro",
  offer_details: "Get 20% off your first order!",
});
```

## Best Practices

1. **Design for flexibility** - Create templates with generic placeholders
2. **Set reasonable limits** - Define max lengths for text variables
3. **Validate inputs** - Check variable values before generating
4. **Use test mode** - Test with `test: true` to verify before production
5. **Implement rate limiting** - Add delays for batch generation
6. **Cache template data** - Reduce API calls by caching template details
7. **Error handling** - Gracefully handle generation failures

## Use Cases

- **Sales outreach** - Personalized prospect videos
- **Customer onboarding** - Welcome videos with customer name
- **Product updates** - Announcements with dynamic content
- **Training** - Customized training modules
- **Marketing campaigns** - Targeted promotional videos


path: references/text-overlays.md
content: ---
name: text-overlays
description: Adding text overlays with fonts and positioning to HeyGen videos
---

# Text Overlays

Add text overlays to your HeyGen videos for titles, captions, lower thirds, and other on-screen text elements.

## Basic Text Overlay

```typescript
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:

```typescript
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

```typescript
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

```typescript
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

```typescript
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)

```typescript
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

```typescript
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

```typescript
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:

```typescript
// 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](captions.md)


path: references/video-generation.md
content: ---
name: video-generation
description: POST /v3/videos workflow for creating AI avatar videos with HeyGen
---

# Video Generation

## Table of Contents
- [Basic Video Generation](#basic-video-generation)
- [Request Fields](#request-fields)
- [Type: Avatar](#type-avatar)
- [Type: Image](#type-image)
- [Voice Input Modes](#voice-input-modes)
- [Voice Settings](#voice-settings)
- [Motion and Expressiveness](#motion-and-expressiveness)
- [Background Options](#background-options)
- [Resolution and Aspect Ratio](#resolution-and-aspect-ratio)
- [Complete Workflow Example](#complete-workflow-example)
- [Error Handling](#error-handling)
- [Script Length Limits](#script-length-limits)
- [Adding Pauses to Scripts](#adding-pauses-to-scripts)
- [Transparent Background](#transparent-background)
- [Production-Ready Workflow](#production-ready-workflow)
- [Best Practices](#best-practices)

---

The `POST /v3/videos` endpoint creates AI avatar videos with HeyGen. The request body is a **discriminated union** on the `type` field with two variants:

- **`type: "avatar"`** -- Use a HeyGen avatar (video or photo avatar) by its look ID.
- **`type: "image"`** -- Animate an arbitrary image (via URL, asset ID, or base64). Uses Avatar IV automatically.

Each request produces a single video with one avatar/image and one voice input. For multi-scene workflows, create multiple videos and stitch them together in post-production.

## Basic Video Generation

### From a HeyGen avatar (`type: "avatar"`)

```bash
curl -X POST "https://api.heygen.com/v3/videos" \
  -H "X-[REDACTED_SECRET]" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "avatar",
    "avatar_id": "josh_lite3_20230714",
    "script": "Hello! Welcome to HeyGen.",
    "voice_id": "1bd001e7e50f421d891986aad5158bc8",
    "resolution": "1080p",
    "aspect_ratio": "16:9"
  }'
```

### From an arbitrary image (`type: "image"`)

```bash
curl -X POST "https://api.heygen.com/v3/videos" \
  -H "X-[REDACTED_SECRET]" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "image",
    "image": {
      "type": "url",
      "url": "https://example.com/my-photo.jpg"
    },
    "script": "Hello from a custom image!",
    "voice_id": "1bd001e7e50f421d891986aad5158bc8",
    "resolution": "1080p",
    "aspect_ratio": "16:9"
  }'
```

### Response (same for both)

```json
{
  "data": {
    "video_id": "abc123",
    "status": "waiting"
  }
}
```

### TypeScript

```typescript
// Discriminated union: the `type` field determines which variant is used.

interface AssetInputUrl {
  type: "url";
  url: string;
}

interface AssetInputAssetId {
  type: "asset_id";
  asset_id: string;
}

interface AssetInputBase64 {
  type: "base64";
  media_type: string;  // e.g. "image/png"
  data: string;
}

type AssetInput = AssetInputUrl | AssetInputAssetId | AssetInputBase64;

interface SharedVideoFields {
  // Voice source (mutually exclusive groups)
  script?: string;          // Text script -- requires voice_id
  voice_id?: string;        // Voice ID for TTS -- required with script
  audio_url?: string;       // Public URL of audio to lip-sync
  audio_asset_id?: string;  // HeyGen asset ID of uploaded audio

  // Display
  title?: string;
  resolution?: "1080p" | "720p";
  aspect_ratio?: "16:9" | "9:16";

  // Avatar behavior (photo avatars / image mode)
  motion_prompt?: string;
  expressiveness?: "high" | "medium" | "low";

  // Background
  remove_background?: boolean;
  background?: {
    type: "color" | "image";
    value?: string;     // Hex color (when type is "color")
    url?: string;       // Image URL (when type is "image")
    asset_id?: string;  // HeyGen asset ID (when type is "image")
  };

  // Voice tuning
  voice_settings?: {
    speed?: number;   // 0.5 - 1.5
    pitch?: number;   // -50 to 50
    locale?: string;  // e.g. "en-US"
  };

  // Callbacks
  callback_url?: string;
  callback_id?: string;
}

interface CreateVideoFromAvatar extends SharedVideoFields {
  type: "avatar";
  avatar_id: string;       // Required: HeyGen avatar look ID
}

interface CreateVideoFromImage extends SharedVideoFields {
  type: "image";
  image: AssetInput;       // Required: image source (url, asset_id, or base64)
}

type CreateVideoRequest = CreateVideoFromAvatar | CreateVideoFromImage;

interface CreateVideoResponse {
  data: {
    video_id: string;
    status: string; // e.g. "waiting"
  };
}

interface VideoErrorResponse {
  error: {
    code: string;
    message: string;
  };
}

async function generateVideo(config: CreateVideoRequest): Promise<string> {
  const response = await fetch("https://api.heygen.com/v3/videos", {
    method: "POST",
    headers: {
      "X-Api-Key": process.env.HEYGEN_API_KEY!,
      "Content-Type": "application/json",
    },
    body: JSON.stringify(config),
  });

  const json = await response.json();

  if (!response.ok) {
    const err = json as VideoErrorResponse;
    throw new Error(`${err.error.code}: ${err.error.message}`);
  }

  const result = json as CreateVideoResponse;
  return result.data.video_id;
}
```

### Python

```python
import requests
import os

def generate_video(config: dict) -> str:
    response = requests.post(
        "https://api.heygen.com/v3/videos",
        headers={
            "X-Api-Key": os.environ["HEYGEN_API_KEY"],
            "Content-Type": "application/json",
        },
        json=config,
    )

    data = response.json()
    if not response.ok:
        err = data["error"]
        raise Exception(f"{err['code']}: {err['message']}")

    return data["data"]["video_id"]
```

## Request Fields

The v3 API uses a **discriminated union** on the `type` field. The `type` determines which variant-specific fields are required, while audio, display, and output fields are shared across both variants.

### Discriminator

| Field | Type | Description |
|-------|------|-------------|
| `type` | `"avatar"` \| `"image"` | **Required.** Selects the creation mode. |

### Type-specific Fields

| Field | Applies to | Type | Description |
|-------|-----------|------|-------------|
| `avatar_id` | `type: "avatar"` | string | **Required.** HeyGen avatar look ID. |
| `image` | `type: "image"` | AssetInput | **Required.** Image source as a discriminated union (see [Type: Image](#type-image)). |

### Shared Audio Fields (mutually exclusive groups)

Provide exactly one audio source: `script` + `voice_id`, or `audio_url`, or `audio_asset_id`.

| Field | Type | Description |
|-------|------|-------------|
| `script` | string \| null | Text script for the avatar to speak. Requires `voice_id`. |
| `voice_id` | string \| null | Voice ID for TTS. Required when `script` is provided. |
| `audio_url` | string \| null | Public URL of audio file to lip-sync. Mutually exclusive with `script`. |
| `audio_asset_id` | string \| null | HeyGen asset ID of uploaded audio. Mutually exclusive with `script`. |

### Shared Output Fields

| Field | Type | Description |
|-------|------|-------------|
| `title` | string \| null | Display title in HeyGen dashboard. |
| `resolution` | `"1080p"` \| `"720p"` \| null | Output resolution. |
| `aspect_ratio` | `"16:9"` \| `"9:16"` \| null | Output aspect ratio. |
| `motion_prompt` | string \| null | Natural-language prompt controlling body motion. Photo avatars and image mode. |
| `expressiveness` | `"high"` \| `"medium"` \| `"low"` \| null | Expression intensity. Photo avatars and image mode. Defaults to `"low"`. |
| `remove_background` | boolean \| null | Remove the avatar background. |
| `background` | object \| null | Background config (see [Background Options](#background-options)). |
| `voice_settings` | object \| null | Voice tuning (see [Voice Settings](#voice-settings)). |
| `callback_url` | string \| null | URL for completion webhook notification. |
| `callback_id` | string \| null | Custom ID included in webhook payload. |

## Type: Avatar

Use `type: "avatar"` to create a video from a HeyGen avatar (video or photo avatar) by its look ID. The `avatar_id` field is required.

### curl

```bash
curl -X POST "https://api.heygen.com/v3/videos" \
  -H "X-[REDACTED_SECRET]" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "avatar",
    "avatar_id": "josh_lite3_20230714",
    "script": "Hello from a HeyGen avatar!",
    "voice_id": "1bd001e7e50f421d891986aad5158bc8"
  }'
```

### TypeScript

```typescript
const videoId = await generateVideo({
  type: "avatar",
  avatar_id: "josh_lite3_20230714",
  script: "Hello from a HeyGen avatar!",
  voice_id: "1bd001e7e50f421d891986aad5158bc8",
});
```

### Python

```python
video_id = generate_video({
    "type": "avatar",
    "avatar_id": "josh_lite3_20230714",
    "script": "Hello from a HeyGen avatar!",
    "voice_id": "1bd001e7e50f421d891986aad5158bc8",
})
```

## Type: Image

Use `type: "image"` to animate an arbitrary image. The `image` field is required and is itself a discriminated union (`AssetInput`) with three variants:

| Variant | Fields | Description |
|---------|--------|-------------|
| `type: "url"` | `url` (string, required) | Public URL of the image. |
| `type: "asset_id"` | `asset_id` (string, required) | HeyGen asset ID from a previous upload. |
| `type: "base64"` | `media_type` (string, required), `data` (string, required) | Inline base64-encoded image. |

### 1. Image from URL

#### curl

```bash
curl -X POST "https://api.heygen.com/v3/videos" \
  -H "X-[REDACTED_SECRET]" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "image",
    "image": {
      "type": "url",
      "url": "https://example.com/my-photo.jpg"
    },
    "script": "Hello from a custom image!",
    "voice_id": "1bd001e7e50f421d891986aad5158bc8"
  }'
```

#### TypeScript

```typescript
const videoId = await generateVideo({
  type: "image",
  image: {
    type: "url",
    url: "https://example.com/my-photo.jpg",
  },
  script: "Hello from a custom image!",
  voice_id: "1bd001e7e50f421d891986aad5158bc8",
});
```

#### Python

```python
video_id = generate_video({
    "type": "image",
    "image": {
        "type": "url",
        "url": "https://example.com/my-photo.jpg",
    },
    "script": "Hello from a custom image!",
    "voice_id": "1bd001e7e50f421d891986aad5158bc8",
})
```

### 2. Image from Asset ID

#### curl

```bash
curl -X POST "https://api.heygen.com/v3/videos" \
  -H "X-[REDACTED_SECRET]" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "image",
    "image": {
      "type": "asset_id",
      "asset_id": "asset_abc123"
    },
    "script": "Hello from an uploaded image!",
    "voice_id": "1bd001e7e50f421d891986aad5158bc8"
  }'
```

#### TypeScript

```typescript
const videoId = await generateVideo({
  type: "image",
  image: {
    type: "asset_id",
    asset_id: "asset_abc123",
  },
  script: "Hello from an uploaded image!",
  voice_id: "1bd001e7e50f421d891986aad5158bc8",
});
```

#### Python

```python
video_id = generate_video({
    "type": "image",
    "image": {
        "type": "asset_id",
        "asset_id": "asset_abc123",
    },
    "script": "Hello from an uploaded image!",
    "voice_id": "1bd001e7e50f421d891986aad5158bc8",
})
```

### 3. Image from Base64

#### curl

```bash
curl -X POST "https://api.heygen.com/v3/videos" \
  -H "X-[REDACTED_SECRET]" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "image",
    "image": {
      "type": "base64",
      "media_type": "image/png",
      "data": "iVBORw0KGgoAAAANSUhEUgAA..."
    },
    "script": "Hello from a base64 image!",
    "voice_id": "1bd001e7e50f421d891986aad5158bc8"
  }'
```

#### TypeScript

```typescript
import { readFileSync } from "fs";

const imageData = readFileSync("photo.png").toString("base64");

const videoId = await generateVideo({
  type: "image",
  image: {
    type: "base64",
    media_type: "image/png",
    data: imageData,
  },
  script: "Hello from a base64 image!",
  voice_id: "1bd001e7e50f421d891986aad5158bc8",
});
```

#### Python

```python
import base64

with open("photo.png", "rb") as f:
    image_data = base64.b64encode(f.read()).decode("utf-8")

video_id = generate_video({
    "type": "image",
    "image": {
        "type": "base64",
        "media_type": "image/png",
        "data": image_data,
    },
    "script": "Hello from a base64 image!",
    "voice_id": "1bd001e7e50f421d891986aad5158bc8",
})
```

## Voice Input Modes

There are three mutually exclusive ways to provide voice input. These work the same for both `type: "avatar"` and `type: "image"`.

### 1. Text-to-Speech (`script` + `voice_id`)

Provide a text script and a voice ID. HeyGen generates speech and lip-syncs the avatar.

#### curl

```bash
curl -X POST "https://api.heygen.com/v3/videos" \
  -H "X-[REDACTED_SECRET]" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "avatar",
    "avatar_id": "josh_lite3_20230714",
    "script": "This is text-to-speech with a chosen voice.",
    "voice_id": "1bd001e7e50f421d891986aad5158bc8",
    "voice_settings": {
      "speed": 1.1,
      "pitch": 5
    }
  }'
```

#### TypeScript

```typescript
const videoId = await generateVideo({
  type: "avatar",
  avatar_id: "josh_lite3_20230714",
  script: "This is text-to-speech with a chosen voice.",
  voice_id: "1bd001e7e50f421d891986aad5158bc8",
  voice_settings: {
    speed: 1.1,
    pitch: 5,
  },
});
```

#### Python

```python
video_id = generate_video({
    "type": "avatar",
    "avatar_id": "josh_lite3_20230714",
    "script": "This is text-to-speech with a chosen voice.",
    "voice_id": "1bd001e7e50f421d891986aad5158bc8",
    "voice_settings": {
        "speed": 1.1,
        "pitch": 5,
    },
})
```

### 2. Custom Audio (`audio_url`)

Provide a pre-recorded audio file by URL. The avatar lip-syncs to it. Do not provide `script` or `voice_id` when using audio input.

#### curl

```bash
curl -X POST "https://api.heygen.com/v3/videos" \
  -H "X-[REDACTED_SECRET]" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "avatar",
    "avatar_id": "josh_lite3_20230714",
    "audio_url": "https://example.com/narration.mp3"
  }'
```

#### TypeScript

```typescript
const videoId = await generateVideo({
  type: "avatar",
  avatar_id: "josh_lite3_20230714",
  audio_url: "https://example.com/narration.mp3",
});
```

#### Python

```python
video_id = generate_video({
    "type": "avatar",
    "avatar_id": "josh_lite3_20230714",
    "audio_url": "https://example.com/narration.mp3",
})
```

### 3. Uploaded Audio Asset (`audio_asset_id`)

Use an audio file previously uploaded to HeyGen's asset system.

#### curl

```bash
curl -X POST "https://api.heygen.com/v3/videos" \
  -H "X-[REDACTED_SECRET]" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "avatar",
    "avatar_id": "josh_lite3_20230714",
    "audio_asset_id": "asset_audio_456"
  }'
```

#### TypeScript

```typescript
const videoId = await generateVideo({
  type: "avatar",
  avatar_id: "josh_lite3_20230714",
  audio_asset_id: "asset_audio_456",
});
```

#### Python

```python
video_id = generate_video({
    "type": "avatar",
    "avatar_id": "josh_lite3_20230714",
    "audio_asset_id": "asset_audio_456",
})
```

## Voice Settings

Fine-tune TTS output with `voice_settings`. Only applicable when using `script` + `voice_id`.

| Field | Type | Range | Description |
|-------|------|-------|-------------|
| `speed` | number | 0.5 - 1.5 | Speech speed multiplier. Default 1.0. |
| `pitch` | number | -50 to 50 | Voice pitch adjustment. Default 0. |
| `locale` | string | | Language locale hint (e.g. `"en-US"`, `"es-ES"`). |

### curl

```bash
curl -X POST "https://api.heygen.com/v3/videos" \
  -H "X-[REDACTED_SECRET]" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "avatar",
    "avatar_id": "josh_lite3_20230714",
    "script": "This has custom voice settings.",
    "voice_id": "1bd001e7e50f421d891986aad5158bc8",
    "voice_settings": {
      "speed": 0.9,
      "pitch": -10,
      "locale": "en-US"
    }
  }'
```

### TypeScript

```typescript
const videoId = await generateVideo({
  type: "avatar",
  avatar_id: "josh_lite3_20230714",
  script: "This has custom voice settings.",
  voice_id: "1bd001e7e50f421d891986aad5158bc8",
  voice_settings: {
    speed: 0.9,
    pitch: -10,
    locale: "en-US",
  },
});
```

### Python

```python
video_id = generate_video({
    "type": "avatar",
    "avatar_id": "josh_lite3_20230714",
    "script": "This has custom voice settings.",
    "voice_id": "1bd001e7e50f421d891986aad5158bc8",
    "voice_settings": {
        "speed": 0.9,
        "pitch": -10,
        "locale": "en-US",
    },
})
```

## Motion and Expressiveness

Control avatar body motion and facial expressiveness. These work with **photo avatars** (`type: "avatar"`) and **image mode** (`type: "image"`).

| Field | Type | Description |
|-------|------|-------------|
| `motion_prompt` | string | Natural-language description of desired body motion (e.g. `"lean forward enthusiastically"`, `"nod while speaking"`). |
| `expressiveness` | `"high"` \| `"medium"` \| `"low"` | Facial expression intensity. Defaults to `"low"`. |

### curl

```bash
curl -X POST "https://api.heygen.com/v3/videos" \
  -H "X-[REDACTED_SECRET]" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "avatar",
    "avatar_id": "josh_lite3_20230714",
    "script": "I am really excited to show you this feature!",
    "voice_id": "1bd001e7e50f421d891986aad5158bc8",
    "motion_prompt": "lean forward with enthusiasm, use hand gestures",
    "expressiveness": "high"
  }'
```

### TypeScript

```typescript
const videoId = await generateVideo({
  type: "avatar",
  avatar_id: "josh_lite3_20230714",
  script: "I am really excited to show you this feature!",
  voice_id: "1bd001e7e50f421d891986aad5158bc8",
  motion_prompt: "lean forward with enthusiasm, use hand gestures",
  expressiveness: "high",
});
```

### Python

```python
video_id = generate_video({
    "type": "avatar",
    "avatar_id": "josh_lite3_20230714",
    "script": "I am really excited to show you this feature!",
    "voice_id": "1bd001e7e50f421d891986aad5158bc8",
    "motion_prompt": "lean forward with enthusiasm, use hand gestures",
    "expressiveness": "high",
})
```

## Background Options

Set a custom background with the `background` field.

| Field | Type | Description |
|-------|------|-------------|
| `type` | `"color"` \| `"image"` | Background type. |
| `value` | string | Hex color code (when type is `"color"`). |
| `url` | string | Public image URL (when type is `"image"`). |
| `asset_id` | string | HeyGen asset ID of uploaded image (when type is `"image"`). |

### Solid Color Background

#### curl

```bash
curl -X POST "https://api.heygen.com/v3/videos" \
  -H "X-[REDACTED_SECRET]" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "avatar",
    "avatar_id": "josh_lite3_20230714",
    "script": "White background video.",
    "voice_id": "1bd001e7e50f421d891986aad5158bc8",
    "background": {
      "type": "color",
      "value": "#FFFFFF"
    }
  }'
```

#### TypeScript

```typescript
const videoId = await generateVideo({
  type: "avatar",
  avatar_id: "josh_lite3_20230714",
  script: "White background video.",
  voice_id: "1bd001e7e50f421d891986aad5158bc8",
  background: {
    type: "color",
    value: "#FFFFFF",
  },
});
```

#### Python

```python
video_id = generate_video({
    "type": "avatar",
    "avatar_id": "josh_lite3_20230714",
    "script": "White background video.",
    "voice_id": "1bd001e7e50f421d891986aad5158bc8",
    "background": {
        "type": "color",
        "value": "#FFFFFF",
    },
})
```

### Image Background

#### curl

```bash
curl -X POST "https://api.heygen.com/v3/videos" \
  -H "X-[REDACTED_SECRET]" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "avatar",
    "avatar_id": "josh_lite3_20230714",
    "script": "Custom image background.",
    "voice_id": "1bd001e7e50f421d891986aad5158bc8",
    "background": {
      "type": "image",
      "url": "https://example.com/office-bg.jpg"
    }
  }'
```

#### TypeScript

```typescript
// Using a public URL
const videoId = await generateVideo({
  type: "avatar",
  avatar_id: "josh_lite3_20230714",
  script: "Custom image background.",
  voice_id: "1bd001e7e50f421d891986aad5158bc8",
  background: {
    type: "image",
    url: "https://example.com/office-bg.jpg",
  },
});

// Using a HeyGen asset
const videoId2 = await generateVideo({
  type: "avatar",
  avatar_id: "josh_lite3_20230714",
  script: "Background from uploaded asset.",
  voice_id: "1bd001e7e50f421d891986aad5158bc8",
  background: {
    type: "image",
    asset_id: "asset_bg_789",
  },
});
```

#### Python

```python
# Using a public URL
video_id = generate_video({
    "type": "avatar",
    "avatar_id": "josh_lite3_20230714",
    "script": "Custom image background.",
    "voice_id": "1bd001e7e50f421d891986aad5158bc8",
    "background": {
        "type": "image",
        "url": "https://example.com/office-bg.jpg",
    },
})

# Using a HeyGen asset
video_id = generate_video({
    "type": "avatar",
    "avatar_id": "josh_lite3_20230714",
    "script": "Background from uploaded asset.",
    "voice_id": "1bd001e7e50f421d891986aad5158bc8",
    "background": {
        "type": "image",
        "asset_id": "asset_bg_789",
    },
})
```

## Resolution and Aspect Ratio

| Field | Values | Description |
|-------|--------|-------------|
| `resolution` | `"1080p"`, `"720p"` | Output resolution. Defaults to `"1080p"`. |
| `aspect_ratio` | `"16:9"`, `"9:16"` | Output aspect ratio. `"16:9"` for landscape, `"9:16"` for portrait/mobile. |

### curl

```bash
# Portrait/mobile video
curl -X POST "https://api.heygen.com/v3/videos" \
  -H "X-[REDACTED_SECRET]" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "avatar",
    "avatar_id": "josh_lite3_20230714",
    "script": "This is a portrait video for mobile.",
    "voice_id": "1bd001e7e50f421d891986aad5158bc8",
    "resolution": "1080p",
    "aspect_ratio": "9:16"
  }'
```

### TypeScript

```typescript
// Portrait/mobile video
const videoId = await generateVideo({
  type: "avatar",
  avatar_id: "josh_lite3_20230714",
  script: "This is a portrait video for mobile.",
  voice_id: "1bd001e7e50f421d891986aad5158bc8",
  resolution: "1080p",
  aspect_ratio: "9:16",
});
```

### Python

```python
# Portrait/mobile video
video_id = generate_video({
    "type": "avatar",
    "avatar_id": "josh_lite3_20230714",
    "script": "This is a portrait video for mobile.",
    "voice_id": "1bd001e7e50f421d891986aad5158bc8",
    "resolution": "1080p",
    "aspect_ratio": "9:16",
})
```

## Complete Workflow Example

### TypeScript

```typescript
async function createVideo(
  script: string,
  avatarId: string,
  voiceId: string
): Promise<string> {
  // 1. Generate video
  console.log("Starting video generation...");
  const videoId = await generateVideo({
    type: "avatar",
    avatar_id: avatarId,
    script: script,
    voice_id: voiceId,
    resolution: "1080p",
    aspect_ratio: "16:9",
    background: {
      type: "color",
      value: "#FFFFFF",
    },
  });

  console.log(`Video ID: ${videoId}`);

  // 2. Poll for completion
  console.log("Waiting for video completion...");
  const videoUrl = await waitForVideo(videoId);

  console.log(`Video ready: ${videoUrl}`);
  return videoUrl;
}

// Helper function for polling
async function waitForVideo(videoId: string): Promise<string> {
  const maxAttempts = 60;
  const pollInterval = 10000; // 10 seconds

  for (let i = 0; i < maxAttempts; i++) {
    const response = await fetch(
      `https://api.heygen.com/v3/videos/${videoId}`,
      { headers: { "X-Api-Key": process.env.HEYGEN_API_KEY! } }
    );

    const json = await response.json();

    if (!response.ok) {
      const err = json as VideoErrorResponse;
      throw new Error(`${err.error.code}: ${err.error.message}`);
    }

    const { data } = json;

    if (data.status === "completed") {
      return data.video_url;
    } else if (data.status === "failed") {
      throw new Error(data.failure_message || "Video generation failed");
    }

    await new Promise((r) => setTimeout(r, pollInterval));
  }

  throw new Error("Video generation timed out");
}
```

### Python

```python
import time

def create_video(script: str, avatar_id: str, voice_id: str) -> str:
    # 1. Generate video
    print("Starting video generation...")
    video_id = generate_video({
        "type": "avatar",
        "avatar_id": avatar_id,
        "script": script,
        "voice_id": voice_id,
        "resolution": "1080p",
        "aspect_ratio": "16:9",
        "background": {
            "type": "color",
            "value": "#FFFFFF",
        },
    })

    print(f"Video ID: {video_id}")

    # 2. Poll for completion
    print("Waiting for video completion...")
    video_url = wait_for_video(video_id)

    print(f"Video ready: {video_url}")
    return video_url


def wait_for_video(video_id: str, max_attempts: int = 60, poll_interval: int = 10) -> str:
    for _ in range(max_attempts):
        response = requests.get(
            f"https://api.heygen.com/v3/videos/{video_id}",
            headers={"X-Api-Key": os.environ["HEYGEN_API_KEY"]},
        )

        data = response.json()
        if not response.ok:
            err = data["error"]
            raise Exception(f"{err['code']}: {err['message']}")

        status = data["data"]["status"]

        if status == "completed":
            return data["data"]["video_url"]
        elif status == "failed":
            raise Exception(data["data"].get("failure_message", "Video generation failed"))

        time.sleep(poll_interval)

    raise Exception("Video generation timed out")
```

## Error Handling

The v3 API returns errors in a structured format:

```json
{
  "error": {
    "code": "invalid_request",
    "message": "avatar_id is required when type is avatar"
  }
}
```

### TypeScript

```typescript
async function generateVideoSafe(config: CreateVideoRequest) {
  try {
    const videoId = await generateVideo(config);
    return { success: true, videoId };
  } catch (error) {
    const message = error instanceof Error ? error.message : String(error);

    // Common error patterns
    if (message.includes("quota") || message.includes("credit")) {
      console.error("Insufficient credits");
    } else if (message.includes("avatar")) {
      console.error("Invalid avatar ID");
    } else if (message.includes("voice")) {
      console.error("Invalid voice ID");
    } else if (message.includes("image")) {
      console.error("Invalid image input");
    } else if (message.includes("script")) {
      console.error("Script too long or invalid");
    }

    return { success: false, error: message };
  }
}
```

### Python

```python
def generate_video_safe(config: dict) -> dict:
    try:
        video_id = generate_video(config)
        return {"success": True, "video_id": video_id}
    except Exception as e:
        message = str(e)

        if "quota" in message or "credit" in message:
            print("Insufficient credits")
        elif "avatar" in message:
            print("Invalid avatar ID")
        elif "voice" in message:
            print("Invalid voice ID")
        elif "image" in message:
            print("Invalid image input")
        elif "script" in message:
            print("Script too long or invalid")

        return {"success": False, "error": message}
```

## Script Length Limits

| Tier | Max Characters |
|------|----------------|
| Free | ~500 |
| Creator | ~1,500 |
| Team | ~3,000 |
| Enterprise | ~5,000+ |

## Adding Pauses to Scripts

Use `<break>` tags to add pauses in your script:

```typescript
const script = 'Welcome to our demo. <break time="1s"/> Let me show you the features.';
```

**Format:** `<break time="Xs"/>` where X is seconds (e.g., `1s`, `1.5s`, `0.5s`)

**Important:** Break tags must have spaces before and after them.

See [voices.md](voices.md) for detailed break tag documentation.

## Transparent Background

Use `remove_background: true` to generate a video with the avatar's background removed. This is useful when you want to overlay the avatar on other content in post-production.

### curl

```bash
curl -X POST "https://api.heygen.com/v3/videos" \
  -H "X-[REDACTED_SECRET]" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "avatar",
    "avatar_id": "josh_lite3_20230714",
    "script": "This video has the background removed.",
    "voice_id": "1bd001e7e50f421d891986aad5158bc8",
    "remove_background": true
  }'
```

### TypeScript

```typescript
const videoId = await generateVideo({
  type: "avatar",
  avatar_id: "josh_lite3_20230714",
  script: "This video has the background removed.",
  voice_id: "1bd001e7e50f421d891986aad5158bc8",
  remove_background: true,
});
```

### Python

```python
video_id = generate_video({
    "type": "avatar",
    "avatar_id": "josh_lite3_20230714",
    "script": "This video has the background removed.",
    "voice_id": "1bd001e7e50f421d891986aad5158bc8",
    "remove_background": True,
})
```

**When to use `remove_background`:**
- Avatar overlaid on screen recordings (Loom-style)
- Avatar floating over video or image content
- True alpha-channel compositing in post-production

**When you do NOT need `remove_background`:**
- Avatar with overlays/text on top of the avatar
- Standard presenter videos with a solid or image background

## Production-Ready Workflow

Complete example using the avatar's default voice (recommended), proper timeouts, and retry logic.

### TypeScript

```typescript
interface VideoGenerationResult {
  videoId: string;
  videoUrl: string;
  duration: number;
  avatarId: string;
  voiceId: string;
  avatarName: string;
}

async function generateAvatarVideo(
  script: string,
  options: {
    avatarId?: string; // Specific avatar, or will pick first available
    resolution?: "1080p" | "720p";
    aspectRatio?: "16:9" | "9:16";
  } = {}
): Promise<VideoGenerationResult> {
  const { resolution = "1080p", aspectRatio = "16:9" } = options;
  let { avatarId } = options;

  // 1. List avatar looks if no specific one provided
  if (!avatarId) {
    console.log("Listing available avatar looks...");
    const listResponse = await fetch("https://api.heygen.com/v3/avatars/looks", {
      headers: { "X-Api-Key": process.env.HEYGEN_API_KEY! },
    });

    if (!listResponse.ok) {
      const err = await listResponse.json();
      throw new Error(`${err.error.code}: ${err.error.message}`);
    }

    const listData = await listResponse.json();

    if (!listData.data?.length) {
      throw new Error("No avatar looks available");
    }
    avatarId = listData.data[0].id;
  }

  // 2. Get avatar look details including default voice
  console.log(`Getting details for avatar look: ${avatarId}`);
  const detailsResponse = await fetch(
    `https://api.heygen.com/v3/avatars/looks/${avatarId}`,
    { headers: { "X-Api-Key": process.env.HEYGEN_API_KEY! } }
  );

  if (!detailsResponse.ok) {
    const err = await detailsResponse.json();
    throw new Error(`${err.error.code}: ${err.error.message}`);
  }

  const { data: avatar } = await detailsResponse.json();

  if (!avatar.default_voice_id) {
    throw new Error(
      `Avatar ${avatar.name} has no default voice - select a voice manually`
    );
  }

  console.log(
    `Using avatar: ${avatar.name} with default voice: ${avatar.default_voice_id}`
  );

  // 3. Generate video
  const videoId = await generateVideo({
    type: "avatar",
    avatar_id: avatarId,
    script: script,
    voice_id: avatar.default_voice_id,
    resolution: resolution,
    aspect_ratio: aspectRatio,
    background: {
      type: "color",
      value: "#1a1a2e",
    },
  });

  console.log(`Video ID: ${videoId}`);

  // 4. Wait for completion (20 minute timeout - generation can take 15+ min)
  console.log("Waiting for video generation (typically 5-15 minutes)...");
  const maxAttempts = 120; // 120 * 10s = 20 minutes
  const pollInterval = 10000;

  for (let i = 0; i < maxAttempts; i++) {
    const response = await fetch(
      `https://api.heygen.com/v3/videos/${videoId}`,
      { headers: { "X-Api-Key": process.env.HEYGEN_API_KEY! } }
    );

    const json = await response.json();

    if (!response.ok) {
      const err = json as VideoErrorResponse;
      throw new Error(`${err.error.code}: ${err.error.message}`);
    }

    const { data } = json;

    console.log(`  [${Math.round((i * pollInterval) / 1000)}s] ${data.status}`);

    if (data.status === "completed") {
      return {
        videoId,
        videoUrl: data.video_url,
        duration: data.duration,
        avatarId: avatarId!,
        voiceId: avatar.default_voice_id,
        avatarName: avatar.name,
      };
    } else if (data.status === "failed") {
      throw new Error(data.failure_message || "Video generation failed");
    }

    await new Promise((r) => setTimeout(r, pollInterval));
  }

  throw new Error("Video generation timed out after 20 minutes");
}

// Usage - let it pick an avatar automatically
const result = await generateAvatarVideo(
  "Hello! Welcome to our product demonstration."
);
console.log(`Video ready: ${result.videoUrl}`);

// Or specify a known avatar look ID
const result2 = await generateAvatarVideo(
  "Hello! Welcome to our product demonstration.",
  { avatarId: "josh_lite3_20230714", aspectRatio: "9:16" }
);
```

### Python

```python
def generate_avatar_video(
    script: str,
    avatar_id: str | None = None,
    resolution: str = "1080p",
    aspect_ratio: str = "16:9",
) -> dict:
    headers = {"X-Api-Key": os.environ["HEYGEN_API_KEY"]}

    # 1. List avatar looks if no specific one provided
    if not avatar_id:
        print("Listing available avatar looks...")
        list_resp = requests.get(
            "https://api.heygen.com/v3/avatars/looks",
            headers=headers,
        )
        list_data = list_resp.json()
        if not list_resp.ok:
            err = list_data["error"]
            raise Exception(f"{err['code']}: {err['message']}")

        if not list_data.get("data"):
            raise Exception("No avatar looks available")
        avatar_id = list_data["data"][0]["id"]

    # 2. Get avatar look details
    print(f"Getting details for avatar look: {avatar_id}")
    details_resp = requests.get(
        f"https://api.heygen.com/v3/avatars/looks/{avatar_id}",
        headers=headers,
    )
    details_data = details_resp.json()
    if not details_resp.ok:
        err = details_data["error"]
        raise Exception(f"{err['code']}: {err['message']}")

    avatar = details_data["data"]

    if not avatar.get("default_voice_id"):
        raise Exception(f"Avatar {avatar['name']} has no default voice")

    voice_id = avatar["default_voice_id"]
    print(f"Using avatar: {avatar['name']} with default voice: {voice_id}")

    # 3. Generate video
    video_id = generate_video({
        "type": "avatar",
        "avatar_id": avatar_id,
        "script": script,
        "voice_id": voice_id,
        "resolution": resolution,
        "aspect_ratio": aspect_ratio,
        "background": {
            "type": "color",
            "value": "#1a1a2e",
        },
    })

    print(f"Video ID: {video_id}")

    # 4. Wait for completion (20 minute timeout)
    print("Waiting for video generation (typically 5-15 minutes)...")
    max_attempts = 120
    poll_interval = 10

    for i in range(max_attempts):
        resp = requests.get(
            f"https://api.heygen.com/v3/videos/{video_id}",
            headers=headers,
        )
        data = resp.json()
        if not resp.ok:
            err = data["error"]
            raise Exception(f"{err['code']}: {err['message']}")

        status = data["data"]["status"]
        print(f"  [{i * poll_interval}s] {status}")

        if status == "completed":
            return {
                "video_id": video_id,
                "video_url": data["data"]["video_url"],
                "duration": data["data"]["duration"],
                "avatar_id": avatar_id,
                "voice_id": voice_id,
                "avatar_name": avatar["name"],
            }
        elif status == "failed":
            raise Exception(
                data["data"].get("failure_message", "Video generation failed")
            )

        time.sleep(poll_interval)

    raise Exception("Video generation timed out after 20 minutes")


# Usage
result = generate_avatar_video("Hello! Welcome to our product demonstration.")
print(f"Video ready: {result['video_url']}")
```

## Multi-Scene Note

The v3 API creates **one video per request** (single scene). For multi-scene workflows, generate multiple videos and concatenate them in post-production using tools like ffmpeg, Remotion, or a video editor.

## Best Practices

1. **Preview avatars before generating** - Download `preview_image_url` so the user can see what the avatar looks like before committing to a video (see [avatars.md](avatars.md))
2. **Use the avatar's default voice** - Most avatars have a `default_voice_id` that is pre-matched for natural results (see [avatars.md](avatars.md))
3. **Fallback: match gender manually** - If no default voice, ensure avatar and voice genders match (see [voices.md](voices.md))
4. **Validate inputs** - Check avatar and voice IDs before generating
5. **Set generous timeouts** - Use 15-20 minutes; generation often takes 10-15 min, sometimes longer
6. **Consider async patterns** - For long videos, save `video_id` and check status later (see [video-status.md](video-status.md))
7. **Handle errors gracefully** - Check `response.ok` and parse the structured `error` object
8. **Monitor progress** - Implement polling with progress feedback
9. **Optimize scripts** - Keep scripts concise and natural
10. **Match resolution to use case** - Use `"9:16"` for mobile/social, `"16:9"` for presentations and web


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)