Discord

Live video SDKs

JavaScript SDK

An ES-module client for Node 18+ and browsers: zero dependencies, uses the platform fetch, and ships hand-written TypeScript types. Read the SDK overview first for the concepts (broker model, tokens, connect, heartbeat).

Published. Both SDKs are on PyPI and npm as videorouter-live. A live-video model can only be booked once its price is verified (see Live video & world models), and the SDKs surface that as PricingUnavailableError.
npm install videorouter-live

The package is ESM-only ("type": "module"). From CommonJS, load it with await import("videorouter-live").

Quickstart: server → browser

import { LiveVideo } from "videorouter-live";

const lv = new LiveVideo({ apiKey: process.env.VIDEOROUTER_API_KEY });

// Your server, per page load / per user. The token is safe to hand to a browser.
const { token } = await lv.createToken({
  models: ["fal/happy-oyster-adventure"],
  expiresIn: 300,                                  // seconds
  maxSessions: 1,
  maxSessionDurationSeconds: 60,
});
res.json({ token });                               // e.g. from your /api/live-token route

The browser never sees your API key: it gets a short-lived token. tokenProvider mirrors the apiKeyProvider pattern of realtime SDKs — it is called for the first request, and once more if the server answers 401 (an expired token).

withSession

import { LiveVideo } from "videorouter-live";

const lv = new LiveVideo({ apiKey: process.env.VIDEOROUTER_API_KEY });

// open -> wait until ready -> heartbeat -> your code -> settle (in a finally)
const result = await lv.withSession(
  { model: "decart/lucy-2.5", maxDurationSeconds: 120, options: { speed: "standard" } },
  async (session) => {
    await sendToBrowser(session.connect);      // host credentials - returned once
    return await waitForUserToFinish();
  },
);

Options (third argument): waitUntilReady = true, autoHeartbeat = true, readyTimeout = 300000, pollInterval = 4000, heartbeatInterval (ms). It returns whatever your function returns; the session is always settled in a finally, and a failure to settle never masks your own error.

Manual lifecycle & events

const s = await lv.createSession({
  model: "fal/happy-oyster-adventure",
  maxDurationSeconds: 60,
  options: { world: { prompt: "x", perspective: "third_person" } },
});
console.log(s.status);                         // "waiting" - a world is building, nothing is metered

s.on("statusChanged", (st) => console.log("->", st));
s.once("ended", (sess) => console.log("settled:", sess.costUsd));
s.on("error", (e) => console.warn("heartbeat problem:", e.code));

await s.waitUntilReady({ timeout: 180_000, pollInterval: 4_000 });   // the poll that sees it ready returns s.connect
s.startHeartbeat();                            // setInterval, unref()'d in Node
use(s.connect);

await s.end({ hostTravelId: "..." });          // optional (Happy Oyster): bill on the host's own duration + fetch the replay

Use the manual flow when the session outlives one function. getSession(id) rebuilds a handle from an id (it never carries connect). waitUntilReady also accepts an AbortSignal.

Model wrappers

One class per live model, so a browser app reads like the model it drives. Each wrapper does the broker half with LiveVideo (open, wait until ready, heartbeat, settle) and the host half with the host’s own browser SDK, which it loads lazily. Install that SDK next to this one — @happy-oyster/js-sdk, @decartai/sdk, @odysseyml/odyssey are all optional peer dependencies — or pass it in as hostSdk.

import { HappyOysterModel } from "videorouter-live";

const happyOyster = new HappyOysterModel({ mode: "adventure", videoElement });
await happyOyster.connect(token);          // a browser token from lv.createToken() on your server

// Describe the world
await happyOyster.createWorld({ prompt: "A sunlit meadow with an old oak." });

// Start streaming
await happyOyster.startTravel();
happyOyster.sendCommand({ translation: "Front" });

await happyOyster.end();                   // stops the stream, then settles the session
import { LucyModel } from "videorouter-live";

const lucy = new LucyModel({ model: "decart/lucy-2.5" });
await lucy.connect(token);

