Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 28 additions & 7 deletions examples/nextjs-realtime/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,18 +28,39 @@ pnpm dev
## Features

- Real-time webcam video transformation
- A fresh client token for every connect and reconnect (`apiKeyProvider`), minted on the server
- Dynamic style prompt updates
- Connection state management
- Connection state display, including reconnects
- Error handling

## How it works

1. The frontend requests a short-lived client token from `/api/realtime-token`
2. The backend uses `client.tokens.create()` to generate the token
3. The frontend uses the client token to connect to Decart's Realtime API
4. Webcam feed is captured and sent to Decart's realtime API
5. Transformed video is displayed side-by-side with the original
6. You can change the style prompt in real-time
Client tokens expire **60 seconds** after minting by default, so a token fetched on page load is
usually dead by the time the user has granted the camera and pressed Start. Instead of holding a
token, the page hands the SDK a function that fetches one:

```ts
// components/video-stream.tsx
const client = createDecartClient({
apiKeyProvider: async () => {
const response = await fetch("/api/realtime-token", { method: "POST" });
const { apiKey } = await response.json();
return apiKey;
},
});
```

1. Pressing **Start** captures the webcam and calls `client.realtime.connect(...)`.
2. The SDK calls `apiKeyProvider`, which `POST`s to `/api/realtime-token`.
3. The route mints a token with `client.tokens.create({ expiresIn: 60 })` using the permanent
`DECART_API_KEY`, which never leaves the server.
4. The SDK dials with that token. If the session drops, the SDK reconnects and calls
`apiKeyProvider` again, so a reconnect never reuses the token the session started with.
5. The transformed video is shown next to the original; the prompt can be changed live.

If you prefer a static `apiKey`, mint it right before `connect()`. The SDK reads the token's
`exp` before dialling and rejects an expired one with `TOKEN_EXPIRED` instead of opening a
socket.

## Models

Expand Down
12 changes: 9 additions & 3 deletions examples/nextjs-realtime/app/api/realtime-token/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,22 @@ import { NextResponse } from "next/server";

const DECART_API_KEY = process.env.DECART_API_KEY;

