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
1 change: 1 addition & 0 deletions examples/tanstack-streamer/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
DECART_API_KEY=your-api-key-here
7 changes: 7 additions & 0 deletions examples/tanstack-streamer/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
node_modules
dist
.output
.vinxi
.env
*.local
routeTree.gen.ts
58 changes: 58 additions & 0 deletions examples/tanstack-streamer/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
# TanStack Start Streamer Example

A [TanStack Start](https://tanstack.com/start) application demonstrating the producer + subscriber realtime pattern with the Decart SDK.

## Setup

1. Copy `.env.example` to `.env` and add your API key:

```sh
cp .env.example .env
```

2. Install dependencies & build:

```sh
pnpm install
pnpm build
```

3. Start the development server:

```sh
pnpm dev
```

4. Open [http://localhost:3000](http://localhost:3000) in your browser.

## Features

- Real-time webcam video transformation using `lucy_2_rt`
- Producer + subscriber streaming pattern
- Shareable viewer link via subscribe token
- Dynamic style prompt updates
- Connection state management
- Error handling

## Routes

| Route | Description |
|-------|-------------|
| `/` | **Producer** — streams your camera through `lucy_2_rt`, shows styled output, and generates a shareable viewer link |
| `/watch?token=...` | **Subscriber** — watches the producer's styled stream (receive-only, no camera needed) |

## How it works

1. The **server function** (`src/server/token.ts`) creates a short-lived client token via `client.tokens.create()` so the API key never leaves the server
2. The **producer** page captures the webcam and connects with `client.realtime.connect()`
3. Once connected, `realtimeClient.subscribeToken` is exposed as a shareable URL
4. The **subscriber** page receives the token via URL search params and calls `client.realtime.subscribe()` to view the same stream
5. The producer can update the style prompt in real-time with `realtimeClient.setPrompt()`

## Models

This example uses `lucy_2_rt` for video editing with reference image support. You can also use:

- `mirage` - MirageLSD video restyling model (older)
- `mirage_v2` - MirageLSD v2 for style transformation
- `lucy_v2v_720p_rt` - Lucy for video editing (add objects, change elements)
29 changes: 29 additions & 0 deletions examples/tanstack-streamer/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"name": "@example/tanstack-streamer",
"version": "0.0.0",
"private": true,
"sideEffects": false,
"type": "module",
"scripts": {
"dev": "vite dev",
"build": "vite build",
"start": "node .output/server/index.mjs"
},
"dependencies": {
"@decartai/sdk": "workspace:*",
"@tanstack/react-router": "^1.159.5",
"@tanstack/react-start": "^1.159.5",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"zod": "^4.0.17"
},
"devDependencies": {
"@types/node": "^22.5.4",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.6.0",
"typescript": "^5.8.0",
"vite": "^7.3.1",
"vite-tsconfig-paths": "^5.1.4"
}
}
15 changes: 15 additions & 0 deletions examples/tanstack-streamer/src/router.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { createRouter } from "@tanstack/react-router";
import { routeTree } from "./routeTree.gen";

export function getRouter() {
return createRouter({
routeTree,
scrollRestoration: true,
});
}

declare module "@tanstack/react-router" {
interface Register {
router: ReturnType<typeof getRouter>;
}
}
31 changes: 31 additions & 0 deletions examples/tanstack-streamer/src/routes/__root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { createRootRoute, HeadContent, Outlet, Scripts } from "@tanstack/react-router";

export const Route = createRootRoute({
head: () => ({
meta: [
{ charSet: "utf-8" },
{ name: "viewport", content: "width=device-width, initial-scale=1" },
{ title: "Decart Streamer — Producer + Subscriber Demo" },
],
}),
component: RootComponent,
shellComponent: RootDocument,
});

function RootDocument({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
{children}
<Scripts />
</body>
</html>
);
}

function RootComponent() {
return <Outlet />;
}
163 changes: 163 additions & 0 deletions examples/tanstack-streamer/src/routes/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { type ConnectionState, createDecartClient, models, type RealTimeClient } from "@decartai/sdk";
import { createFileRoute } from "@tanstack/react-router";
import { useCallback, useEffect, useRef, useState } from "react";
import { getClientToken } from "~/server/token";

export const Route = createFileRoute("/")({
component: ProducerPage,
});

function ProducerPage() {
const inputRef = useRef<HTMLVideoElement>(null);
const outputRef = useRef<HTMLVideoElement>(null);
const clientRef = useRef<RealTimeClient | null>(null);

const [status, setStatus] = useState<ConnectionState | "idle" | "requesting-camera">("idle");
const [prompt, setPrompt] = useState("cinematic, film grain, moody lighting");
const [shareUrl, setShareUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);

const start = useCallback(async () => {
try {
const model = models.realtime("lucy_2_rt");

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

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

setStatus("connecting");

const { apiKey } = await getClientToken();
const client = createDecartClient({ apiKey });

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

clientRef.current = realtimeClient;

realtimeClient.on("connectionChange", (state) => {
setStatus(state);

if ((state === "connected" || state === "generating") && realtimeClient.subscribeToken) {
const url = new URL("/watch", window.location.origin);
url.searchParams.set("token", realtimeClient.subscribeToken);
setShareUrl(url.toString());
}
});

realtimeClient.on("error", (err) => {
setError(err.message);
});
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
setStatus("idle");
}
}, [prompt]);

useEffect(() => {
return () => {
clientRef.current?.disconnect();
};
}, []);

const updatePrompt = () => {
if (clientRef.current?.isConnected()) {
clientRef.current.setPrompt(prompt, { enhance: true });
}
};

const copyShareUrl = () => {
if (shareUrl) {
navigator.clipboard.writeText(shareUrl);
}
};

return (
<div style={{ padding: "2rem", fontFamily: "system-ui, sans-serif" }}>
<h1>Producer</h1>
<p style={{ color: "#666" }}>
Streams your camera through <code>lucy_2_rt</code> and generates a subscribe link for viewers.
</p>

{status === "idle" && (
<button type="button" onClick={start} style={buttonStyle}>
Start Streaming
</button>
)}

{error && <p style={{ color: "red" }}>Error: {error}</p>}

<p>
Status: <strong>{status}</strong>
</p>

<div style={{ marginBottom: "1rem", display: "flex", gap: "0.5rem" }}>
<input
type="text"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && updatePrompt()}
style={{ padding: "0.5rem", width: "350px" }}
placeholder="Style prompt..."
/>
<button type="button" onClick={updatePrompt} style={buttonStyle}>
Update
</button>
</div>

{shareUrl && (
<div
style={{
padding: "0.75rem 1rem",
background: "#f0f9f0",
border: "1px solid #b5e2b5",
borderRadius: 6,
marginBottom: "1rem",
display: "flex",
alignItems: "center",
gap: "0.5rem",
}}
>
<span style={{ flex: 1, wordBreak: "break-all", fontSize: "0.85rem" }}>{shareUrl}</span>
<button type="button" onClick={copyShareUrl} style={buttonStyle}>
Copy
</button>
</div>
)}

<div style={{ display: "flex", gap: "1rem", flexWrap: "wrap" }}>
<div>
<h3>Camera Input</h3>
<video ref={inputRef} autoPlay muted playsInline width={480} />
</div>
<div>
<h3>Styled Output</h3>
<video ref={outputRef} autoPlay playsInline width={480} />
</div>
</div>
</div>
);
}

const buttonStyle: React.CSSProperties = {
padding: "0.5rem 1rem",
cursor: "pointer",
};
Loading
Loading