const stream = await navigator.mediaDevices.getUserMedia({ video: true });
await lucy.start(stream, { prompt: "Anime style", onRemoteStream: (s) => (video.srcObject = s) });
lucy.setPrompt("Cyberpunk");               // change the edit on the fly
await lucy.end();
import { OdysseyModel } from "videorouter-live";

const odyssey = new OdysseyModel();
await odyssey.connect(token);
await odyssey.start({ prompt: "A cat walking through a neon city" });
odyssey.interact("make it rain");
await odyssey.end();
import { ViduAvatarModel } from "videorouter-live";

// Needs Alibaba Cloud RTC in the page: `npm i aliyun-rtc-sdk`, or load its CDN script, or pass { hostSdk: { AliRtcEngine } }.
const tina = new ViduAvatarModel({ callMode: "video" });       // or "audio"
await tina.connect(token);
await tina.start({
  persona: "You are Tina, a friendly product guide.",
  imageUri: "https://example.com/tina.png",
  voice: "Tina",
  videoElement: document.getElementById("character"),   // her video
  previewElement: document.getElementById("me"),
});
tina.say("Hi! What can you show me?");                  // she answers in audio and video
tina.interrupt();                                       // cut her off mid-sentence
await tina.end();                                       // hangs up, then settles
import { ViduEditingModel } from "videorouter-live";

const edit = new ViduEditingModel({ editingType: "virtual_tryon" });
await edit.connect(token);
await edit.start({ imageUrl: "https://example.com/jacket.png", videoElement: document.getElementById("result") });
edit.switchPrompt({ imageUrl: "https://example.com/coat.png" });   // no ack on success; each call is a real switch
edit.switchPrompt({ editingType: "background_replacement" });
await edit.end();
import { ViduComponentAvatarModel } from "videorouter-live";
import WebSocket from "ws";                       // server: a WebSocket that accepts headers (an API key never goes in a URL)

const tina = new ViduComponentAvatarModel({ webSocket: WebSocket });
await tina.connect({ apiKey: process.env.VIDEOROUTER_API_KEY });   // in a browser: connect(token) and omit webSocket
await tina.start({
  imageUri: "https://example.com/tina.png",
  rtcInfo: { provider: "agora", app_id, channel_id, user_id, token },   // omit it and Vidu hosts a channel: tina.managedRtc
});
tina.sendAudio(pcmChunk);                // raw PCM, 16-bit signed little-endian, 24 kHz, mono
tina.sendUserText("What time is it?");   // what the user said (your ASR)
tina.sendReplyText("It is noon.");       // what your model answered
tina.interrupt();                        // the user started talking over her
await tina.end();                        // hangs up and settles; closing the stream does the same
Preview. The wrappers are unit-tested against fake host SDKs; their call shapes follow each host’s public docs but have not been run end to end against a live session yet. The Vidu wrappers are the exception: they were run end to end against Vidu’s live API in headless Chrome with a fake camera and microphone — control connection, Alibaba Cloud RTC join, decoded remote video, hangup and billing all worked. Happy Oyster’s directing mode assumes the browser SDK model id happyoyster-1.0-directing — override it with hostModel if the host says otherwise. Vidu wrappers also take webSocket (Node), ackTimeout and retryDelay. Options: hostSdk, maxDurationSeconds, pollInterval, baseUrl.

Generating videos

The same client wraps the /v1/videos generation API. Params are the API’s own fields, passed through unchanged (see Video generation). It needs an API key — a browser token from createToken is live-video only. Creating a job is never retried, so a flaky network cannot bill you twice.

import { LiveVideo } from "videorouter-live";

const lv = new LiveVideo();                // reads VIDEOROUTER_API_KEY

// Create and wait
const job = await lv.videos.generate({
  model: "heygen-video-1",
  prompt: "A presenter says: 'Welcome to VideoRouter.'",
  duration_secs: 5,
});
console.log(job.url);

// Or step by step
const queued = await lv.videos.create({ model: "wan-3.0-prime", prompt: "A robotic arm in a lab" });
await queued.wait({ timeout: 600_000, pollInterval: 5_000 });   // throws VideoGenerationError / TimeoutError
const again = await lv.videos.get(queued.id);

