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/README.md +176 -0
- package/dist/browser.d.ts +203 -0
- package/dist/browser.js +172 -0
- package/dist/express.d.ts +28 -0
- package/dist/express.js +510 -0
- package/dist/hono.d.ts +19 -0
- package/dist/hono.js +498 -0
- package/dist/index.d.ts +192 -0
- package/dist/index.js +453 -0
- package/dist/nextjs.d.ts +18 -0
- package/dist/nextjs.js +498 -0
- package/dist/proxy-client-cZyX50-O.d.ts +1508 -0
- package/dist/react-native.d.ts +103 -0
- package/dist/react-native.js +2305 -0
- package/dist/react.d.ts +107 -0
- package/dist/react.js +2610 -0
- package/dist/server-only-guard.d.ts +2 -0
- package/dist/server-only-guard.js +4 -0
- package/dist/server.d.ts +2 -0
- package/dist/server.js +453 -0
- package/dist/tanstack-start.d.ts +24 -0
- package/dist/tanstack-start.js +498 -0
- package/dist/tools.d.ts +111 -0
- package/dist/tools.js +121 -0
- package/dist/types-C_EMPwN7.d.ts +831 -0
- package/dist/types-E8SrD6sv.d.ts +48 -0
- package/package.json +150 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,453 @@
|
|
|
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/webhook.ts
|
|
419
|
+
async function verifyTranscript(rawBody, headers, secret, options = {}) {
|
|
420
|
+
const get = (name) => headers instanceof Headers ? headers.get(name) ?? void 0 : headers[name];
|
|
421
|
+
const signature = get("x-rta-signature");
|
|
422
|
+
const timestamp = get("x-rta-timestamp");
|
|
423
|
+
if (!signature || !timestamp) throw new Error("missing x-rta-signature / x-rta-timestamp");
|
|
424
|
+
const skew = Math.abs(Date.now() / 1e3 - Number(timestamp));
|
|
425
|
+
if (!Number.isFinite(skew) || skew > (options.toleranceSeconds ?? 300)) {
|
|
426
|
+
throw new Error("transcript webhook timestamp is outside the replay window");
|
|
427
|
+
}
|
|
428
|
+
const text = typeof rawBody === "string" ? rawBody : new TextDecoder().decode(rawBody);
|
|
429
|
+
const key = await crypto.subtle.importKey(
|
|
430
|
+
"raw",
|
|
431
|
+
new TextEncoder().encode(secret),
|
|
432
|
+
{ name: "HMAC", hash: "SHA-256" },
|
|
433
|
+
false,
|
|
434
|
+
["sign"]
|
|
435
|
+
);
|
|
436
|
+
const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(`${timestamp}.${text}`));
|
|
437
|
+
const expected = `v1=${[...new Uint8Array(mac)].map((b) => b.toString(16).padStart(2, "0")).join("")}`;
|
|
438
|
+
if (!timingSafeEqual(expected, signature)) throw new Error("transcript webhook signature mismatch");
|
|
439
|
+
return JSON.parse(text);
|
|
440
|
+
}
|
|
441
|
+
function timingSafeEqual(a, b) {
|
|
442
|
+
if (a.length !== b.length) return false;
|
|
443
|
+
let diff = 0;
|
|
444
|
+
for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
445
|
+
return diff === 0;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ../http-client/src/types.ts
|
|
449
|
+
function isQueued(result) {
|
|
450
|
+
return "queued" in result;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export { RealtimeAvatar, RealtimeAvatarError, RealtimeAvatarHttpError, isQueued, verifyTranscript };
|
package/dist/nextjs.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { P as ProxyConfig } from './types-E8SrD6sv.js';
|
|
2
|
+
import './types-C_EMPwN7.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* App Router. Mount at `app/api/realtime-avatar/[...path]/route.ts`:
|
|
6
|
+
*
|
|
7
|
+
* ```ts
|
|
8
|
+
* export const { GET, POST } = createRealtimeAvatarRoute({ apiKey: process.env.KEY!, session });
|
|
9
|
+
* ```
|
|
10
|
+
*
|
|
11
|
+
* Name the variable without `NEXT_PUBLIC_` — that prefix inlines it into the client bundle.
|
|
12
|
+
*/
|
|
13
|
+
declare function createRealtimeAvatarRoute(config: ProxyConfig): {
|
|
14
|
+
GET: (request: Request) => Promise<Response>;
|
|
15
|
+
POST: (request: Request) => Promise<Response>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export { createRealtimeAvatarRoute };
|