realtime-avatar 0.3.0

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/dist/nextjs.js ADDED
@@ -0,0 +1,498 @@
1
+ // ../http-client/src/retry.ts
2
+ var RETRYABLE_STATUS = /* @__PURE__ */ new Set([408, 500, 502, 503, 504]);
3
+ var MUTATING = /* @__PURE__ */ new Set(["POST", "PUT", "PATCH", "DELETE"]);
4
+ function newIdempotencyKey() {
5
+ const c = globalThis.crypto;
6
+ if (c?.randomUUID) return c.randomUUID();
7
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 12)}`;
8
+ }
9
+ function backoffMs(attempt, retryAfter) {
10
+ const server = retryAfter ? Number(retryAfter) * 1e3 : NaN;
11
+ if (Number.isFinite(server) && server >= 0) return Math.min(server, 2e4);
12
+ return Math.random() * Math.min(500 * 2 ** attempt, 8e3);
13
+ }
14
+ var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
15
+ function isTransient(cause) {
16
+ const name = cause?.name;
17
+ return name === "TimeoutError" || name === "TypeError" || name === "FetchError";
18
+ }
19
+
20
+ // ../http-client/src/errors.ts
21
+ var RealtimeAvatarError = class extends Error {
22
+ constructor(message, options) {
23
+ super(message, options);
24
+ this.name = "RealtimeAvatarError";
25
+ }
26
+ };
27
+ var RealtimeAvatarHttpError = class extends RealtimeAvatarError {
28
+ status;
29
+ code;
30
+ body;
31
+ constructor(status, code, body) {
32
+ super(`Realtime Avatar API ${status}${code ? ` (${code})` : ""}: ${body || "no body"}`);
33
+ this.name = "RealtimeAvatarHttpError";
34
+ this.status = status;
35
+ this.code = code;
36
+ this.body = body;
37
+ }
38
+ /** Out of credits, or over this key's spend limit. Surface a paywall, not an error. */
39
+ get isBilling() {
40
+ return this.status === 402;
41
+ }
42
+ };
43
+
44
+ // ../http-client/src/client.ts
45
+ var DEFAULT_BASE_URL = "https://realtimeavatar.ai/api/v1";
46
+ var SDK_VERSION = "0.3.0";
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 ?? globalThis.fetch;
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
+ * Use a VIDEO source for anything that will be called live. An avatar built from a still
161
+ * image reaches `ready`, mints calls, and publishes a BLACK track — the status and the
162
+ * track dimensions both look fine, only the pixels are wrong. Image sources are for
163
+ * offline lipsync renders.
164
+ */
165
+ async createAvatarFromVideo(input) {
166
+ const asset = await this.createRemoteAsset({ kind: "video", remoteUrl: input.videoUrl });
167
+ return this.createAvatar({
168
+ displayName: input.displayName,
169
+ sourceKind: "video",
170
+ sourceAssetId: asset.id,
171
+ voice: input.voice,
172
+ settings: input.settings,
173
+ metadata: input.metadata
174
+ });
175
+ }
176
+ async createAvatar(input) {
177
+ const body = {
178
+ displayName: input.displayName,
179
+ sourceKind: input.sourceKind,
180
+ sourceAssetId: input.sourceAssetId
181
+ };
182
+ if (input.voice !== void 0) body.voice = input.voice;
183
+ if (input.settings !== void 0) body.settings = input.settings;
184
+ if (input.metadata !== void 0) body.metadata = input.metadata;
185
+ return toAvatar(await this.#json(await this.#request("POST", "/avatars", { json: body })));
186
+ }
187
+ async listAvatars() {
188
+ const data = await this.#json(await this.#request("GET", "/avatars"));
189
+ return (data.data ?? []).map(toAvatar);
190
+ }
191
+ async getAvatar(avatarId) {
192
+ return toAvatar(await this.#json(await this.#request("GET", `/avatars/${avatarId}`)));
193
+ }
194
+ /** Re-point what an avatar already is. `defaultVoiceId: null` clears the default voice. */
195
+ async updateAvatar(avatarId, patch) {
196
+ return toAvatar(
197
+ await this.#json(await this.#request("PATCH", `/avatars/${avatarId}`, { json: patch }))
198
+ );
199
+ }
200
+ async deleteAvatar(avatarId) {
201
+ await this.#json(await this.#request("DELETE", `/avatars/${avatarId}`));
202
+ }
203
+ /**
204
+ * Reconcile an avatar's clip set after it changes.
205
+ *
206
+ * Required, not optional: clips are prepared once and cached by URL hash, and the serve
207
+ * path only LOADS that cache. A clip that has never been prepared silently does nothing on
208
+ * the first call after you add it. Idempotent, so calling it on every write is cheap.
209
+ *
210
+ * **At most 32 URLs per call.** This is the whole set for the avatar, not a delta, and the
211
+ * endpoint rejects an oversize list rather than truncating it — so a library that outgrows
212
+ * 32 needs the set trimmed, not split across two calls.
213
+ */
214
+ async syncClips(avatarId, clipUrls) {
215
+ const out = await this.#json(
216
+ await this.#request("POST", `/avatars/${avatarId}/clips`, { json: { clipUrls } })
217
+ );
218
+ return { queued: out.queued ?? [], ready: out.ready ?? [], retired: out.retired ?? [] };
219
+ }
220
+ // ── assets ───────────────────────────────────────────────────────────────
221
+ /** Hand us a URL and we stream it into storage. Prefer this for anything large. */
222
+ async createRemoteAsset(input) {
223
+ return toAsset(await this.#json(await this.#request("POST", "/assets/remote", { json: input })));
224
+ }
225
+ /** Upload bytes you already hold. */
226
+ async uploadAsset(file, options = {}) {
227
+ const form = new FormData();
228
+ form.append("file", file, options.filename ?? "upload");
229
+ if (options.kind) form.append("kind", options.kind);
230
+ return toAsset(await this.#json(await this.#request("POST", "/assets", { body: form })));
231
+ }
232
+ // ── billing ──────────────────────────────────────────────────────────────
233
+ /**
234
+ * Billable sessions, newest first — the itemised half of the bill that `creditBalance`
235
+ * cannot give you.
236
+ *
237
+ * Works with no setup: every session is listed with its times, duration and cost. To also
238
+ * know WHICH of your users a session belongs to, tag the call when you start it:
239
+ *
240
+ * ```ts
241
+ * await rta.startCall({ avatarId, metadata: { user_id: user.id } });
242
+ * // ...later
243
+ * await rta.listSessions({ endUserId: user.id });
244
+ * ```
245
+ *
246
+ * Tagging is optional and nothing degrades without it — you just cannot attribute a
247
+ * session to one of your users. Requires a key with the `usage:read` scope.
248
+ */
249
+ async listSessions(options = {}) {
250
+ const query = new URLSearchParams();
251
+ if (options.from) query.set("from", options.from);
252
+ if (options.to) query.set("to", options.to);
253
+ if (options.limit !== void 0) query.set("limit", String(options.limit));
254
+ if (options.cursor) query.set("cursor", options.cursor);
255
+ if (options.endUserId) query.set("endUserId", options.endUserId);
256
+ const suffix = query.size > 0 ? `?${query}` : "";
257
+ const page = await this.#json(await this.#request("GET", `/usage/sessions${suffix}`));
258
+ return toUsageSessionPage(page);
259
+ }
260
+ /**
261
+ * Every session in a window, following the cursor for you.
262
+ *
263
+ * An async iterator rather than an array, because a busy month is a lot of rows and a
264
+ * caller writing a monthly report should not have to hold all of them to start writing.
265
+ */
266
+ async *iterateSessions(options = {}) {
267
+ let cursor = options.cursor;
268
+ do {
269
+ const page = await this.listSessions({ ...options, cursor });
270
+ yield* page.sessions;
271
+ cursor = page.nextCursor ?? void 0;
272
+ } while (cursor);
273
+ }
274
+ async creditBalance() {
275
+ return await this.#json(await this.#request("GET", "/credits/balance"));
276
+ }
277
+ // ── internals ────────────────────────────────────────────────────────────
278
+ async #request(method, path, init = {}) {
279
+ const headers = {
280
+ authorization: `Bearer ${this.#apiKey}`,
281
+ "user-agent": this.#userAgent
282
+ };
283
+ let body = init.body;
284
+ if (init.json !== void 0) {
285
+ headers["content-type"] = "application/json";
286
+ body = JSON.stringify(init.json);
287
+ }
288
+ if (MUTATING.has(method)) headers["idempotency-key"] = newIdempotencyKey();
289
+ let lastError;
290
+ for (let attempt = 0; ; attempt++) {
291
+ try {
292
+ const signal = AbortSignal.timeout(this.#timeoutMs);
293
+ const response = await this.#fetch(`${this.#baseUrl}${path}`, { method, headers, body, signal });
294
+ if (attempt >= this.#maxRetries || !RETRYABLE_STATUS.has(response.status)) return response;
295
+ await response.body?.cancel().catch(() => {
296
+ });
297
+ await sleep(backoffMs(attempt, response.headers.get("retry-after")));
298
+ } catch (cause) {
299
+ lastError = cause;
300
+ if (attempt >= this.#maxRetries || !isTransient(cause) || isStream(body)) {
301
+ throw new RealtimeAvatarError(
302
+ `${method} ${path} failed after ${attempt + 1} attempt(s): ${cause.message}`,
303
+ { cause: lastError }
304
+ );
305
+ }
306
+ await sleep(backoffMs(attempt, null));
307
+ }
308
+ }
309
+ }
310
+ /** Throw a useful error rather than letting a 4xx flow on as `undefined`. */
311
+ async #json(response) {
312
+ if (response.ok) return response.json();
313
+ const text = await response.text().catch(() => "");
314
+ let code;
315
+ try {
316
+ code = JSON.parse(text).code;
317
+ } catch {
318
+ }
319
+ throw new RealtimeAvatarHttpError(response.status, code, text.slice(0, 400));
320
+ }
321
+ };
322
+ function toUsageSessionPage(raw) {
323
+ const body = isRecord(raw) ? raw : {};
324
+ const rows = Array.isArray(body.data) ? body.data : [];
325
+ return {
326
+ sessions: rows.filter(isRecord).map((row) => ({
327
+ sessionId: String(row.sessionId ?? ""),
328
+ avatarId: typeof row.avatarId === "string" ? row.avatarId : null,
329
+ status: usageStatus(row.status),
330
+ startedAt: typeof row.startedAt === "string" ? row.startedAt : null,
331
+ endedAt: typeof row.endedAt === "string" ? row.endedAt : null,
332
+ activeSeconds: typeof row.activeSeconds === "number" ? row.activeSeconds : null,
333
+ billedCreditMicros: typeof row.billedCreditMicros === "number" ? row.billedCreditMicros : null,
334
+ metadata: isRecord(row.metadata) ? row.metadata : {},
335
+ createdAt: String(row.createdAt ?? "")
336
+ })),
337
+ nextCursor: typeof body.nextCursor === "string" ? body.nextCursor : null,
338
+ from: String(body.from ?? ""),
339
+ to: String(body.to ?? "")
340
+ };
341
+ }
342
+ var USAGE_STATUSES = ["reserved", "started", "released", "failed"];
343
+ function usageStatus(value) {
344
+ return USAGE_STATUSES.find((s) => s === value) ?? "failed";
345
+ }
346
+ function isRecord(value) {
347
+ return typeof value === "object" && value !== null && !Array.isArray(value);
348
+ }
349
+ function videoToWire(video) {
350
+ if (video.mode === "generative") return { render_backend: "generative" };
351
+ const out = {};
352
+ if (video.edits) {
353
+ const edits = { instruction: video.edits.instruction };
354
+ if (video.edits.referenceUrl !== void 0) edits.reference_url = video.edits.referenceUrl;
355
+ if (video.edits.live !== void 0) {
356
+ const live = { rules: video.edits.live.rules };
357
+ if (video.edits.live.cooldownSeconds !== void 0) {
358
+ live.cooldown_seconds = Math.floor(video.edits.live.cooldownSeconds);
359
+ }
360
+ if (video.edits.live.renderer !== void 0) live.renderer = video.edits.live.renderer;
361
+ edits.live_edit = live;
362
+ }
363
+ out.support_edits = edits;
364
+ }
365
+ if (video.states) {
366
+ out.clip_library = Object.entries(video.states).map(([id, state]) => {
367
+ const clip = {
368
+ clip_id: id,
369
+ source_video_url: state.url,
370
+ trigger: "directive",
371
+ // `when` is the public name for this cue. The wire also still accepts the older
372
+ // `hint`; send one name only, and prefer the one the docs and types use.
373
+ when: state.when
374
+ };
375
+ if (state.weight !== void 0) clip.weight = state.weight;
376
+ return clip;
377
+ });
378
+ }
379
+ return out;
380
+ }
381
+ function toAvatar(raw) {
382
+ const a = raw;
383
+ return {
384
+ id: String(a.id),
385
+ displayName: String(a.displayName ?? ""),
386
+ sourceKind: a.sourceKind === "video" ? "video" : "image",
387
+ status: a.status ?? "draft",
388
+ defaultVoiceId: a.defaultVoiceId ? String(a.defaultVoiceId) : null
389
+ };
390
+ }
391
+ function toAsset(raw) {
392
+ const a = raw;
393
+ const url = a.publicUrl ?? a.url;
394
+ if (typeof url !== "string" || !url) {
395
+ throw new RealtimeAvatarError(`asset ${String(a.id)} came back without a public URL`);
396
+ }
397
+ return {
398
+ id: String(a.id),
399
+ kind: a.kind ?? "video",
400
+ url,
401
+ status: typeof a.status === "string" ? a.status : "ready",
402
+ contentType: typeof a.contentType === "string" ? a.contentType : null,
403
+ sizeBytes: typeof a.sizeBytes === "number" ? a.sizeBytes : null
404
+ };
405
+ }
406
+ function runtimeTag() {
407
+ const g = globalThis;
408
+ if ("Deno" in g) return "deno";
409
+ if ("Bun" in g) return "bun";
410
+ if ("navigator" in g && typeof navigator?.userAgent === "string" && navigator.userAgent.includes("Cloudflare-Workers")) return "workerd";
411
+ if ("process" in g && typeof process?.versions?.node === "string") return `node/${process.versions.node}`;
412
+ return "unknown";
413
+ }
414
+ function isStream(body) {
415
+ return typeof ReadableStream !== "undefined" && body instanceof ReadableStream;
416
+ }
417
+
418
+ // ../http-client/src/types.ts
419
+ function isQueued(result) {
420
+ return "queued" in result;
421
+ }
422
+
423
+ // ../proxy/src/config.ts
424
+ var json = (body, status = 200) => new Response(JSON.stringify(body), {
425
+ status,
426
+ headers: { "content-type": "application/json", "cache-control": "no-store" }
427
+ });
428
+ function operationFor(pathname, method) {
429
+ const tail = pathname.replace(/\/+$/, "").split("/").pop() ?? "";
430
+ if (method === "POST" && (tail === "connect" || tail === "call")) return "connect";
431
+ if (method === "POST" && (tail === "end" || tail === "release")) return "end";
432
+ if (method === "GET" && tail === "avatars") return "avatars";
433
+ if (method === "GET" && tail === "credits") return "credits";
434
+ return null;
435
+ }
436
+ function createProxyHandler(config) {
437
+ const minted = /* @__PURE__ */ new Map();
438
+ const MINTED_TTL_MS = 30 * 6e4;
439
+ return async (request) => {
440
+ const url = new URL(request.url);
441
+ const operation = operationFor(url.pathname, request.method);
442
+ if (!operation) return json({ error: "not found" }, 404);
443
+ const refusal = await config.authorize?.({ request, operation });
444
+ if (refusal instanceof Response) return refusal;
445
+ const apiKey = typeof config.apiKey === "function" ? await config.apiKey() : config.apiKey;
446
+ const rta = new RealtimeAvatar({ apiKey, baseUrl: config.baseUrl });
447
+ try {
448
+ if (operation === "avatars") return json({ data: await rta.listAvatars() });
449
+ if (operation === "credits") return json(await rta.creditBalance());
450
+ if (operation === "end") {
451
+ const ended = await request.json().catch(() => ({}));
452
+ const sessionId = ended.session_id ?? ended.queue_ticket_id;
453
+ if (!sessionId) return json({ error: "session_id or queue_ticket_id is required" }, 422);
454
+ const owns = config.ownsSession ? await config.ownsSession({ request, sessionId }) : minted.delete(sessionId);
455
+ if (!owns) return new Response(null, { status: 204 });
456
+ const reason = ended.reason === "page_hide" || ended.reason === "unmount" ? ended.reason : "manual";
457
+ await rta.endCall(sessionId, { reason });
458
+ return new Response(null, { status: 204 });
459
+ }
460
+ const body = await request.json().catch(() => ({}));
461
+ if (!body.avatarId) return json({ error: "avatarId is required" }, 422);
462
+ const mode = body.mode === "voice" ? "voice" : "avatar";
463
+ const decided = await config.session?.({ request, avatarId: body.avatarId, mode });
464
+ if (decided instanceof Response) return decided;
465
+ const call = await rta.startCall({ avatarId: body.avatarId, mode, ...decided ?? {} });
466
+ if (isQueued(call)) {
467
+ if (call.queueTicketId) minted.set(call.queueTicketId, Date.now());
468
+ return json(
469
+ {
470
+ queued: true,
471
+ position: call.position,
472
+ size: call.size,
473
+ retryAfterMs: call.retryAfterMs,
474
+ queue_ticket_id: call.queueTicketId
475
+ },
476
+ 429
477
+ );
478
+ }
479
+ const now = Date.now();
480
+ for (const [id, at] of minted) if (now - at > MINTED_TTL_MS) minted.delete(id);
481
+ minted.set(call.sessionId, now);
482
+ return json(call.raw);
483
+ } catch (error) {
484
+ if (error instanceof RealtimeAvatarHttpError && error.isBilling) {
485
+ return json({ code: error.code ?? "insufficient_credits" }, 402);
486
+ }
487
+ throw error;
488
+ }
489
+ };
490
+ }
491
+
492
+ // ../proxy/src/nextjs.ts
493
+ function createRealtimeAvatarRoute(config) {
494
+ const handler = createProxyHandler(config);
495
+ return { GET: handler, POST: handler };
496
+ }
497
+
498
+ export { createRealtimeAvatarRoute };