vargai 0.4.0-alpha100 → 0.4.0-alpha102

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -315,6 +315,13 @@ See the [BYOK docs](https://docs.varg.ai/sdk/byok) for details.
315
315
 
316
316
  A typical 3-clip video costs $2-5. Cache hits are always free.
317
317
 
318
+ ## Star History
319
+
320
+ <img width="2832" height="2253" alt="star-history-202643" src="https://github.com/user-attachments/assets/63e84279-d756-43a9-b328-118fb69ed2d5" />
321
+
322
+
323
+
324
+
318
325
  ## Contributing
319
326
 
320
327
  See [CONTRIBUTING.md](CONTRIBUTING.md) for development setup.
package/package.json CHANGED
@@ -104,7 +104,7 @@
104
104
  "license": "Apache-2.0",
105
105
  "author": "varg.ai <hello@varg.ai> (https://varg.ai)",
106
106
  "sideEffects": false,
107
- "version": "0.4.0-alpha100",
107
+ "version": "0.4.0-alpha102",
108
108
  "exports": {
109
109
  ".": "./src/index.ts",
110
110
  "./ai": "./src/ai-sdk/index.ts",
@@ -17,6 +17,8 @@ export interface FileMetadata {
17
17
  model?: string;
18
18
  /** Original prompt used */
19
19
  prompt?: string;
20
+ /** Duration in seconds (for video/audio files) */
21
+ duration?: number;
20
22
  }
21
23
 
22
24
  export class File {
@@ -158,11 +160,16 @@ export class File {
158
160
  return this._url;
159
161
  }
160
162
 
161
- /** Get file metadata (type, model, prompt) */
163
+ /** Get file metadata (type, model, prompt, duration) */
162
164
  get metadata(): FileMetadata {
163
165
  return this._metadata;
164
166
  }
165
167
 
168
+ /** Duration in seconds (for video/audio files) */
169
+ get duration(): number | undefined {
170
+ return this._metadata.duration;
171
+ }
172
+
166
173
  /** Set metadata and return this for chaining */
167
174
  withMetadata(metadata: FileMetadata): this {
168
175
  this._metadata = { ...this._metadata, ...metadata };
@@ -78,6 +78,12 @@ export {
78
78
  type GoogleProviderSettings,
79
79
  google,
80
80
  } from "./providers/google";
81
+ export {
82
+ createHeyGen,
83
+ type HeyGenProvider,
84
+ type HeyGenProviderSettings,
85
+ heygen,
86
+ } from "./providers/heygen";
81
87
  export {
82
88
  createHiggsfield,
83
89
  type HiggsfieldImageModelSettings,
@@ -99,12 +99,34 @@ async function processClips(
99
99
  let duration = clip.duration ?? defaultDuration;
100
100
 
101
101
  for (const layer of layers) {
102
- if (layer.type === "video" && !clip.duration) {
102
+ if (layer.type === "video") {
103
103
  const videoLayer = layer as VideoLayer;
104
- const videoDuration = await getVideoDuration(videoLayer.path, backend);
104
+
105
+ // Use pre-propagated duration when available (avoids ffprobe HTTP
106
+ // round-trip for remote URLs). Fall back to ffprobe otherwise.
107
+ const videoDuration =
108
+ videoLayer.sourceDuration ??
109
+ (await getVideoDuration(videoLayer.path, backend));
110
+
105
111
  const cutFrom = videoLayer.cutFrom ?? 0;
106
- const cutTo = videoLayer.cutTo ?? videoDuration;
107
- duration = cutTo - cutFrom;
112
+ const cutTo = Math.min(
113
+ videoLayer.cutTo ?? videoDuration,
114
+ videoDuration,
115
+ );
116
+
117
+ // Clamp the layer's cutTo so the FFmpeg trim filter also respects
118
+ // the actual source duration (prevents freeze frames at the tail)
119
+ videoLayer.cutTo = cutTo;
120
+
121
+ const effectiveDuration = cutTo - cutFrom;
122
+ if (!clip.duration) {
123
+ // No explicit duration — derive from the video layer
124
+ duration = effectiveDuration;
125
+ } else if (effectiveDuration < duration) {
126
+ // Explicit duration exceeds actual video length — clamp to avoid
127
+ // freeze frames and xfade offset misalignment
128
+ duration = effectiveDuration;
129
+ }
108
130
  break;
109
131
  }
110
132
  }
@@ -117,6 +117,8 @@ export interface VideoLayer extends BaseLayer {
117
117
  cropPosition?: CropPosition;
118
118
  cutFrom?: number;
119
119
  cutTo?: number;
120
+ /** Known source video duration in seconds (avoids ffprobe when set). */
121
+ sourceDuration?: number;
120
122
  width?: SizeValue;
121
123
  height?: SizeValue;
122
124
  left?: SizeValue;
@@ -21,16 +21,30 @@ import type { MusicModelV3, MusicModelV3CallOptions } from "../music-model";
21
21
  * call the gateway's GET /v1/voices endpoint to browse/search.
22
22
  */
23
23
  const VOICES: Record<string, string> = {
24
- rachel: "21m00Tcm4TlvDq8ikWAM",
25
- domi: "AZnzlk1XvdvUeBnXmlld",
26
- sarah: "EXAVITQu4vr4xnSDxMaL",
27
- bella: "EXAVITQu4vr4xnSDxMaL", // alias — ElevenLabs calls this voice "Sarah"
28
- antoni: "ErXwobaYiN019PkySvjV",
29
- elli: "MF3mGyEYCl7XYWbV9V6O",
30
- josh: "TxGEqnHWrfWFTfGW9XjX",
31
- arnold: "VR6AewLTigWG4xSOukaG",
24
+ // Current ElevenLabs premade voices (source: skills/varg-ai/references/models.md)
32
25
  adam: "pNInz6obpgDQGcFmaJgB",
33
- sam: "yoZ06aMxZJJ28mfd3POQ",
26
+ alice: "Xb7hH8MSUJpSbSDYk0k2",
27
+ bella: "hpp4J3VqNfWAUOO0d1Us",
28
+ bill: "pqHfZKP75CvOlQylNhV4",
29
+ brian: "nPczCjzI2devNBz1zQrb",
30
+ callum: "N2lVS1w4EtoT3dr4eOWO",
31
+ charlie: "IKne3meq5aSn9XLyUdCD",
32
+ chris: "iP95p4xoKVk53GoZ742B",
33
+ daniel: "onwK4e9ZLuTAKqWW03F9",
34
+ eric: "cjVigY5qzO86Huf0OWal",
35
+ george: "JBFqnCBsd6RMkjVDRZzb",
36
+ harry: "SOYHLrjzK2X1ezoPC6cr",
37
+ jessica: "cgSgspJ2msm6clMCkdW9",
38
+ laura: "FGY2WhTYpPnrIDTdsKH5",
39
+ liam: "TX3LPaxmHKxFdv7VOQHJ",
40
+ lily: "pFZP5JQG7iQjIQuC4Bku",
41
+ matilda: "XrExE9yKIg1WjnnlVkGX",
42
+ river: "SAz9YHcvj6GT2YYXdXww",
43
+ roger: "CwhRBWXzGAHq8TQ4Fs17",
44
+ sarah: "EXAVITQu4vr4xnSDxMaL",
45
+ will: "bIHbv24MWmeRgasZH58o",
46
+ // Legacy
47
+ rachel: "21m00Tcm4TlvDq8ikWAM",
34
48
  };
35
49
 
36
50
  const TTS_MODELS: Record<string, string> = {
@@ -15,6 +15,7 @@ import pMap from "p-map";
15
15
  import type { CacheStorage } from "../cache";
16
16
  import { fileCache } from "../file-cache";
17
17
  import type { VideoModelV3, VideoModelV3CallOptions } from "../video-model";
18
+ import { normalizeProviderInput } from "./model-rules";
18
19
 
19
20
  interface PendingRequest {
20
21
  request_id: string;
@@ -640,35 +641,22 @@ class FalVideoModel implements VideoModelV3 {
640
641
  if (input.video_size === undefined) {
641
642
  input.video_size = "auto";
642
643
  }
643
- } else if (isKlingV3 || isKlingV26) {
644
- // Duration must be string for Kling v2.6+ and O3 (v3)
645
- input.duration = String(duration ?? 5);
646
- } else if (isGrokImagine) {
647
- // Grok Imagine: duration 1-15 seconds (default 6)
648
- input.duration = duration ?? 6;
649
- // Grok Imagine supports resolution: "480p", "720p" (default "720p")
650
- if (!input.resolution) {
651
- input.resolution = "720p";
652
- }
653
- } else if (isSora2) {
654
- // Sora 2: only supports 4, 8, 12, 16, 20 second durations
655
- const allowedDurations = [4, 8, 12, 16, 20];
656
- const d = duration ?? 4;
657
- if (!allowedDurations.includes(d)) {
658
- warnings.push({
659
- type: "other",
660
- message: `Sora 2 only supports durations: ${allowedDurations.join(", ")}s. Got ${d}s, defaulting to 4s.`,
661
- });
662
- input.duration = 4;
663
- } else {
664
- input.duration = d;
665
- }
666
- // Disable video deletion so generated video URLs remain accessible
667
- if (input.delete_video === undefined) {
668
- input.delete_video = false;
669
- }
670
644
  } else {
671
- input.duration = duration ?? 5;
645
+ // Apply model-specific duration normalization via Zod schemas
646
+ // (clamp to valid range, round floats, convert type e.g. number → string for Kling v3)
647
+ const normalized = normalizeProviderInput(this.modelId, { duration });
648
+ input.duration = normalized.duration;
649
+
650
+ // Model-specific non-duration defaults
651
+ if (isGrokImagine) {
652
+ if (!input.resolution) {
653
+ input.resolution = "720p";
654
+ }
655
+ } else if (isSora2) {
656
+ if (input.delete_video === undefined) {
657
+ input.delete_video = false;
658
+ }
659
+ }
672
660
  }
673
661
 
674
662
  if (hasImageInput && files) {
@@ -0,0 +1,436 @@
1
+ /**
2
+ * HeyGen AI SDK provider for avatar video generation.
3
+ *
4
+ * Exposes heygen.videoModel("avatar-iv") for use in JSX composition:
5
+ *
6
+ * import { heygen } from "vargai/ai-sdk";
7
+ *
8
+ * const talking = Video({
9
+ * prompt: { text: "Hello world", images: [portrait] },
10
+ * model: heygen.videoModel("avatar-iv"),
11
+ * providerOptions: {
12
+ * heygen: { voice_id: "abc123", expressiveness: "medium" }
13
+ * },
14
+ * });
15
+ */
16
+
17
+ import {
18
+ type EmbeddingModelV3,
19
+ type ImageModelV3,
20
+ type LanguageModelV3,
21
+ NoSuchModelError,
22
+ type ProviderV3,
23
+ type SharedV3Warning,
24
+ type SpeechModelV3,
25
+ } from "@ai-sdk/provider";
26
+ import type {
27
+ VideoModelV3,
28
+ VideoModelV3CallOptions,
29
+ VideoModelV3File,
30
+ } from "../video-model";
31
+
32
+ const HEYGEN_API_BASE = "https://api.heygen.com";
33
+ const HEYGEN_UPLOAD_BASE = "https://upload.heygen.com";
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // HeyGen response types
37
+ // ---------------------------------------------------------------------------
38
+
39
+ interface HeyGenVideoStatusData {
40
+ id: string;
41
+ status: string;
42
+ video_url?: string;
43
+ duration?: number;
44
+ error?: string | null;
45
+ }
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Helpers
49
+ // ---------------------------------------------------------------------------
50
+
51
+ function getMediaType(file: VideoModelV3File): string | undefined {
52
+ if ("mediaType" in file && file.mediaType) return file.mediaType;
53
+ return undefined;
54
+ }
55
+
56
+ async function fileToBytes(file: VideoModelV3File): Promise<Uint8Array> {
57
+ if ("data" in file) {
58
+ if (file.data instanceof Uint8Array) return file.data;
59
+ if (typeof file.data === "string") return Buffer.from(file.data, "base64");
60
+ }
61
+ throw new Error("HeyGen: file has no data");
62
+ }
63
+
64
+ /**
65
+ * Upload a file to HeyGen's asset endpoint and return the asset_id.
66
+ */
67
+ async function uploadAssetToHeyGen(
68
+ apiKey: string,
69
+ data: Uint8Array,
70
+ contentType: string,
71
+ ): Promise<string> {
72
+ const res = await fetch(`${HEYGEN_UPLOAD_BASE}/v1/asset`, {
73
+ method: "POST",
74
+ headers: {
75
+ "X-Api-Key": apiKey,
76
+ "Content-Type": contentType,
77
+ },
78
+ body: data,
79
+ });
80
+
81
+ if (!res.ok) {
82
+ const errorText = await res.text();
83
+ throw new Error(`HeyGen asset upload failed (${res.status}): ${errorText}`);
84
+ }
85
+
86
+ const json = (await res.json()) as {
87
+ data?: { id?: string };
88
+ };
89
+ const assetId = json.data?.id;
90
+ if (!assetId) throw new Error("HeyGen asset upload returned no asset id");
91
+ return assetId;
92
+ }
93
+
94
+ /**
95
+ * Upload an image as a HeyGen talking photo and return the talking_photo_id.
96
+ * This allows any image to be used as a character in Studio V2 videos.
97
+ */
98
+ async function uploadTalkingPhoto(
99
+ apiKey: string,
100
+ data: Uint8Array,
101
+ contentType: string,
102
+ ): Promise<string> {
103
+ const res = await fetch(`${HEYGEN_UPLOAD_BASE}/v1/talking_photo`, {
104
+ method: "POST",
105
+ headers: {
106
+ "X-Api-Key": apiKey,
107
+ "Content-Type": contentType,
108
+ },
109
+ body: data,
110
+ });
111
+
112
+ if (!res.ok) {
113
+ const errorText = await res.text();
114
+ throw new Error(
115
+ `HeyGen talking photo upload failed (${res.status}): ${errorText}`,
116
+ );
117
+ }
118
+
119
+ const json = (await res.json()) as {
120
+ data?: { talking_photo_id?: string };
121
+ };
122
+ const talkingPhotoId = json.data?.talking_photo_id;
123
+ if (!talkingPhotoId)
124
+ throw new Error("HeyGen talking photo upload returned no talking_photo_id");
125
+ return talkingPhotoId;
126
+ }
127
+
128
+ /**
129
+ * Build the `background` object for a Studio V2 scene.
130
+ * Accepts a URL string (image), a hex color, or a structured object.
131
+ */
132
+ function buildBackground(bg: unknown): Record<string, unknown> | undefined {
133
+ if (!bg) return undefined;
134
+ if (typeof bg === "string") {
135
+ if (bg.startsWith("#")) return { type: "color", value: bg };
136
+ return { type: "image", url: bg, fit: "cover" };
137
+ }
138
+ if (typeof bg === "object") return bg as Record<string, unknown>;
139
+ return undefined;
140
+ }
141
+
142
+ /**
143
+ * Poll HeyGen video status until completed or failed.
144
+ */
145
+ async function pollVideoStatus(
146
+ apiKey: string,
147
+ videoId: string,
148
+ signal?: AbortSignal,
149
+ ): Promise<HeyGenVideoStatusData> {
150
+ const maxWait = 600_000; // 10 minutes
151
+ const pollInterval = 5_000; // 5 seconds
152
+ const start = Date.now();
153
+
154
+ while (Date.now() - start < maxWait) {
155
+ if (signal?.aborted) throw new Error("HeyGen: aborted");
156
+
157
+ const res = await fetch(
158
+ `${HEYGEN_API_BASE}/v1/video_status.get?video_id=${videoId}`,
159
+ {
160
+ headers: {
161
+ "X-Api-Key": apiKey,
162
+ Accept: "application/json",
163
+ },
164
+ signal,
165
+ },
166
+ );
167
+
168
+ if (!res.ok) {
169
+ throw new Error(`HeyGen status check failed (${res.status})`);
170
+ }
171
+
172
+ const body = (await res.json()) as {
173
+ data?: HeyGenVideoStatusData;
174
+ };
175
+ const status = body.data?.status?.toLowerCase();
176
+
177
+ if (status === "completed") {
178
+ if (!body.data?.video_url) {
179
+ throw new Error("HeyGen video completed but no video_url in response");
180
+ }
181
+ return body.data;
182
+ }
183
+
184
+ if (status === "failed") {
185
+ throw new Error(
186
+ `HeyGen video generation failed: ${body.data?.error ?? "unknown error"}`,
187
+ );
188
+ }
189
+
190
+ await new Promise((resolve) => setTimeout(resolve, pollInterval));
191
+ }
192
+
193
+ throw new Error(`HeyGen video generation timed out after ${maxWait / 1000}s`);
194
+ }
195
+
196
+ // ---------------------------------------------------------------------------
197
+ // Video model
198
+ // ---------------------------------------------------------------------------
199
+
200
+ class HeyGenVideoModel implements VideoModelV3 {
201
+ readonly specificationVersion = "v3" as const;
202
+ readonly provider = "heygen";
203
+ readonly modelId: string;
204
+ readonly maxVideosPerCall = 1;
205
+
206
+ private apiKey: string;
207
+
208
+ constructor(modelId: string, apiKey: string) {
209
+ this.modelId = modelId;
210
+ this.apiKey = apiKey;
211
+ }
212
+
213
+ async doGenerate(options: VideoModelV3CallOptions) {
214
+ const { prompt, files, providerOptions, abortSignal } = options;
215
+ const warnings: SharedV3Warning[] = [];
216
+
217
+ const heygenOpts = (providerOptions?.heygen ?? {}) as Record<
218
+ string,
219
+ unknown
220
+ >;
221
+
222
+ // ---- Resolve character source ----
223
+ const avatarId = heygenOpts.avatar_id as string | undefined;
224
+ const talkingPhotoId = heygenOpts.talking_photo_id as string | undefined;
225
+ const voiceId = heygenOpts.voice_id as string | undefined;
226
+
227
+ // If an image file is provided and no avatar/talking_photo specified,
228
+ // upload it as a talking photo for use in Studio V2
229
+ let resolvedTalkingPhotoId = talkingPhotoId;
230
+ if (!avatarId && !talkingPhotoId) {
231
+ const imageFile = files?.find((f) =>
232
+ getMediaType(f)?.startsWith("image/"),
233
+ );
234
+ if (imageFile) {
235
+ const bytes = await fileToBytes(imageFile);
236
+ const contentType = getMediaType(imageFile) ?? "image/jpeg";
237
+ resolvedTalkingPhotoId = await uploadTalkingPhoto(
238
+ this.apiKey,
239
+ bytes,
240
+ contentType,
241
+ );
242
+ }
243
+ }
244
+
245
+ // Upload audio file if present (external audio mode)
246
+ let audioAssetId: string | undefined;
247
+ const audioFile = files?.find((f) => getMediaType(f)?.startsWith("audio/"));
248
+ if (audioFile) {
249
+ const audioBytes = await fileToBytes(audioFile);
250
+ const audioContentType = getMediaType(audioFile) ?? "audio/mpeg";
251
+ audioAssetId = await uploadAssetToHeyGen(
252
+ this.apiKey,
253
+ audioBytes,
254
+ audioContentType,
255
+ );
256
+ }
257
+
258
+ if (prompt && voiceId === undefined && !audioFile) {
259
+ warnings.push({
260
+ type: "other",
261
+ message:
262
+ "HeyGen requires voice_id when using script mode. Pass it via providerOptions.heygen.voice_id",
263
+ });
264
+ }
265
+
266
+ // ---- Always use Studio V2 (POST /v2/video/generate) ----
267
+ // Works for both pre-registered avatars and uploaded talking photos.
268
+
269
+ // Build character object
270
+ const character: Record<string, unknown> = {};
271
+ if (avatarId) {
272
+ character.type = "avatar";
273
+ character.avatar_id = avatarId;
274
+ character.avatar_style = (heygenOpts.avatar_style as string) ?? "normal";
275
+ } else if (resolvedTalkingPhotoId) {
276
+ character.type = "talking_photo";
277
+ character.talking_photo_id = resolvedTalkingPhotoId;
278
+ if (heygenOpts.talking_style)
279
+ character.talking_style = heygenOpts.talking_style;
280
+ if (heygenOpts.use_avatar_iv_model)
281
+ character.use_avatar_iv_model = heygenOpts.use_avatar_iv_model;
282
+ if (heygenOpts.matting) character.matting = heygenOpts.matting;
283
+ }
284
+
285
+ // Build voice object
286
+ const voice: Record<string, unknown> = {};
287
+ if (audioAssetId) {
288
+ voice.type = "audio";
289
+ voice.audio_asset_id = audioAssetId;
290
+ } else if (prompt && voiceId) {
291
+ voice.type = "text";
292
+ voice.input_text = prompt;
293
+ voice.voice_id = voiceId;
294
+ if (heygenOpts.speed) voice.speed = heygenOpts.speed;
295
+ if (heygenOpts.emotion) voice.emotion = heygenOpts.emotion;
296
+ }
297
+
298
+ // Build background object
299
+ const background = buildBackground(heygenOpts.background);
300
+
301
+ // Build scene
302
+ const scene: Record<string, unknown> = { character, voice };
303
+ if (background) scene.background = background;
304
+
305
+ // Aspect ratio → dimension
306
+ const aspectRatio =
307
+ (heygenOpts.aspect_ratio as string | undefined) ?? options.aspectRatio;
308
+ const dim =
309
+ aspectRatio === "9:16"
310
+ ? { width: 720, height: 1280 }
311
+ : { width: 1280, height: 720 };
312
+
313
+ const studioPayload: Record<string, unknown> = {
314
+ video_inputs: [scene],
315
+ dimension: dim,
316
+ };
317
+ if (heygenOpts.callback_url)
318
+ studioPayload.callback_url = heygenOpts.callback_url;
319
+ if (heygenOpts.title) studioPayload.title = heygenOpts.title;
320
+ if (heygenOpts.caption) studioPayload.caption = heygenOpts.caption;
321
+
322
+ const submitUrl = `${HEYGEN_API_BASE}/v2/video/generate`;
323
+ const submitBody = JSON.stringify(studioPayload);
324
+
325
+ // ---- Submit ----
326
+ const submitRes = await fetch(submitUrl, {
327
+ method: "POST",
328
+ headers: {
329
+ "X-Api-Key": this.apiKey,
330
+ "Content-Type": "application/json",
331
+ Accept: "application/json",
332
+ },
333
+ body: submitBody,
334
+ signal: abortSignal,
335
+ });
336
+
337
+ if (!submitRes.ok) {
338
+ const errorText = await submitRes.text();
339
+ throw new Error(
340
+ `HeyGen video generation failed (${submitRes.status}): ${errorText}`,
341
+ );
342
+ }
343
+
344
+ const submitData = (await submitRes.json()) as {
345
+ data?: { video_id?: string };
346
+ video_id?: string;
347
+ };
348
+ const videoId = submitData.data?.video_id ?? submitData.video_id;
349
+ if (!videoId) throw new Error("HeyGen returned no video_id");
350
+
351
+ // ---- Poll for completion ----
352
+ const statusData = await pollVideoStatus(this.apiKey, videoId, abortSignal);
353
+
354
+ // ---- Download video ----
355
+ const videoRes = await fetch(statusData.video_url!, {
356
+ signal: abortSignal,
357
+ });
358
+ if (!videoRes.ok) {
359
+ throw new Error(`Failed to download HeyGen video (${videoRes.status})`);
360
+ }
361
+ const videoBytes = new Uint8Array(await videoRes.arrayBuffer());
362
+
363
+ return {
364
+ videos: [videoBytes],
365
+ warnings,
366
+ response: {
367
+ timestamp: new Date(),
368
+ modelId: this.modelId,
369
+ headers: undefined,
370
+ },
371
+ };
372
+ }
373
+ }
374
+
375
+ // ---------------------------------------------------------------------------
376
+ // Provider factory
377
+ // ---------------------------------------------------------------------------
378
+
379
+ export interface HeyGenProviderSettings {
380
+ apiKey?: string;
381
+ }
382
+
383
+ export interface HeyGenProvider extends ProviderV3 {
384
+ videoModel(modelId?: string): VideoModelV3;
385
+ }
386
+
387
+ export function createHeyGen(
388
+ settings: HeyGenProviderSettings = {},
389
+ ): HeyGenProvider {
390
+ const apiKey = settings.apiKey ?? process.env.HEYGEN_API_KEY;
391
+ if (!apiKey) {
392
+ throw new Error("HEYGEN_API_KEY not set");
393
+ }
394
+
395
+ return {
396
+ specificationVersion: "v3",
397
+ videoModel(modelId = "avatar-iv") {
398
+ return new HeyGenVideoModel(modelId, apiKey);
399
+ },
400
+ languageModel(modelId: string): LanguageModelV3 {
401
+ throw new NoSuchModelError({
402
+ modelId,
403
+ modelType: "languageModel",
404
+ });
405
+ },
406
+ embeddingModel(modelId: string): EmbeddingModelV3 {
407
+ throw new NoSuchModelError({
408
+ modelId,
409
+ modelType: "embeddingModel",
410
+ });
411
+ },
412
+ imageModel(modelId: string): ImageModelV3 {
413
+ throw new NoSuchModelError({
414
+ modelId,
415
+ modelType: "imageModel",
416
+ });
417
+ },
418
+ speechModel(modelId: string): SpeechModelV3 {
419
+ throw new NoSuchModelError({
420
+ modelId,
421
+ modelType: "speechModel",
422
+ });
423
+ },
424
+ };
425
+ }
426
+
427
+ // Lazy singleton (same pattern as elevenlabs)
428
+ let _heygen: HeyGenProvider | undefined;
429
+ export const heygen = new Proxy({} as HeyGenProvider, {
430
+ get(_, prop) {
431
+ if (!_heygen) {
432
+ _heygen = createHeyGen();
433
+ }
434
+ return _heygen[prop as keyof HeyGenProvider];
435
+ },
436
+ });