job.status, job.done, job.failed, job.url / job.urls, job.error and job.raw are available on every job.

Handling errors

import { InsufficientCreditsError, ConcurrencyLimitError, HostCapacityError, PricingUnavailableError, LiveVideoError } from "videorouter-live";

try {
  await lv.createSession({ model: "fal/happy-oyster-adventure", maxDurationSeconds: 60 });
} catch (e) {
  if (e instanceof InsufficientCreditsError) { /* top up, or book fewer seconds */ }
  else if (e instanceof ConcurrencyLimitError) { /* end a session first; e.retryAfter is set */ }
  else if (e instanceof HostCapacityError) { await sleep((e.retryAfter ?? 5) * 1000); }
  else if (e instanceof PricingUnavailableError) { /* model.available === false */ }
  else if (e instanceof LiveVideoError) { console.log(e.status, e.code, e.message); }
  else throw e;
}

The full class table is in the overview. Every class extends LiveVideoError (itself an Error) with status, code, type, retryAfter (seconds) and body.

Reference

LiveVideo

Constructor optionDescription
apiKeyYour server key. Falls back to process.env.VIDEOROUTER_API_KEY in Node. Never in a browser.
tokenA short-lived token from createToken — browser-safe.
tokenProvider() => string | Promise<string>. Called for the first request and once more on a 401.
baseUrlhttps://videorouter.sh/api/v1 by default.
fetchA custom fetch (tests, proxies, older runtimes).
maxRetriesDefault 2, for reads / heartbeats / end only. Creates are never retried.
timeoutPer-request timeout in ms, default 30000.
MethodReturns / behavior
models()Promise<Model[]>. available is false until the model has a verified price.
createToken({ models, expiresIn, maxSessions, maxSessionDurationSeconds })Promise<Token>. Call from your server. expiresIn is seconds, 1–21,600.
createSession({ model, maxDurationSeconds, options })Promise<LiveSession>. Never retried.
getSession(id)Promise<LiveSession> (no connect).
withSession(params, fn, opts)See above.

LiveSession

MemberDescription
id · getSessionId()The session id (lvs_...).
modelThe model slug.
status · getStatus()"waiting" | "ready" | "ended".
connectHost credentials, or null (waiting, or a re-fetched session).
worldId, billedSeconds, costUsd, endReason, recordingUrl, expiresAtServer fields; the billing fields are set once ended.
rawA copy of the last server response.
refresh()Re-read. For a waiting session this advances it; the call that sees the world built returns connect.
waitUntilReady({ timeout, pollInterval, signal })Throws TimeoutError or SessionStateError (ended while waiting).
heartbeat()One beat. Throws SessionStateError on an ended session.
startHeartbeat(ms?) · stopHeartbeat()Background beat via setInterval (unref()’d in Node so it never keeps a process alive). Default: the server’s 5 s. Stops itself on 404 / 401.
end({ hostTravelId })Settle. Idempotent. hostTravelId (Happy Oyster) bills on the host’s own duration and fetches the replay.
on / once / offEvents statusChanged, ended, heartbeat, error. Unknown names throw.
getLastError()The last background-heartbeat error.

TypeScript

import { LiveVideo, type LiveSession, type Model, type SessionStatus } from "videorouter-live";

const lv = new LiveVideo({ token });
const models: Model[] = await lv.models();
const bookable = models.filter((m) => m.available && m.kind === "world");

const session: LiveSession = await lv.createSession({ model: bookable[0].id });
session.on("statusChanged", (status: SessionStatus) => {});
// session.on("frame", ...)   // type error: not an event

The exported types are LiveVideoOptions, Model, Token, CreateTokenParams, CreateSessionParams, WithSessionOptions, SessionStatus, EndReason, Connect, SessionEvents and every error class. Event names are checked at compile time.

Testing your code

import { LiveVideo } from "videorouter-live";

const lv = new LiveVideo({
  apiKey: "test",
  baseUrl: "http://test/v1",
  fetch: async () => new Response(JSON.stringify({ object: "list", data: [] }), { status: 200 }),
});
console.assert((await lv.models()).length === 0);