/**
* Mints a short-lived client token with the permanent API key, which never leaves the server.
* The browser's `apiKeyProvider` calls this right before every realtime connect and reconnect.
*/
export async function POST() {
try {
if (!DECART_API_KEY) {
return NextResponse.json({ error: "DECART_API_KEY is not set" }, { status: 500 });
}

const client = createDecartClient({
apiKey: DECART_API_KEY,
const client = createDecartClient({ apiKey: DECART_API_KEY });
const token = await client.tokens.create({
// Seconds until the token expires (1-3600, default 60). The SDK asks for a token right before
// each dial, so a short TTL is enough; raise it only if you mint ahead of connecting.
expiresIn: 60,
});
const token = await client.tokens.create();

return NextResponse.json(token);
} catch (error) {
Expand Down
160 changes: 89 additions & 71 deletions examples/nextjs-realtime/components/video-stream.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,29 @@
import { createDecartClient, type DecartSDKError, models, type RealTimeClient } from "@decartai/sdk";
import { useEffect, useRef, useState } from "react";

const model = models.realtime("lucy-restyle-2");

/**
* Asks our backend for a fresh client token. The SDK calls this right before every connect and
* reconnect, so the token is minted for that dial: it cannot have expired while the page sat open
* or the user was deciding about camera permissions. (Client tokens live 60 s by default.)
*/
async function fetchClientToken(): Promise<string> {
const response = await fetch("/api/realtime-token", { method: "POST" });
if (!response.ok) throw new Error(`Token endpoint answered ${response.status}`);
const { apiKey } = await response.json();
return apiKey;
}

// One client for the page's lifetime. It holds no credential of its own; the provider supplies one per dial.
const client = createDecartClient({ apiKeyProvider: fetchClientToken });

function describeError(error: unknown): string {
if (error instanceof Error) return error.message;
const sdkError = error as Partial<DecartSDKError>;
return sdkError?.code ? `${sdkError.code}: ${sdkError.message}` : String(error);
}

interface VideoStreamProps {
prompt: string;
}
Expand All @@ -11,81 +34,71 @@ export function VideoStream({ prompt }: VideoStreamProps) {
const inputRef = useRef<HTMLVideoElement>(null);
const outputRef = useRef<HTMLVideoElement>(null);
const realtimeClientRef = useRef<RealTimeClient | null>(null);
const cameraRef = useRef<MediaStream | null>(null);
// Bumped by release(), so a start() still waiting on the camera prompt or on connect() notices
// that Stop (or unmount) happened and lets go of what it was about to keep.
const attemptRef = useRef(0);
const [status, setStatus] = useState<string>("idle");

useEffect(() => {
let mounted = true;

async function start() {
try {
const model = models.realtime("lucy-restyle-2");

setStatus("requesting camera...");
const stream = await navigator.mediaDevices.getUserMedia({
video: {
frameRate: model.fps,
width: model.width,
height: model.height,
},
});

if (!mounted) return;

if (inputRef.current) {
inputRef.current.srcObject = stream;
}

// Fetch client token from our backend API
const tokenResponse = await fetch("/api/realtime-token", {
method: "POST",
});
if (!tokenResponse.ok) {
throw new Error("Failed to get client token");
}
const { apiKey } = await tokenResponse.json();

if (!mounted) return;

setStatus("connecting...");

const client = createDecartClient({ apiKey });

const realtimeClient = await client.realtime.connect(stream, {
model,
onRemoteStream: (transformedStream: MediaStream) => {
if (outputRef.current) {
outputRef.current.srcObject = transformedStream;
}
},
initialState: {
prompt: { text: prompt, enhance: true },
},
});

realtimeClientRef.current = realtimeClient;

// Subscribe to events
realtimeClient.on("connectionChange", (state) => {
setStatus(state);
});

realtimeClient.on("error", (error: DecartSDKError) => {
setStatus(`error: ${error.message}`);
});
} catch (error) {
setStatus(`error: ${error}`);
const [running, setRunning] = useState(false);

function release() {
attemptRef.current++;
realtimeClientRef.current?.disconnect();
realtimeClientRef.current = null;
for (const track of cameraRef.current?.getTracks() ?? []) track.stop();
cameraRef.current = null;
}

function stop() {
release();
setRunning(false);
setStatus("idle");
}

async function start() {
const attempt = ++attemptRef.current;
const cancelled = () => attemptRef.current !== attempt;
setRunning(true);
try {
setStatus("requesting camera...");
const camera = await navigator.mediaDevices.getUserMedia({
video: { frameRate: model.fps, width: model.width, height: model.height },
});
if (cancelled()) {
for (const track of camera.getTracks()) track.stop();
return;
}
cameraRef.current = camera;
if (inputRef.current) inputRef.current.srcObject = camera;

setStatus("connecting...");
const realtimeClient = await client.realtime.connect(camera, {
model,
onRemoteStream: (transformedStream) => {
if (outputRef.current) outputRef.current.srcObject = transformedStream;
},
onConnectionChange: setStatus,
initialState: { prompt: { text: prompt, enhance: true } },
});
if (cancelled()) {
realtimeClient.disconnect();
return;
Comment thread
AdirAmsalem marked this conversation as resolved.
}
realtimeClientRef.current = realtimeClient;

realtimeClient.on("error", (error) => setStatus(`error: ${describeError(error)}`));
} catch (error) {
if (cancelled()) return;
release();
setRunning(false);
setStatus(`error: ${describeError(error)}`);
}
}
Comment thread
cursor[bot] marked this conversation as resolved.

start();

return () => {
mounted = false;
realtimeClientRef.current?.disconnect();
};
}, []);
// Release the camera and the session when the component unmounts.
useEffect(() => release, []);

// Update prompt when it changes
// Update the prompt on the running session when it changes.
useEffect(() => {
if (realtimeClientRef.current?.isConnected()) {
realtimeClientRef.current.setPrompt(prompt, { enhance: true });
Expand All @@ -94,7 +107,12 @@ export function VideoStream({ prompt }: VideoStreamProps) {

return (
<div>
<p>Status: {status}</p>
<p>
<button type="button" onClick={running ? stop : start}>
{running ? "Stop" : "Start"}
</button>
<span style={{ marginLeft: "1rem" }}>Status: {status}</span>
</p>
<div style={{ display: "flex", gap: "1rem" }}>
<div>
<h3>Input</h3>
Expand Down
1 change: 1 addition & 0 deletions packages/sdk/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
- `types.ts` - TypeScript types for queue operations
- **src/realtime/** - LiveKit-backed real-time video streaming logic
- `client.ts` - Real-time client implementation and public event surface
- `credential.ts` - Per-dial credential: `apiKeyProvider` and the client-token expiry preflight (TOKEN_EXPIRED before any dial)
- `livekit-manager.ts` - LiveKit connection lifecycle and retry management
- `livekit-connection.ts` - LiveKit room connection and control WebSocket handling
- `methods.ts` - Realtime method implementations
Expand Down
34 changes: 34 additions & 0 deletions packages/sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,40 @@ clockTolerance }`. This is an offline check, not an API call. It needs WebCrypto
runtimes without it (React Native) `verify` rejects with `UNSUPPORTED_PLATFORM_FEATURE`;
`decodeClientToken` works everywhere.

#### Token lifetime: mint right before connecting

Client tokens expire **60 seconds** after minting by default (`expiresIn`, 1-3600 s). A token
minted on page load or component mount is usually dead by the time the user has granted camera
access and pressed start, and a reconnect later with the same token is refused too. The SDK reads
the token's `exp` before every realtime dial (connect, connect retry and reconnect) and rejects an
expired one with `TOKEN_EXPIRED` instead of dialling: no socket is opened, and the message says how
long ago it expired. A few seconds of clock skew are tolerated.

Two ways to keep the token fresh:

- Mint it right before `connect()`, after `getUserMedia` and the user's click, and size
`expiresIn` to your connect window, e.g. `client.tokens.create({ expiresIn: 300 })`.
- Pass `apiKeyProvider` instead of (or alongside) `apiKey`. The SDK calls it before every connect
and reconnect and dials with what it returns, so each dial carries a token minted for it:

```ts
const client = createDecartClient({
apiKeyProvider: async () => {
// Your server: `client.tokens.create(...)` with your permanent API key.
const res = await fetch("/api/realtime-token", { method: "POST" });
const { apiKey } = await res.json();
return apiKey;
},
});

const realtimeClient = await client.realtime.connect(stream, { model, onRemoteStream });
```

If the provider rejects during `connect()`, `connect()` rejects with that error; during a
reconnect a provider failure is retried like any other dial failure. The provider serves the
realtime APIs (`connect` and `subscribe`); `process`, `queue`, `files` and `tokens` still use
`apiKey` or `proxy`.

### React Native / Expo

React Native realtime requires [LiveKit's React Native packages](https://github.com/livekit/client-sdk-react-native)
Expand Down
Loading
Loading