heygen-render.mjs
199 lines 6.3 KB
Raw
sha256:41d741fb345c4abdb640838aa3d847de02ccffd7a39fce04894e743e683b50d0 fix(security): pin patched transitive deps to clear Dependa… Human minor ⚠ breaking 7 days ago
1 /**
2 * HeyGen render bridge.
3 *
4 * Calls HeyGen v2 API to render a Custom Digital Twin avatar speaking a script with
5 * a chosen voice (typically a HeyGen voice paired to ElevenLabs).
6 *
7 * Two-step flow per HeyGen docs:
8 * 1. POST /v2/video/generate — submits the render job, returns video_id
9 * 2. GET /v1/video_status.get?video_id=... — poll until status == 'completed'
10 * (statuses: pending, processing, completed, failed)
11 *
12 * @typedef {object} HeyGenRenderArgs
13 * @property {string} script The text the avatar speaks (≤30,000 chars).
14 * @property {string} avatarId HeyGen avatar_id (the Custom Digital Twin ID).
15 * @property {string} voiceId HeyGen voice_id (paired ElevenLabs voice).
16 * @property {'1080p'|'720p'} [quality='1080p']
17 * @property {number} [pollIntervalMs=10000]
18 * @property {number} [pollTimeoutMs=1_200_000] 20 min default; HeyGen Avatar IV can take 5-10 min for 5 min content.
19 *
20 * @typedef {object} HeyGenClientOptions
21 * @property {string} apiKey
22 * @property {string} [baseUrl] Default 'https://api.heygen.com'.
23 * @property {typeof fetch} [fetch]
24 * @property {(ms:number)=>Promise<void>} [sleep] Inject for testing.
25 */
26
27 const HEYGEN_DEFAULT_BASE_URL = 'https://api.heygen.com';
28
29 /**
30 * @param {HeyGenClientOptions} opts
31 * @returns {{
32 * render: (args: HeyGenRenderArgs) => Promise<{ video_id: string, video_url: string, duration_seconds: number, status: 'completed' }>,
33 * submitRender: (args: HeyGenRenderArgs) => Promise<{ video_id: string }>,
34 * pollStatus: (videoId: string, opts?: { intervalMs?: number, timeoutMs?: number }) => Promise<{ video_id: string, video_url: string, duration_seconds: number, status: 'completed' }>,
35 * }}
36 */
37 export function createHeyGenClient(opts) {
38 const {
39 apiKey,
40 baseUrl = HEYGEN_DEFAULT_BASE_URL,
41 fetch: fetchImpl = globalThis.fetch,
42 sleep = (ms) => new Promise((r) => setTimeout(r, ms)),
43 } = opts;
44
45 if (!apiKey) throw new Error('createHeyGenClient: apiKey required');
46 if (typeof fetchImpl !== 'function') throw new Error('createHeyGenClient: fetch required');
47
48 const headers = () => ({
49 'X-Api-Key': apiKey,
50 'Content-Type': 'application/json',
51 Accept: 'application/json',
52 });
53
54 async function submitRender(args) {
55 validateRenderArgs(args);
56 const body = {
57 video_inputs: [
58 {
59 character: {
60 type: 'avatar',
61 avatar_id: args.avatarId,
62 avatar_style: 'normal',
63 },
64 voice: {
65 type: 'text',
66 input_text: args.script,
67 voice_id: args.voiceId,
68 },
69 },
70 ],
71 dimension: dimensionFor(args.quality ?? '1080p'),
72 test: false,
73 };
74
75 const res = await fetchImpl(`${baseUrl}/v2/video/generate`, {
76 method: 'POST',
77 headers: headers(),
78 body: JSON.stringify(body),
79 });
80
81 if (!res.ok) {
82 const text = await safeJson(res);
83 throw heygenError(res.status, text, 'submit');
84 }
85
86 const data = await res.json();
87 const videoId = data?.data?.video_id ?? data?.video_id;
88 if (!videoId) {
89 throw Object.assign(new Error('heygen_missing_video_id'), {
90 code: 'HEYGEN_MISSING_VIDEO_ID',
91 body: data,
92 });
93 }
94 return { video_id: String(videoId) };
95 }
96
97 async function pollStatus(videoId, options = {}) {
98 const intervalMs = options.intervalMs ?? 10_000;
99 const timeoutMs = options.timeoutMs ?? 1_200_000;
100 const start = Date.now();
101
102 while (Date.now() - start < timeoutMs) {
103 const res = await fetchImpl(
104 `${baseUrl}/v1/video_status.get?video_id=${encodeURIComponent(videoId)}`,
105 { method: 'GET', headers: headers() }
106 );
107
108 if (!res.ok) {
109 const text = await safeJson(res);
110 throw heygenError(res.status, text, 'status');
111 }
112
113 const data = await res.json();
114 const node = data?.data ?? data ?? {};
115 const status = String(node.status ?? '').toLowerCase();
116
117 if (status === 'completed') {
118 const video_url = String(node.video_url ?? '');
119 if (!video_url) {
120 throw Object.assign(new Error('heygen_completed_but_no_url'), {
121 code: 'HEYGEN_COMPLETED_BUT_NO_URL',
122 body: data,
123 });
124 }
125 return {
126 video_id: videoId,
127 video_url,
128 duration_seconds: Number(node.duration ?? 0),
129 status: 'completed',
130 };
131 }
132 if (status === 'failed') {
133 throw Object.assign(new Error(`heygen_render_failed: ${node.error ?? 'unknown'}`), {
134 code: 'HEYGEN_RENDER_FAILED',
135 body: data,
136 });
137 }
138
139 await sleep(intervalMs);
140 }
141
142 throw Object.assign(new Error(`heygen_poll_timeout after ${timeoutMs}ms`), {
143 code: 'HEYGEN_POLL_TIMEOUT',
144 videoId,
145 });
146 }
147
148 async function render(args) {
149 const { video_id } = await submitRender(args);
150 return pollStatus(video_id, {
151 intervalMs: args.pollIntervalMs,
152 timeoutMs: args.pollTimeoutMs,
153 });
154 }
155
156 return { render, submitRender, pollStatus };
157 }
158
159 function validateRenderArgs(args) {
160 if (!args || typeof args !== 'object') {
161 throw Object.assign(new Error('heygen_invalid_args'), { code: 'HEYGEN_INVALID_ARGS' });
162 }
163 if (typeof args.script !== 'string' || !args.script.trim()) {
164 throw Object.assign(new Error('heygen_invalid_script'), { code: 'HEYGEN_INVALID_SCRIPT' });
165 }
166 if (args.script.length > 30_000) {
167 throw Object.assign(new Error('heygen_script_too_long'), { code: 'HEYGEN_SCRIPT_TOO_LONG' });
168 }
169 if (typeof args.avatarId !== 'string' || !args.avatarId.trim()) {
170 throw Object.assign(new Error('heygen_invalid_avatar_id'), {
171 code: 'HEYGEN_INVALID_AVATAR_ID',
172 });
173 }
174 if (typeof args.voiceId !== 'string' || !args.voiceId.trim()) {
175 throw Object.assign(new Error('heygen_invalid_voice_id'), {
176 code: 'HEYGEN_INVALID_VOICE_ID',
177 });
178 }
179 }
180
181 function dimensionFor(quality) {
182 if (quality === '720p') return { width: 1280, height: 720 };
183 return { width: 1920, height: 1080 };
184 }
185
186 function heygenError(status, body, stage) {
187 return Object.assign(
188 new Error(`heygen_${stage}_${status}: ${body?.message || body?.error || 'request failed'}`),
189 { code: `HEYGEN_${stage.toUpperCase()}_${status}`, status, body }
190 );
191 }
192
193 async function safeJson(res) {
194 try {
195 return await res.json();
196 } catch (_e) {
197 return null;
198 }
199 }
File History 1 commit
sha256:41d741fb345c4abdb640838aa3d847de02ccffd7a39fce04894e743e683b50d0 fix(security): pin patched transitive deps to clear Dependa… Human minor 7 days ago