realtime-avatar 0.5.2 → 0.5.4

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.
@@ -0,0 +1,678 @@
1
+ // ../http-client/src/errors.ts
2
+ var RealtimeAvatarError = class extends Error {
3
+ constructor(message, options) {
4
+ super(message, options);
5
+ this.name = "RealtimeAvatarError";
6
+ }
7
+ };
8
+ var RealtimeAvatarHttpError = class extends RealtimeAvatarError {
9
+ status;
10
+ code;
11
+ body;
12
+ constructor(status, code, body) {
13
+ super(`Realtime Avatar API ${status}${code ? ` (${code})` : ""}: ${body || "no body"}`);
14
+ this.name = "RealtimeAvatarHttpError";
15
+ this.status = status;
16
+ this.code = code;
17
+ this.body = body;
18
+ }
19
+ /** Out of credits, or over this key's spend limit. Surface a paywall, not an error. */
20
+ get isBilling() {
21
+ return this.status === 402;
22
+ }
23
+ };
24
+
25
+ // ../http-client/src/retry.ts
26
+ var RETRYABLE_STATUS = /* @__PURE__ */ new Set([408, 500, 502, 503, 504]);
27
+ var MUTATING = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
28
+ function newIdempotencyKey() {
29
+ const c = globalThis.crypto;
30
+ if (c?.randomUUID) return c.randomUUID();
31
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
32
+ }
33
+ function backoffMs(attempt, retryAfter) {
34
+ const server = retryAfter ? Number(retryAfter) * 1e3 : NaN;
35
+ if (Number.isFinite(server) && server >= 0) return Math.min(server, 2e4);
36
+ return Math.random() * Math.min(500 * 2 ** attempt, 8e3);
37
+ }
38
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
39
+ function isTransient(cause) {
40
+ const name = cause?.name;
41
+ return name === "TimeoutError" || name === "TypeError" || name === "FetchError";
42
+ }
43
+
44
+ // ../http-client/src/client.ts
45
+ var DEFAULT_BASE_URL = "https://realtimeavatar.ai/api/v1";
46
+ var SDK_VERSION = "0.5.4";
47
+ var RealtimeAvatar = class {
48
+ #apiKey;
49
+ #baseUrl;
50
+ #fetch;
51
+ #timeoutMs;
52
+ #maxRetries;
53
+ #userAgent;
54
+ constructor(options) {
55
+ if (typeof document !== "undefined") {
56
+ throw new RealtimeAvatarError(
57
+ "RealtimeAvatar is server-only \u2014 it holds your API key. Call it from your backend and hand the browser only the connection payload it returns."
58
+ );
59
+ }
60
+ if (!options.apiKey) throw new RealtimeAvatarError("apiKey is required");
61
+ this.#apiKey = options.apiKey;
62
+ this.#baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
63
+ this.#fetch = options.fetch ?? ((input, init) => globalThis.fetch(input, init));
64
+ this.#timeoutMs = options.timeoutMs ?? 6e4;
65
+ this.#maxRetries = Math.max(0, options.maxRetries ?? 2);
66
+ this.#userAgent = [`realtime-avatar-sdk/${SDK_VERSION}`, runtimeTag(), options.userAgent].filter(Boolean).join(" ");
67
+ }
68
+ // ── calls ────────────────────────────────────────────────────────────────
69
+ /**
70
+ * Start a call and get back what the client needs to join.
71
+ *
72
+ * Returns `{ queued: true, … }` when every slot is busy — that is a normal state, not a
73
+ * failure. Render the position and retry after `retryAfterMs`.
74
+ *
75
+ * Everything in `options` beyond `avatarId`/`mode` is a POLICY: it is what your server has
76
+ * decided about this call. Never populate it from a request body.
77
+ */
78
+ async startCall(options) {
79
+ const body = {
80
+ avatar_id: options.avatarId,
81
+ mode: options.mode ?? "avatar",
82
+ stt_mode: options.listen === false ? "off" : "server"
83
+ };
84
+ if (options.instructions !== void 0) body.instructions = options.instructions;
85
+ if (options.context !== void 0) {
86
+ body.initial_context = options.context.map((m) => ({ role: m.role, content: m.content }));
87
+ }
88
+ if (options.maxSeconds !== void 0) body.max_session_seconds = Math.floor(options.maxSeconds);
89
+ if (options.voice !== void 0) body.voice = options.voice;
90
+ if (options.metadata !== void 0) body.client_metadata = options.metadata;
91
+ if (options.clientTools) body.capabilities = ["client_tools"];
92
+ if (options.transcript !== void 0) {
93
+ body.transcript_webhook = { url: options.transcript.url, secret: options.transcript.secret };
94
+ }
95
+ if (options.video !== void 0) Object.assign(body, videoToWire(options.video));
96
+ const response = await this.#request("POST", "/realtime/livekit/session", { json: body });
97
+ if (response.status === 429) {
98
+ const busy = await response.json();
99
+ return {
100
+ queued: true,
101
+ position: typeof busy.queue_position === "number" ? busy.queue_position : null,
102
+ size: typeof busy.queue_size === "number" ? busy.queue_size : 0,
103
+ retryAfterMs: typeof busy.recommended_retry_ms === "number" ? busy.recommended_retry_ms : 3e3,
104
+ queueTicketId: typeof busy.queue_ticket_id === "string" ? busy.queue_ticket_id : null
105
+ };
106
+ }
107
+ const grant = await this.#json(response);
108
+ return {
109
+ status: "ready",
110
+ sessionId: String(grant.session_id),
111
+ roomName: String(grant.room_name),
112
+ livekitUrl: String(grant.livekit_url),
113
+ participantToken: String(grant.participant_token),
114
+ participantIdentity: String(grant.participant_identity),
115
+ maxSessionSeconds: Number(grant.max_session_seconds ?? 0),
116
+ idleTimeoutSeconds: Number(grant.idle_timeout_seconds ?? 0),
117
+ reservationExpiresAt: String(grant.reservation_expires_at),
118
+ // The parsed fields above are for YOUR logic. Relay `raw` to the client untouched:
119
+ // the browser SDK validates the grant strictly and rejects an added or renamed key.
120
+ raw: grant
121
+ };
122
+ }
123
+ /**
124
+ * End a call and free its slot NOW, instead of when a timeout notices.
125
+ *
126
+ * The slot is held from the moment `startCall` returns — **including the window before
127
+ * your user has joined the room**. Someone who closes the tab right there leaves the call
128
+ * running until the join timeout reclaims it. Give the page a same-origin route that calls
129
+ * this, hit it with `navigator.sendBeacon` on `pagehide`, and the abandoned call ends the
130
+ * moment they leave. The demo apps carry the whole pattern.
131
+ *
132
+ * Best-effort, like the hang-up it is: `true` when the platform acknowledged the release,
133
+ * `false` for anything else — never a throw. Ending is idempotent (an unknown or
134
+ * already-ended session still acks), so a pagehide beacon and a disconnect handler may
135
+ * both fire for the same call without error. A release that is lost is a slower release,
136
+ * not a leak — the join timeout is the backstop.
137
+ *
138
+ * This ends whatever the id names, so only pass ids YOUR SERVER minted — remember them at
139
+ * `startCall` time and refuse the rest. A route that relays an arbitrary id from the
140
+ * request body lets any visitor hang up any call on your account.
141
+ */
142
+ async endCall(sessionId, options = {}) {
143
+ if (!sessionId) return false;
144
+ const body = { session_id: sessionId };
145
+ if (options.reason !== void 0) body.reason = options.reason;
146
+ if (options.capacityPool !== void 0) body.capacity_pool = options.capacityPool;
147
+ try {
148
+ const response = await this.#request("POST", "/realtime/livekit/session/release", { json: body });
149
+ await response.body?.cancel().catch(() => {
150
+ });
151
+ return response.ok;
152
+ } catch {
153
+ return false;
154
+ }
155
+ }
156
+ // ── avatars ──────────────────────────────────────────────────────────────
157
+ /**
158
+ * Register a character from a looping clip you host.
159
+ *
160
+ * @deprecated CLOSED to new callers — this answers `422` unless your tenant was already
161
+ * creating from video, in which case it keeps working and your existing avatars are
162
+ * untouched. Use {@link createAvatarFromImage}: one still, and the platform renders the
163
+ * resting loop and the motion library from it.
164
+ *
165
+ * The reason it closed is not arbitrary. Every clip has to start and end on ONE rest pose
166
+ * or a state switch reads as a jump, and the platform can only guarantee that when it
167
+ * rendered the loop and the clips from the same portrait. A supplied video cannot honour
168
+ * it, so the lane could never be made to look right.
169
+ */
170
+ async createAvatarFromVideo(input) {
171
+ const asset = await this.createRemoteAsset({ kind: "video", remoteUrl: input.videoUrl });
172
+ return this.createAvatar({
173
+ displayName: input.displayName,
174
+ sourceKind: "video",
175
+ sourceAssetId: asset.id,
176
+ voice: input.voice,
177
+ settings: input.settings,
178
+ metadata: input.metadata
179
+ });
180
+ }
181
+ /**
182
+ * Register a character from ONE still image. The platform generates everything moving:
183
+ * the resting loop she idles in, then a starter motion library rendered against her rest
184
+ * pose. No footage, no clip URLs, nothing to shoot.
185
+ *
186
+ * `motionPrompt` directs the RESTING LOOP — the video she plays when nothing else is
187
+ * happening — and it is the only chance to direct it, because there is no API today that
188
+ * re-generates a loop after creation (see {@link updateAvatar} for the one thing that can
189
+ * be re-pointed). Describe a small closed arc that returns to where it started: "settles
190
+ * into frame, breathes gently, a slow blink". Omit it and the house default is used.
191
+ *
192
+ * Creation returns while the avatar is still `preprocessing`; poll {@link getAvatar} until
193
+ * it leaves that state. The loop is load-bearing, so a failure there settles `failed` with
194
+ * a readable error — the motion library is not, and a library failure degrades to
195
+ * loop-only rather than demoting the character.
196
+ */
197
+ async createAvatarFromImage(input) {
198
+ const asset = await this.createRemoteAsset({ kind: "image", remoteUrl: input.imageUrl });
199
+ return this.createAvatar({
200
+ displayName: input.displayName,
201
+ sourceKind: "image",
202
+ sourceAssetId: asset.id,
203
+ motionPrompt: input.motionPrompt,
204
+ voice: input.voice,
205
+ settings: input.settings,
206
+ metadata: input.metadata
207
+ });
208
+ }
209
+ async createAvatar(input) {
210
+ const body = {
211
+ displayName: input.displayName,
212
+ sourceKind: input.sourceKind,
213
+ sourceAssetId: input.sourceAssetId
214
+ };
215
+ if (input.motionPrompt !== void 0) body.motionPrompt = input.motionPrompt;
216
+ if (input.voice !== void 0) body.voice = input.voice;
217
+ if (input.settings !== void 0) body.settings = input.settings;
218
+ if (input.metadata !== void 0) body.metadata = input.metadata;
219
+ return toAvatar(await this.#json(await this.#request("POST", "/avatars", { json: body })));
220
+ }
221
+ async listAvatars() {
222
+ const data = await this.#json(await this.#request("GET", "/avatars"));
223
+ return (data.data ?? []).map(toAvatar);
224
+ }
225
+ async getAvatar(avatarId) {
226
+ return toAvatar(await this.#json(await this.#request("GET", `/avatars/${avatarId}`)));
227
+ }
228
+ /**
229
+ * Re-point what an avatar already is. `defaultVoiceId: null` clears the default voice.
230
+ *
231
+ * Cheap metadata only. The three things that cost a render have their own methods, because
232
+ * putting them in a patch alongside a rename would hide minutes of GPU work behind a field:
233
+ * {@link retimeAnchor} moves the rest frame, {@link swapSource} replaces the footage, and
234
+ * the resting loop is re-directed by description at `PUT /v1/avatars/{id}/loop`.
235
+ */
236
+ async updateAvatar(avatarId, patch) {
237
+ return toAvatar(
238
+ await this.#json(await this.#request("PATCH", `/avatars/${avatarId}`, { json: patch }))
239
+ );
240
+ }
241
+ /**
242
+ * Re-shoot the character — swap in new footage as her resting loop.
243
+ *
244
+ * ASYNCHRONOUS, and that is the whole design. This returns as soon as the swap is
245
+ * accepted; the avatar keeps serving its CURRENT loop, cache and clips the entire time,
246
+ * and cuts over to the new generation in one step once the replacement is prepared.
247
+ * A call minted a second after this returns is a normal call on the old footage.
248
+ *
249
+ * Two consequences worth designing for:
250
+ *
251
+ * - **The clip library empties and refills.** Old takes are footage of the old source and
252
+ * cannot splice against the new loop, so they are dropped and re-rendered. Between the
253
+ * cutover and the last re-render she rests on the new loop with less variety — never
254
+ * broken, just plainer. Do not gate your UI on the library being full.
255
+ * - **A failed swap does not fail the avatar.** She keeps serving, `status` stays `ready`,
256
+ * and the reason lands on `error`. So poll `getAvatar` and read `error` — a non-null
257
+ * `error` on a `ready` avatar is the swap that did not take, not an unhealthy character.
258
+ *
259
+ * The frame this rests on comes from the new footage: pass `anchorTimeMs` when frame 0 of
260
+ * the take is mid-blink. Video-sourced avatars only — a portrait-anchored one is a 422.
261
+ */
262
+ async swapSource(avatarId, input) {
263
+ const json = { sourceAssetId: input.sourceAssetId };
264
+ if (input.anchorTimeMs !== void 0) json.anchorTimeMs = input.anchorTimeMs;
265
+ return toAvatar(await this.#json(await this.#request("PATCH", `/avatars/${avatarId}`, { json })));
266
+ }
267
+ /**
268
+ * Re-point the anchor at a different frame of the loop she ALREADY has — same footage,
269
+ * different rest pose. `swapSource` replaces the footage; this only moves the frame.
270
+ *
271
+ * Re-renders the clip library the same way, with the same degradation window, and is
272
+ * clamped server-side to the loop's last extractable frame (read the avatar back to see
273
+ * what was actually cut). Video-sourced avatars only.
274
+ */
275
+ async retimeAnchor(avatarId, anchorTimeMs) {
276
+ return toAvatar(
277
+ await this.#json(await this.#request("PATCH", `/avatars/${avatarId}`, { json: { anchorTimeMs } }))
278
+ );
279
+ }
280
+ async deleteAvatar(avatarId) {
281
+ await this.#json(await this.#request("DELETE", `/avatars/${avatarId}`));
282
+ }
283
+ // A clip envelope missing `revision` would silently drop `expectedRevision` from the
284
+ // next declare — CAS degrades to unconditional with zero signal — so it throws instead.
285
+ #clipEnvelope(out) {
286
+ if (typeof out.revision !== "number" || !Array.isArray(out.data)) {
287
+ throw new RealtimeAvatarError("clip library response did not match the contract");
288
+ }
289
+ return out;
290
+ }
291
+ /**
292
+ * Declare the avatar's full desired clip library — a declaration, not a delta. The
293
+ * platform reconciles it against what exists: unchanged clips are `kept` (still
294
+ * serving), new or changed ones are `queued` to render, and clips you dropped are
295
+ * `retired`. The 202 is acceptance, not readiness — poll `listClips` until no row is
296
+ * `queued` or `generating`. A rejected upload settles `failed`, which is terminal, so
297
+ * waiting for all-`ready` waits forever. While a re-render is in flight the previous
298
+ * take keeps serving, so a declaration never blanks a live avatar.
299
+ *
300
+ * `expectedRevision` is compare-and-set: pass the `revision` you last read and a
301
+ * concurrent writer surfaces as a 409 instead of a lost update. Omit it to declare
302
+ * unconditionally.
303
+ *
304
+ * At most 12 clips: one `idle`, up to two `listen`, the rest `gesture`. An uploaded
305
+ * clip (`source: { assetId }`) must start AND end on the avatar's rest pose — pose
306
+ * validation rejects it otherwise (`status: "failed"`, the verdict in `poseCheck`),
307
+ * and the rest of the library is untouched.
308
+ */
309
+ async setClipLibrary(avatarId, library) {
310
+ const body = { clips: library.clips };
311
+ if (library.expectedRevision !== void 0) body.expectedRevision = library.expectedRevision;
312
+ return this.#clipEnvelope(
313
+ await this.#json(
314
+ await this.#request("PUT", `/avatars/${avatarId}/clips`, { json: body })
315
+ )
316
+ );
317
+ }
318
+ /**
319
+ * Re-direct the RESTING LOOP — the video she plays when nothing else is happening — from
320
+ * a new one-sentence description.
321
+ *
322
+ * Not a clip, and this is the distinction integrations get wrong: a clip with
323
+ * `role: "idle"` is a variant spliced OVER the loop, and declaring one never changes what
324
+ * she rests in. This is the only thing that does.
325
+ *
326
+ * `202`, because the render takes minutes. Three properties, all measured against a real
327
+ * render rather than asserted:
328
+ *
329
+ * - **She never goes dark.** She stays `ready` and keeps serving her previous loop for the
330
+ * entire render — returned as `servingUrl` — then the swap publishes in one step.
331
+ * - **Your clip library is untouched.** Clips render against the portrait, not against the
332
+ * loop, so a re-direct re-queues nothing and does not move `revision`.
333
+ * - **It bills once**, at the rendering model's rate, per re-direct.
334
+ *
335
+ * Refusals worth telling apart: `409 loop_pending` (one is already in flight — wait) and
336
+ * `422 loop_not_generatable` (a grandfathered video-sourced avatar has no portrait to
337
+ * re-animate — terminal, do not retry).
338
+ */
339
+ async setLoop(avatarId, loop) {
340
+ return await this.#json(
341
+ await this.#request("PUT", `/avatars/${avatarId}/loop`, { json: { motionPrompt: loop.motionPrompt } })
342
+ );
343
+ }
344
+ /**
345
+ * Block until a loop re-direct settles, and throw if it did not take.
346
+ *
347
+ * `setLoop` returns on ACCEPTANCE; the render runs for minutes afterwards. Every caller
348
+ * therefore writes the same polling loop, and the obvious version of it never terminates
349
+ * on failure — a failed re-direct leaves her `ready` (she is still serving the old loop)
350
+ * and writes nothing to `error`. `idleVideoStatus` is the only field that moves, which is
351
+ * why this exists rather than a doc line telling you to poll.
352
+ *
353
+ * Race-free without a baseline: the platform commits `queued` before `setLoop` returns, so
354
+ * by the time you can call this the status has already left `ready`.
355
+ *
356
+ * Resolves with the settled avatar — `sourceAssetId` is now the new loop. Throws on a
357
+ * failed render and on timeout; a timeout is not a failure, so re-poll or call again.
358
+ */
359
+ async waitForLoop(avatarId, options = {}) {
360
+ const timeoutMs = options.timeoutMs ?? 20 * 6e4;
361
+ const pollMs = Math.max(1e3, options.pollMs ?? 1e4);
362
+ const deadline = Date.now() + timeoutMs;
363
+ for (; ; ) {
364
+ const avatar = await this.getAvatar(avatarId);
365
+ if (avatar.idleVideoStatus === "failed") {
366
+ throw new RealtimeAvatarError(
367
+ `Loop re-direct failed for ${avatarId}. She is still serving her previous loop \u2014 nothing was lost, and you can send another description.`
368
+ );
369
+ }
370
+ if (avatar.idleVideoStatus === "ready" || avatar.idleVideoStatus === "none") return avatar;
371
+ if (Date.now() >= deadline) {
372
+ throw new RealtimeAvatarError(
373
+ `Loop re-direct for ${avatarId} was still ${avatar.idleVideoStatus} after ${Math.round(timeoutMs / 1e3)}s. This is a timeout, not a failure \u2014 the render may still land.`
374
+ );
375
+ }
376
+ await sleep(pollMs);
377
+ }
378
+ }
379
+ /**
380
+ * Block until every clip in the library has stopped moving.
381
+ *
382
+ * Settled means no row is `queued` or `generating` — NOT that every row is `ready`.
383
+ * Waiting for all-`ready` is the intuitive version and it hangs forever: a clip rejected
384
+ * by pose validation settles `failed`, which is terminal. So this returns the library
385
+ * with the failures in it and lets you decide; a partial library is a legitimate outcome
386
+ * and the rest of it is already serving.
387
+ *
388
+ * Throws only on timeout.
389
+ */
390
+ async waitForClips(avatarId, options = {}) {
391
+ const timeoutMs = options.timeoutMs ?? 20 * 6e4;
392
+ const pollMs = Math.max(1e3, options.pollMs ?? 1e4);
393
+ const deadline = Date.now() + timeoutMs;
394
+ for (; ; ) {
395
+ const library = await this.listClips(avatarId);
396
+ const moving = library.data.filter((c) => c.status === "queued" || c.status === "generating");
397
+ if (moving.length === 0) return library;
398
+ if (Date.now() >= deadline) {
399
+ throw new RealtimeAvatarError(
400
+ `${moving.length} clip(s) on ${avatarId} were still rendering after ${Math.round(timeoutMs / 1e3)}s: ${moving.map((c) => c.clipId).join(", ")}. This is a timeout, not a failure.`
401
+ );
402
+ }
403
+ await sleep(pollMs);
404
+ }
405
+ }
406
+ /** The avatar's clip library: every non-retired clip, plus revision, anchor and eligibility. */
407
+ async listClips(avatarId) {
408
+ return this.#clipEnvelope(
409
+ await this.#json(
410
+ await this.#request("GET", `/avatars/${avatarId}/clips`)
411
+ )
412
+ );
413
+ }
414
+ /**
415
+ * Reconcile an avatar's clip set after it changes.
416
+ *
417
+ * Required, not optional: clips are prepared once and cached by URL hash, and the serve
418
+ * path only LOADS that cache. A clip that has never been prepared silently does nothing on
419
+ * the first call after you add it. Idempotent, so calling it on every write is cheap.
420
+ *
421
+ * **At most 32 URLs per call.** This is the whole set for the avatar, not a delta, and the
422
+ * endpoint rejects an oversize list rather than truncating it — so a library that outgrows
423
+ * 32 needs the set trimmed, not split across two calls.
424
+ *
425
+ * @deprecated The externally-hosted clip tier this serves is sunsetting. Declare the
426
+ * library with {@link setClipLibrary} instead — the platform renders and hosts the
427
+ * clips, and pose-validates uploads against the avatar's rest pose.
428
+ */
429
+ async syncClips(avatarId, clipUrls) {
430
+ const out = await this.#json(
431
+ await this.#request("POST", `/avatars/${avatarId}/clips`, { json: { clipUrls } })
432
+ );
433
+ return { queued: out.queued ?? [], ready: out.ready ?? [], retired: out.retired ?? [] };
434
+ }
435
+ // ── assets ───────────────────────────────────────────────────────────────
436
+ /** Hand us a URL and we stream it into storage. Prefer this for anything large. */
437
+ async createRemoteAsset(input) {
438
+ return toAsset(await this.#json(await this.#request("POST", "/assets/remote", { json: input })));
439
+ }
440
+ /** Upload bytes you already hold. */
441
+ async uploadAsset(file, options = {}) {
442
+ const form = new FormData();
443
+ form.append("file", file, options.filename ?? "upload");
444
+ if (options.kind) form.append("kind", options.kind);
445
+ return toAsset(await this.#json(await this.#request("POST", "/assets", { body: form })));
446
+ }
447
+ // ── billing ──────────────────────────────────────────────────────────────
448
+ /**
449
+ * Billable sessions, newest first — the itemised half of the bill that `creditBalance`
450
+ * cannot give you.
451
+ *
452
+ * Works with no setup: every session is listed with its times, duration and cost. To also
453
+ * know WHICH of your users a session belongs to, tag the call when you start it:
454
+ *
455
+ * ```ts
456
+ * await rta.startCall({ avatarId, metadata: { user_id: user.id } });
457
+ * // ...later
458
+ * await rta.listSessions({ endUserId: user.id });
459
+ * ```
460
+ *
461
+ * Tagging is optional and nothing degrades without it — you just cannot attribute a
462
+ * session to one of your users. Requires a key with the `usage:read` scope.
463
+ */
464
+ async listSessions(options = {}) {
465
+ const query = new URLSearchParams();
466
+ if (options.from) query.set("from", options.from);
467
+ if (options.to) query.set("to", options.to);
468
+ if (options.limit !== void 0) query.set("limit", String(options.limit));
469
+ if (options.cursor) query.set("cursor", options.cursor);
470
+ if (options.endUserId) query.set("endUserId", options.endUserId);
471
+ const suffix = query.size > 0 ? `?${query}` : "";
472
+ const page = await this.#json(await this.#request("GET", `/usage/sessions${suffix}`));
473
+ return toUsageSessionPage(page);
474
+ }
475
+ /**
476
+ * Every session in a window, following the cursor for you.
477
+ *
478
+ * An async iterator rather than an array, because a busy month is a lot of rows and a
479
+ * caller writing a monthly report should not have to hold all of them to start writing.
480
+ */
481
+ async *iterateSessions(options = {}) {
482
+ let cursor = options.cursor;
483
+ do {
484
+ const page = await this.listSessions({ ...options, cursor });
485
+ yield* page.sessions;
486
+ cursor = page.nextCursor ?? void 0;
487
+ } while (cursor);
488
+ }
489
+ async creditBalance() {
490
+ return await this.#json(await this.#request("GET", "/credits/balance"));
491
+ }
492
+ // ── internals ────────────────────────────────────────────────────────────
493
+ async #request(method, path, init = {}) {
494
+ const headers = {
495
+ authorization: `Bearer ${this.#apiKey}`,
496
+ "user-agent": this.#userAgent
497
+ };
498
+ let body = init.body;
499
+ if (init.json !== void 0) {
500
+ headers["content-type"] = "application/json";
501
+ body = JSON.stringify(init.json);
502
+ }
503
+ if (MUTATING.has(method)) headers["idempotency-key"] = newIdempotencyKey();
504
+ let lastError;
505
+ for (let attempt = 0; ; attempt++) {
506
+ try {
507
+ const signal = AbortSignal.timeout(this.#timeoutMs);
508
+ const response = await this.#fetch(`${this.#baseUrl}${path}`, { method, headers, body, signal });
509
+ if (attempt >= this.#maxRetries || !RETRYABLE_STATUS.has(response.status)) return response;
510
+ await response.body?.cancel().catch(() => {
511
+ });
512
+ await sleep(backoffMs(attempt, response.headers.get("retry-after")));
513
+ } catch (cause) {
514
+ lastError = cause;
515
+ if (attempt >= this.#maxRetries || !isTransient(cause) || isStream(body)) {
516
+ throw new RealtimeAvatarError(
517
+ `${method} ${path} failed after ${attempt + 1} attempt(s): ${cause.message}`,
518
+ { cause: lastError }
519
+ );
520
+ }
521
+ await sleep(backoffMs(attempt, null));
522
+ }
523
+ }
524
+ }
525
+ /** Throw a useful error rather than letting a 4xx flow on as `undefined`. */
526
+ async #json(response) {
527
+ if (response.ok) return response.json();
528
+ const text = await response.text().catch(() => "");
529
+ let code;
530
+ try {
531
+ code = JSON.parse(text).code;
532
+ } catch {
533
+ }
534
+ throw new RealtimeAvatarHttpError(response.status, code, text.slice(0, 400));
535
+ }
536
+ };
537
+ function toUsageSessionPage(raw) {
538
+ const body = isRecord(raw) ? raw : {};
539
+ const rows = Array.isArray(body.data) ? body.data : [];
540
+ return {
541
+ sessions: rows.filter(isRecord).map((row) => ({
542
+ sessionId: String(row.sessionId ?? ""),
543
+ avatarId: typeof row.avatarId === "string" ? row.avatarId : null,
544
+ status: usageStatus(row.status),
545
+ startedAt: typeof row.startedAt === "string" ? row.startedAt : null,
546
+ endedAt: typeof row.endedAt === "string" ? row.endedAt : null,
547
+ activeSeconds: typeof row.activeSeconds === "number" ? row.activeSeconds : null,
548
+ billedCreditMicros: typeof row.billedCreditMicros === "number" ? row.billedCreditMicros : null,
549
+ metadata: isRecord(row.metadata) ? row.metadata : {},
550
+ createdAt: String(row.createdAt ?? "")
551
+ })),
552
+ nextCursor: typeof body.nextCursor === "string" ? body.nextCursor : null,
553
+ from: String(body.from ?? ""),
554
+ to: String(body.to ?? "")
555
+ };
556
+ }
557
+ var USAGE_STATUSES = ["reserved", "started", "released", "failed"];
558
+ function usageStatus(value) {
559
+ return USAGE_STATUSES.find((s) => s === value) ?? "failed";
560
+ }
561
+ function isRecord(value) {
562
+ return typeof value === "object" && value !== null && !Array.isArray(value);
563
+ }
564
+ function videoToWire(video) {
565
+ if (video.mode === "generative") return { render_backend: "generative" };
566
+ const out = {};
567
+ if (video.edits) {
568
+ const edits = { instruction: video.edits.instruction };
569
+ if (video.edits.referenceUrl !== void 0) edits.reference_url = video.edits.referenceUrl;
570
+ if (video.edits.live !== void 0) {
571
+ const live = { rules: video.edits.live.rules };
572
+ if (video.edits.live.cooldownSeconds !== void 0) {
573
+ live.cooldown_seconds = Math.floor(video.edits.live.cooldownSeconds);
574
+ }
575
+ if (video.edits.live.renderer !== void 0) live.renderer = video.edits.live.renderer;
576
+ edits.live_edit = live;
577
+ }
578
+ out.support_edits = edits;
579
+ }
580
+ if (video.states) {
581
+ out.clip_library = Object.entries(video.states).map(([id, state]) => {
582
+ const clip = {
583
+ clip_id: id,
584
+ source_video_url: state.url,
585
+ trigger: "directive",
586
+ // `when` is the public name for this cue. The wire also still accepts the older
587
+ // `hint`; send one name only, and prefer the one the docs and types use.
588
+ when: state.when
589
+ };
590
+ if (state.weight !== void 0) clip.weight = state.weight;
591
+ return clip;
592
+ });
593
+ }
594
+ return out;
595
+ }
596
+ function toAvatar(raw) {
597
+ const a = raw;
598
+ return {
599
+ id: String(a.id),
600
+ displayName: String(a.displayName ?? ""),
601
+ sourceKind: a.sourceKind === "video" ? "video" : "image",
602
+ status: a.status ?? "draft",
603
+ defaultVoiceId: a.defaultVoiceId ? String(a.defaultVoiceId) : null,
604
+ sourceAssetId: a.sourceAssetId ? String(a.sourceAssetId) : null,
605
+ // Carried because it is the ONLY channel a failed source swap has: she stays `ready`
606
+ // and serving, and this says why the re-shoot did not take.
607
+ error: a.error ? String(a.error) : null,
608
+ // The loop lane's terminal signal, and the reason it is here: a re-direct that fails
609
+ // leaves `status` on `ready` (she is still serving the old loop, which is the whole
610
+ // design) and writes nothing to `error`. Without this field a caller polling after
611
+ // `setLoop` has NO way to distinguish "still rendering" from "gave up", and waits
612
+ // forever. queued → generating → ready | failed.
613
+ idleVideoStatus: a.idleVideoStatus ?? "none"
614
+ };
615
+ }
616
+ function toAsset(raw) {
617
+ const a = raw;
618
+ const url = a.publicUrl ?? a.url;
619
+ if (typeof url !== "string" || !url) {
620
+ throw new RealtimeAvatarError(`asset ${String(a.id)} came back without a public URL`);
621
+ }
622
+ return {
623
+ id: String(a.id),
624
+ kind: a.kind ?? "video",
625
+ url,
626
+ status: typeof a.status === "string" ? a.status : "ready",
627
+ contentType: typeof a.contentType === "string" ? a.contentType : null,
628
+ sizeBytes: typeof a.sizeBytes === "number" ? a.sizeBytes : null
629
+ };
630
+ }
631
+ function runtimeTag() {
632
+ const g = globalThis;
633
+ if ("Deno" in g) return "deno";
634
+ if ("Bun" in g) return "bun";
635
+ if ("navigator" in g && typeof navigator?.userAgent === "string" && navigator.userAgent.includes("Cloudflare-Workers")) return "workerd";
636
+ if ("process" in g && typeof process?.versions?.node === "string") return `node/${process.versions.node}`;
637
+ return "unknown";
638
+ }
639
+ function isStream(body) {
640
+ return typeof ReadableStream !== "undefined" && body instanceof ReadableStream;
641
+ }
642
+
643
+ // ../http-client/src/webhook.ts
644
+ async function verifyTranscript(rawBody, headers, secret, options = {}) {
645
+ const get = (name) => headers instanceof Headers ? headers.get(name) ?? void 0 : headers[name];
646
+ const signature = get("x-rta-signature");
647
+ const timestamp = get("x-rta-timestamp");
648
+ if (!signature || !timestamp) throw new Error("missing x-rta-signature / x-rta-timestamp");
649
+ const skew = Math.abs(Date.now() / 1e3 - Number(timestamp));
650
+ if (!Number.isFinite(skew) || skew > (options.toleranceSeconds ?? 300)) {
651
+ throw new Error("transcript webhook timestamp is outside the replay window");
652
+ }
653
+ const text = typeof rawBody === "string" ? rawBody : new TextDecoder().decode(rawBody);
654
+ const key = await crypto.subtle.importKey(
655
+ "raw",
656
+ new TextEncoder().encode(secret),
657
+ { name: "HMAC", hash: "SHA-256" },
658
+ false,
659
+ ["sign"]
660
+ );
661
+ const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${timestamp}.${text}`));
662
+ const expected = `v1=${[...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join("")}`;
663
+ if (!timingSafeEqual(expected, signature)) throw new Error("transcript webhook signature mismatch");
664
+ return JSON.parse(text);
665
+ }
666
+ function timingSafeEqual(a, b) {
667
+ if (a.length !== b.length) return false;
668
+ let diff = 0;
669
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
670
+ return diff === 0;
671
+ }
672
+
673
+ // ../http-client/src/types.ts
674
+ function isQueued(result) {
675
+ return "queued" in result;
676
+ }
677
+
678
+ export { RealtimeAvatar, RealtimeAvatarError, RealtimeAvatarHttpError, isQueued, verifyTranscript };