@juspay/neurolink
Version:
Universal AI Development Platform with working MCP integration, multi-provider support, voice (TTS/STT/realtime), and professional CLI. 58+ external MCP servers discoverable, multimodal file processing, RAG pipelines. Build, test, and deploy AI applicatio
338 lines • 14.3 kB
JavaScript
/**
* HeyGen Avatar Handler
*
* Async talking-head generation. Submits a video.generate request, polls
* the video status, downloads the result MP4.
*
* @module avatar/providers/HeyGenAvatar
* @see https://docs.heygen.com/reference/avatar-video
*/
import { ErrorCategory, ErrorSeverity } from "../../constants/enums.js";
import { AVATAR_ERROR_CODES, AvatarError, } from "../../utils/avatarProcessor.js";
import { logger } from "../../utils/logger.js";
import { sanitizeForLog } from "../../utils/logSanitize.js";
import { safeDownload } from "../../utils/safeFetch.js";
import { MAX_VIDEO_BYTES } from "../../utils/sizeGuard.js";
const DEFAULT_BASE_URL = "https://api.heygen.com/v2";
const REQUEST_TIMEOUT_MS = 30_000;
const POLL_INTERVAL_MS = 5_000;
const TOTAL_TIMEOUT_MS = 5 * 60_000;
/**
* HeyGen Avatar Handler.
*
* Auth: `X-API-Key: ${HEYGEN_API_KEY}`. The HeyGen API expects an
* `avatar_id` (HeyGen's own avatar catalog) — pass it via `options.voice`
* for legacy callers, or `options.avatarId` for explicit users.
*/
export class HeyGenAvatar {
maxAudioDurationSeconds = 300; // 5 minutes
supportedFormats = ["mp4"];
apiKey;
baseUrl;
constructor(apiKey) {
const resolved = (apiKey ?? process.env.HEYGEN_API_KEY ?? "").trim();
this.apiKey = resolved.length > 0 ? resolved : null;
this.baseUrl = (process.env.HEYGEN_BASE_URL ?? DEFAULT_BASE_URL).replace(/\/$/, "");
}
isConfigured() {
return this.apiKey !== null;
}
async generate(options) {
if (!this.apiKey) {
throw new AvatarError({
code: AVATAR_ERROR_CODES.PROVIDER_NOT_CONFIGURED,
message: "HEYGEN_API_KEY not configured",
category: ErrorCategory.CONFIGURATION,
severity: ErrorSeverity.HIGH,
retriable: false,
});
}
if (!options.text && !options.audio) {
throw new AvatarError({
code: AVATAR_ERROR_CODES.AUDIO_REQUIRED,
message: "HeyGen requires either `text` or `audio` to drive the avatar",
category: ErrorCategory.VALIDATION,
severity: ErrorSeverity.MEDIUM,
retriable: false,
});
}
const startTime = Date.now();
const abortSignal = options.abortSignal;
const videoId = await this.submitVideo(options);
const videoUrl = await this.pollUntilComplete(videoId, abortSignal);
const buffer = await this.download(videoUrl);
const latency = Date.now() - startTime;
logger.info(`[HeyGenAvatar] Generated ${buffer.length} bytes in ${latency}ms — video ${videoId}`);
return {
buffer,
format: "mp4",
size: buffer.length,
provider: "heygen",
metadata: {
latency,
provider: "heygen",
jobId: videoId,
},
};
}
async submitVideo(options) {
const heyOpts = options;
const avatarId = heyOpts.avatarId ??
(typeof options.image === "string" &&
/^[a-zA-Z0-9_-]{20,}$/.test(options.image)
? options.image // use image string as avatar_id when it looks like one
: undefined);
if (!avatarId) {
throw new AvatarError({
code: AVATAR_ERROR_CODES.INVALID_INPUT,
message: "HeyGen requires `avatarId` (HeyGen avatar catalog id). Pass via options.avatarId or as options.image with a valid HeyGen id.",
category: ErrorCategory.VALIDATION,
severity: ErrorSeverity.MEDIUM,
retriable: false,
});
}
if (options.audio !== undefined) {
if (Buffer.isBuffer(options.audio)) {
throw new AvatarError({
code: AVATAR_ERROR_CODES.INVALID_INPUT,
message: "HeyGen requires a publicly accessible audio URL; got a binary Buffer. Upload the audio to a hosted location and pass the HTTPS URL instead.",
category: ErrorCategory.VALIDATION,
severity: ErrorSeverity.MEDIUM,
retriable: false,
});
}
if (typeof options.audio !== "string" ||
!/^https?:\/\//i.test(options.audio)) {
throw new AvatarError({
code: AVATAR_ERROR_CODES.INVALID_INPUT,
message: "HeyGen requires a publicly accessible HTTPS audio URL; got an unsupported audio input type. Upload the audio to a hosted location and pass the HTTPS URL instead.",
category: ErrorCategory.VALIDATION,
severity: ErrorSeverity.MEDIUM,
retriable: false,
});
}
}
const voiceConfig = options.audio
? {
type: "audio",
audio_url: options.audio,
}
: {
type: "text",
input_text: options.text,
voice_id: options.voice ?? "1bd001e7e50f421d891986aad5158bc8",
};
const body = {
video_inputs: [
{
character: {
type: "avatar",
avatar_id: avatarId,
avatar_style: "normal",
},
voice: voiceConfig,
background: {
type: "color",
value: heyOpts.backgroundColor ?? "#FFFFFF",
},
},
],
dimension: {
width: heyOpts.width ?? 1280,
height: heyOpts.height ?? 720,
},
};
const response = await this.fetchWithTimeout(`${this.baseUrl}/video/generate`, {
method: "POST",
headers: {
"X-API-Key": this.apiKey,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
if (!response.ok) {
const raw = await response.text();
const retriable = response.status === 408 ||
response.status === 429 ||
response.status >= 500;
throw new AvatarError({
code: AVATAR_ERROR_CODES.GENERATION_FAILED,
message: `HeyGen submit failed: ${response.status} — ${sanitizeForLog(raw, 500)}`,
category: retriable ? ErrorCategory.NETWORK : ErrorCategory.EXECUTION,
severity: ErrorSeverity.HIGH,
retriable,
context: { status: response.status },
});
}
const json = (await response.json());
const videoId = json.data?.video_id;
if (!videoId) {
throw new AvatarError({
code: AVATAR_ERROR_CODES.GENERATION_FAILED,
message: "HeyGen submit response missing video_id",
category: ErrorCategory.EXECUTION,
severity: ErrorSeverity.HIGH,
retriable: false,
context: { response: json },
});
}
return videoId;
}
async pollUntilComplete(videoId, abortSignal) {
const startTime = Date.now();
// HeyGen status endpoint is on v1, not v2.
const statusBaseUrl = this.baseUrl.replace(/\/v2$/, "/v1");
while (Date.now() - startTime < TOTAL_TIMEOUT_MS) {
if (abortSignal?.aborted) {
throw new AvatarError({
code: AVATAR_ERROR_CODES.GENERATION_FAILED,
message: `HeyGen poll for video ${videoId} aborted by caller`,
category: ErrorCategory.NETWORK,
severity: ErrorSeverity.MEDIUM,
retriable: false,
context: { videoId },
});
}
const response = await this.fetchWithTimeout(`${statusBaseUrl}/video_status.get?video_id=${videoId}`, {
method: "GET",
headers: { "X-API-Key": this.apiKey },
}, abortSignal);
if (!response.ok) {
const raw = await response.text();
throw new AvatarError({
code: AVATAR_ERROR_CODES.GENERATION_FAILED,
message: `HeyGen poll failed: ${response.status} — ${sanitizeForLog(raw, 500)}`,
category: ErrorCategory.NETWORK,
severity: ErrorSeverity.MEDIUM,
retriable: response.status >= 500,
context: { status: response.status, videoId },
});
}
const data = (await response.json());
if (data.data?.status === "completed") {
const videoUrl = data.data.video_url;
if (!videoUrl) {
throw new AvatarError({
code: AVATAR_ERROR_CODES.GENERATION_FAILED,
message: `HeyGen video ${videoId} completed but no URL returned`,
category: ErrorCategory.EXECUTION,
severity: ErrorSeverity.HIGH,
retriable: false,
context: { videoId, data },
});
}
return videoUrl;
}
if (data.data?.status === "failed") {
throw new AvatarError({
code: AVATAR_ERROR_CODES.GENERATION_FAILED,
message: `HeyGen video ${videoId} failed: ${data.data?.error?.message ?? "unknown"}`,
category: ErrorCategory.EXECUTION,
severity: ErrorSeverity.HIGH,
retriable: false,
context: { videoId, data },
});
}
// Abortable sleep.
await new Promise((resolve, reject) => {
const onAbort = () => {
clearTimeout(timer);
reject(new AvatarError({
code: AVATAR_ERROR_CODES.GENERATION_FAILED,
message: `HeyGen poll for video ${videoId} aborted by caller`,
category: ErrorCategory.NETWORK,
severity: ErrorSeverity.MEDIUM,
retriable: false,
context: { videoId },
}));
};
const timer = setTimeout(() => {
abortSignal?.removeEventListener("abort", onAbort);
resolve();
}, POLL_INTERVAL_MS);
abortSignal?.addEventListener("abort", onAbort, { once: true });
});
}
throw new AvatarError({
code: AVATAR_ERROR_CODES.POLL_TIMEOUT,
message: `HeyGen video ${videoId} did not complete within ${TOTAL_TIMEOUT_MS / 1000}s`,
category: ErrorCategory.TIMEOUT,
severity: ErrorSeverity.MEDIUM,
retriable: true,
context: { videoId },
});
}
async download(url) {
try {
return await safeDownload(url, {
maxBytes: MAX_VIDEO_BYTES,
label: "HeyGen video",
timeoutMs: REQUEST_TIMEOUT_MS,
});
}
catch (err) {
throw new AvatarError({
code: AVATAR_ERROR_CODES.GENERATION_FAILED,
message: `HeyGen video download rejected: ${err instanceof Error ? err.message : String(err)}`,
category: ErrorCategory.NETWORK,
severity: ErrorSeverity.HIGH,
retriable: false,
context: { url },
originalError: err instanceof Error ? err : undefined,
});
}
}
async fetchWithTimeout(url, init, callerAbortSignal) {
const controller = new AbortController();
let timedOut = false;
const timeoutId = setTimeout(() => {
timedOut = true;
controller.abort();
}, REQUEST_TIMEOUT_MS);
const onCallerAbort = () => controller.abort();
callerAbortSignal?.addEventListener("abort", onCallerAbort, { once: true });
try {
return await fetch(url, { ...init, signal: controller.signal });
}
catch (err) {
if (err instanceof Error && err.name === "AbortError") {
// Check caller abort first — a cancelled request is not a timeout.
if (callerAbortSignal?.aborted) {
throw new AvatarError({
code: AVATAR_ERROR_CODES.GENERATION_FAILED,
message: `HeyGen request to ${url} was aborted by the caller`,
category: ErrorCategory.NETWORK,
severity: ErrorSeverity.MEDIUM,
retriable: false,
originalError: err,
});
}
if (timedOut) {
throw new AvatarError({
code: AVATAR_ERROR_CODES.GENERATION_FAILED,
message: `HeyGen request to ${url} timed out after ${REQUEST_TIMEOUT_MS / 1000}s`,
category: ErrorCategory.NETWORK,
severity: ErrorSeverity.HIGH,
retriable: true,
originalError: err,
});
}
// Generic abort (shouldn't happen, but surface it).
throw new AvatarError({
code: AVATAR_ERROR_CODES.GENERATION_FAILED,
message: `HeyGen request to ${url} was aborted`,
category: ErrorCategory.NETWORK,
severity: ErrorSeverity.HIGH,
retriable: true,
originalError: err,
});
}
throw err;
}
finally {
callerAbortSignal?.removeEventListener("abort", onCallerAbort);
clearTimeout(timeoutId);
}
}
}
//# sourceMappingURL=HeyGenAvatar.js.map