-
- Free tier
-
+
+
+ {showUsdAllowance ? "Starter allowance" : "Usage this period"}
+
+ {showUsdAllowance && !hasAccess && (
+
+ Exhausted
+
+ )}
+
-
-
- {fmt(FREE_USED)}
-
- / {fmt(FREE_LIMIT)} jobs
-
+ {showUsdAllowance ? (
+
+
+ ${microsToUsdDisplay(remaining)}
+
+ / ${microsToUsdDisplay(granted)} remaining
+
+ ) : requestLimit ? (
+
+
+ {fmt(requestCount)}
+
+ / {fmt(requestLimit)} jobs
+
+ ) : (
+
+ {fmt(requestCount)}
+
+ )}
- resets {RESETS_AT}
+ resets {resetsAt}
- {/* Bar with forecast tick */}
-
-
-
-
+
+ {requestLimit && (
+
+ )}
-
+ )}
- {willExceed ? (
+ {requestLimit && willExceed ? (
- Forecast{" "}
- {fmt(forecast)}
- {" "}by {RESETS_AT} · over limit in{" "}
- ~{daysToLimit}d
+ Forecast {fmt(forecast)} jobs by {resetsAt} ·
+ over limit in ~{daysToLimit}d
) : (
- {fmt(left)} jobs left ·
- pace looks fine.
+ {fmt(requestCount)} signed requests this period
+ {showUsdAllowance && (
+ <>
+ {" "}
+ · ${microsToUsdDisplay(usedUsd)} consumed
+ >
+ )}
)}
- Last period {fmt(priorPeriodTotal)} ·{" "}
- {periodDelta >= 0 ? "+" : ""}
+ Last period {fmt(priorRequestCount)} · {periodDelta >= 0 ? "+" : ""}
{periodDelta.toFixed(0)}%
@@ -140,131 +164,141 @@ function UsageStrip({
);
}
-// ── Main view ───────────────────────────────────────────────────────────────
-
export default function UsageView() {
- // 60-day series so we can split into "this period" + "prior period". Stable
- // per mount via useMemo — random noise mustn't flicker between renders.
- const caps = useMemo(
- () =>
- CAPABILITIES.map((c) => ({
- ...c,
- data60: genCapSeries(c.base, c.drift, c.noise, 60),
- })),
- [],
+ const { user } = useAuth();
+ const externalUserId = user?.email?.trim();
+ const usageState = useAccountUsage(externalUserId, PERIOD_DAYS);
+ const [priceMin, setPriceMin] = useState(0);
+ const [priceMax, setPriceMax] = useState(100);
+
+ const capabilityRows = useMemo(() => {
+ if (usageState.status !== "ready") return [];
+ return buildUsageCapabilityRows({
+ current: usageState.data.current.pipelineModels,
+ prior: usageState.data.prior.pipelineModels,
+ period: usageState.data.period,
+ dailyByPipeline: usageState.data.current.dailyByPipeline,
+ });
+ }, [usageState]);
+
+ const dataMaxSpend = useMemo(
+ () => Math.max(...capabilityRows.map((c) => c.spendUsd), 0.01),
+ [capabilityRows],
);
- const sliced = caps.map((c) => ({
- ...c,
- data: c.data60.slice(-PERIOD_DAYS),
- prior: c.data60.slice(-PERIOD_DAYS * 2, -PERIOD_DAYS),
- }));
-
- const totals = sliced.map((c) => {
- const sum = c.data.reduce((a, b) => a + b, 0);
- const priorSum = c.prior.reduce((a, b) => a + b, 0);
- const delta = priorSum > 0 ? ((sum - priorSum) / priorSum) * 100 : 0;
- return { ...c, sum, priorSum, delta, spend: sum * c.price };
- });
- const grandReq = totals.reduce((a, c) => a + c.sum, 0);
- const grandSpend = totals.reduce((a, c) => a + c.spend, 0);
- const totalsByDay = sliced[0].data.map((_, i) =>
- sliced.reduce((a, c) => a + c.data[i], 0),
- );
- const priorTotalsByDay = sliced[0].prior.map((_, i) =>
- sliced.reduce((a, c) => a + c.prior[i], 0),
- );
- const priorPeriodTotal = Math.round(
- priorTotalsByDay.reduce((a, b) => a + b, 0),
- );
- const periodDelta =
- priorPeriodTotal > 0
- ? ((FREE_USED - priorPeriodTotal) / priorPeriodTotal) * 100
- : 0;
+ const filteredRows = useMemo(() => {
+ return capabilityRows.filter((c) => {
+ const matchesPrice =
+ c.spendUsd >= (priceMin / 100) * dataMaxSpend &&
+ c.spendUsd <= (priceMax / 100) * dataMaxSpend;
+ return matchesPrice;
+ });
+ }, [capabilityRows, priceMin, priceMax, dataMaxSpend]);
+
+ const periodDayCount = useMemo(() => {
+ if (usageState.status !== "ready") return PERIOD_DAYS;
+ const first = capabilityRows[0]?.data.length;
+ return first && first > 0 ? first : PERIOD_DAYS;
+ }, [usageState, capabilityRows]);
+
+ const forecastStats = useMemo(() => {
+ if (usageState.status !== "ready") {
+ return {
+ forecast: 0,
+ willExceed: false,
+ daysToLimit: 0,
+ priorRequestCount: 0,
+ periodDelta: 0,
+ requestCount: 0,
+ };
+ }
+ const { current, prior } = usageState.data;
+ const dayCount = capabilityRows[0]?.data.length ?? PERIOD_DAYS;
+ const totalsByDay = Array.from({ length: dayCount }, (_, dayIndex) =>
+ capabilityRows.reduce((sum, row) => sum + (row.data[dayIndex] ?? 0), 0),
+ );
+ const last7Avg =
+ totalsByDay.slice(-7).reduce((a, b) => a + b, 0) / Math.max(1, Math.min(7, totalsByDay.length));
+ const daysLeft = 6;
+ const forecast = Math.round(current.requestCount + last7Avg * daysLeft);
+ const grantedJobs = usageState.data.balance?.lifetimeGrantedUsdMicros
+ ? null
+ : 10_000;
+ const limit = grantedJobs ?? 10_000;
+ const willExceed = forecast > limit;
+ const left = limit - current.requestCount;
+ const daysToLimit =
+ left > 0 && last7Avg > 0 ? Math.max(0, Math.floor(left / last7Avg)) : 0;
+ const priorRequestCount = prior.requestCount;
+ const periodDelta =
+ priorRequestCount > 0
+ ? ((current.requestCount - priorRequestCount) / priorRequestCount) * 100
+ : 0;
+
+ return {
+ forecast,
+ willExceed,
+ daysToLimit,
+ priorRequestCount,
+ periodDelta,
+ requestCount: current.requestCount,
+ };
+ }, [usageState, capabilityRows]);
+
+ if (usageState.status === "loading" || usageState.status === "idle") {
+ return (
+
+
+
+ );
+ }
- // Forecast: trailing 7-day average × days remaining in period
- const last7Avg =
- totalsByDay.slice(-7).reduce((a, b) => a + b, 0) / 7;
- const forecast = Math.round(FREE_USED + last7Avg * DAYS_LEFT_IN_PERIOD);
- const willExceed = forecast > FREE_LIMIT;
- const left = FREE_LIMIT - FREE_USED;
- const daysToLimit =
- left > 0 && last7Avg > 0 ? Math.max(0, Math.floor(left / last7Avg)) : 0;
-
- // Breakdown table — sorted descending by total runs
- const sortedTotals = [...totals].sort((a, b) => b.sum - a.sum);
-
- // Limits — same shape as design
- const limits: {
- label: string;
- used: number;
- max: number;
- fmt: (v: number) => string;
- }[] = [
- {
- label: "Jobs / month",
- used: FREE_USED,
- max: FREE_LIMIT,
- fmt: (v) => v.toLocaleString("en-US"),
- },
- {
- label: "Concurrent streams",
- used: 2,
- max: 3,
- fmt: (v) => String(v),
- },
- {
- label: "Max video duration",
- used: 4,
- max: 5,
- fmt: (v) => `${v} min`,
- },
- {
- label: "Storage retained",
- used: 1.2,
- max: 5,
- fmt: (v) => `${v} GB`,
- },
- ];
+ if (usageState.status === "error") {
+ return (
+
+
+
+ );
+ }
+
+ const { data } = usageState;
+ const grandReq = filteredRows.reduce((a, c) => a + c.requestCount, 0);
+ const grandSpend = filteredRows.reduce((a, c) => a + c.spendUsd, 0);
+ const resetsAt = formatPeriodResetLabel(getUtcCalendarMonthIsoBounds().endDate);
+ const grantedMicros = data.balance?.lifetimeGrantedUsdMicros ?? null;
return (
- {/* Title */}
-
-
- Workspace · Flipbook
-
-
- Usage
-
-
+
+ Account · {externalUserId}
+
- {/* Free-tier strip */}
-
- {/* Jobs by capability — stacked area */}
-
- Jobs by capability
-
+
Jobs by capability
- Last {PERIOD_LABEL.toLowerCase()} · {fmt(grandReq)} jobs
+ {periodDayCount} days · {fmt(data.current.requestCount)} jobs · OpenMeter
- {sortedTotals.map((c) => (
-
+ {filteredRows.map((c) => (
+
-
-
({ name: c.name, data: c.data }))}
- colors={sliced.map((c) => c.color)}
- />
+
+ {filteredRows.length > 0 ? (
+
({ name: c.name, data: c.data }))}
+ colors={filteredRows.map((c) => c.color)}
+ dayKeys={data.periodDayKeys}
+ />
+ ) : (
+ No usage in this period.
+ )}
- {/* Breakdown table */}
+
{
+ setPriceMin(min);
+ setPriceMax(max);
+ }}
+ allRows={capabilityRows}
+ />
+
+
+
+ );
+}
+
+function BreakdownSection({
+ rows,
+ grandReq,
+ grandSpend,
+ priceMin,
+ priceMax,
+ dataMaxSpend,
+ onPriceChange,
+ allRows,
+}: {
+ rows: UsageCapabilityRow[];
+ grandReq: number;
+ grandSpend: number;
+ priceMin: number;
+ priceMax: number;
+ dataMaxSpend: number;
+ onPriceChange: (min: number, max: number) => void;
+ allRows: UsageCapabilityRow[];
+}) {
+ return (
+ <>
Breakdown
- {totals.length}
+ {rows.length}
-
-
- {/* Limits */}
-
-
-
-
Limits
-
- Free tier defaults · raise after adding payment
-
-
-
+ {allRows.length > 0 && (
+
+ Spend filter: ${((priceMin / 100) * dataMaxSpend).toFixed(3)} – $
+ {((priceMax / 100) * dataMaxSpend).toFixed(3)} (
+ onPriceChange(0, 100)}
>
- Compare plans
-
-
-
- {limits.map((l) => {
- const pct = Math.min(100, (l.used / l.max) * 100);
- const overWarn = pct > 80;
- return (
-
-
- {l.label}
-
- {l.fmt(l.used)}
- / {l.fmt(l.max)}
-
-
-
-
- );
- })}
-
-
-
+ reset
+
+ )
+
+ )}
+ >
);
}
-// ── Breakdown table ─────────────────────────────────────────────────────────
-
function BreakdownTable({
rows,
grandReq,
grandSpend,
}: {
- rows: (Capability & {
- sum: number;
- delta: number;
- spend: number;
- data: number[];
- })[];
+ rows: UsageCapabilityRow[];
grandReq: number;
grandSpend: number;
}) {
- // grid: Capability | Jobs · trend (sparkline) | Δ vs prior | Share | Unit price | Spend
const cols =
"grid grid-cols-[1.7fr_1.5fr_0.7fr_0.7fr_1fr_0.9fr] items-center gap-2 px-4";
@@ -377,7 +415,6 @@ function BreakdownTable({
return (
- {/* Head */}
@@ -385,96 +422,188 @@ function BreakdownTable({
Jobs · trend
Δ vs prior
Share
-
Unit price
-
Spend
+
Network cost
+
Billable
- {/* Rows */}
- {rows.map((c) => {
- const share = (c.sum / grandReq) * 100;
- const dUp = c.delta > 0;
- return (
-
- {/* Capability */}
-
-
-
- {c.name}
-
-
-
- {/* Jobs · trend (number + inline sparkline) */}
-
-
- {fmt(c.sum)}
-
-
-
-
-
-
- {/* Δ vs prior */}
+ {rows.length === 0 ? (
+
No matching capabilities.
+ ) : (
+ rows.map((c) => {
+ const share = grandReq > 0 ? (c.requestCount / grandReq) * 100 : 0;
+ const unitCost =
+ c.requestCount > 0 ? c.spendUsd / c.requestCount : 0;
+ return (
- {dUp ? "+" : ""}
- {c.delta.toFixed(0)}%
-
-
- {/* Share */}
-
- {share.toFixed(1)}%
-
-
- {/* Unit price */}
-
- ${c.price.toFixed(4)}
- /{c.unit}
-
-
- {/* Spend */}
-
- {fmtSpend(c.spend)}
+
+
+
+ {c.name}
+
+
+
+ {fmt(c.requestCount)}
+
+
+
+
+
+ {c.delta > 0 ? "+" : ""}
+ {c.delta.toFixed(0)}%
+
+
+ {share.toFixed(1)}%
+
+
+ ${microsToUsdDisplay(c.networkFeeUsdMicros)}
+
+
+ {fmtSpend(c.spendUsd)}
+ {unitCost > 0 && (
+ · ${unitCost.toFixed(4)}/req
+ )}
+
-
- );
- })}
+ );
+ })
+ )}
- {/* Total */}
-
-
+
+
Total
-
- · this period
-
-
-
- {fmt(grandReq)}
+ · this period
+
{fmt(grandReq)}
-
- {fmtSpend(grandSpend)}
+
{fmtSpend(grandSpend)}
+
+
+ );
+}
+
+function LimitsPanel({
+ balance,
+ networkFeeUsdMicros,
+ endUserBillableUsdMicros,
+ requestCount,
+}: {
+ balance: {
+ balanceUsdMicros: string;
+ consumedUsdMicros: string;
+ lifetimeGrantedUsdMicros: string;
+ hasAccess: boolean;
+ } | null;
+ networkFeeUsdMicros: string;
+ endUserBillableUsdMicros: string;
+ requestCount: number;
+}) {
+ const limits = balance
+ ? [
+ {
+ label: "Included allowance",
+ used: microsToUsdDisplay(balance.consumedUsdMicros),
+ max: `$${microsToUsdDisplay(balance.lifetimeGrantedUsdMicros)}`,
+ pct:
+ BigInt(balance.lifetimeGrantedUsdMicros || "0") > BigInt(0)
+ ? Math.min(
+ 100,
+ Number(
+ (BigInt(balance.consumedUsdMicros || "0") * BigInt(10000)) /
+ BigInt(balance.lifetimeGrantedUsdMicros || "1"),
+ ) / 100,
+ )
+ : 0,
+ },
+ {
+ label: "Remaining balance",
+ used: `$${microsToUsdDisplay(balance.balanceUsdMicros)}`,
+ max: "—",
+ pct: balance.hasAccess ? 40 : 100,
+ },
+ ]
+ : [
+ {
+ label: "Signed requests",
+ used: fmt(requestCount),
+ max: "—",
+ pct: 50,
+ },
+ ];
+
+ const extra = [
+ {
+ label: "Network cost (metered)",
+ used: `$${microsToUsdDisplay(networkFeeUsdMicros)}`,
+ max: "pass-through",
+ pct: 30,
+ },
+ {
+ label: "Billable (retail estimate)",
+ used: `$${microsToUsdDisplay(endUserBillableUsdMicros)}`,
+ max: "—",
+ pct: 45,
+ },
+ ];
+
+ return (
+
+
+
+
Limits & metering
+
+ OpenMeter subscription allowance · network_spend meter
+
+
+ Manage plan
+
+
+
+ {[...limits, ...extra].map((l) => {
+ const overWarn = l.pct > 80;
+ return (
+
+
+ {l.label}
+
+ {l.used}
+ / {l.max}
+
+
+
+
+ );
+ })}
);
diff --git a/components/dashboard/UserSessionContext.tsx b/components/dashboard/UserSessionContext.tsx
new file mode 100644
index 0000000..67abc4e
--- /dev/null
+++ b/components/dashboard/UserSessionContext.tsx
@@ -0,0 +1,77 @@
+"use client";
+
+import {
+ createContext,
+ useContext,
+ useEffect,
+ type ReactNode,
+} from "react";
+import { useAuth } from "@/components/dashboard/AuthContext";
+import {
+ useSigningSession,
+ type SigningSessionState,
+} from "@/lib/dashboard/useSigningSession";
+
+export type UserSessionContextValue = {
+ /** PymtHouse short-lived signing JWT for the signed-in user. */
+ signing: SigningSessionState;
+ refreshSigningToken: () => Promise
;
+ ensureSigningAccessToken: () => Promise;
+ clearSigningSession: () => void;
+};
+
+const UserSessionContext = createContext({
+ signing: { status: "idle" },
+ refreshSigningToken: async () => {},
+ ensureSigningAccessToken: async () => "",
+ clearSigningSession: () => {},
+});
+
+export function useUserSession() {
+ return useContext(UserSessionContext);
+}
+
+/** @deprecated Use `useUserSession` */
+export function useSignerSession() {
+ const session = useUserSession();
+ return {
+ enabled: session.signing.status !== "idle",
+ state: session.signing,
+ refresh: session.refreshSigningToken,
+ ensureAccessToken: session.ensureSigningAccessToken,
+ clearSession: session.clearSigningSession,
+ };
+}
+
+export function UserSessionProvider({ children }: { children: ReactNode }) {
+ const { user, isConnected } = useAuth();
+ const externalUserId = user?.email?.trim();
+ const mintEnabled = isConnected && Boolean(externalUserId);
+
+ const { state, refresh, ensureAccessToken, clearSession } = useSigningSession(
+ mintEnabled,
+ externalUserId,
+ );
+
+ useEffect(() => {
+ if (!mintEnabled) {
+ clearSession();
+ }
+ }, [mintEnabled, clearSession]);
+
+ return (
+
+ {children}
+
+ );
+}
+
+/** @deprecated Use `UserSessionProvider` */
+export const SignerSessionProvider = UserSessionProvider;
diff --git a/components/dashboard/playground/CodeSnippets.tsx b/components/dashboard/playground/CodeSnippets.tsx
index d05ca1d..939f701 100644
--- a/components/dashboard/playground/CodeSnippets.tsx
+++ b/components/dashboard/playground/CodeSnippets.tsx
@@ -4,6 +4,12 @@ import { useEffect, useMemo, useState } from "react";
import CopyButton from "@/components/dashboard/CopyButton";
import { useAuth } from "@/components/dashboard/AuthContext";
import { STARTER_API_KEY } from "@/lib/dashboard/mock-data";
+import { isGatewayEnabledPublic } from "@/lib/dashboard/gateway-public";
+import {
+ buildGatewayStreamingSnippet,
+ buildPythonSdkStreamingSnippet,
+ isStreamingCapabilityModel,
+} from "@/lib/dashboard/sdk-streaming-example";
import type { Model } from "@/lib/dashboard/types";
type Lang = "curl" | "python" | "node" | "http";
@@ -196,10 +202,22 @@ export default function CodeSnippets({
? mockToken
: PLACEHOLDER_TOKEN;
- const snippets = useMemo(
- () => generateSnippets(model, token, runValues),
- [model, token, runValues],
- );
+ const useGatewayStreaming =
+ isGatewayEnabledPublic() && isStreamingCapabilityModel(model);
+
+ const snippets = useMemo(() => {
+ if (useGatewayStreaming) {
+ const gateway = buildGatewayStreamingSnippet(model);
+ const python = buildPythonSdkStreamingSnippet(model);
+ return {
+ curl: gateway,
+ python,
+ node: gateway,
+ http: gateway,
+ };
+ }
+ return generateSnippets(model, token, runValues);
+ }, [model, token, runValues, useGatewayStreaming]);
const activeLang = fixedLang ?? lang;
return (
diff --git a/components/dashboard/playground/LiveStreamPlayground.tsx b/components/dashboard/playground/LiveStreamPlayground.tsx
new file mode 100644
index 0000000..8f49e84
--- /dev/null
+++ b/components/dashboard/playground/LiveStreamPlayground.tsx
@@ -0,0 +1,593 @@
+"use client";
+
+import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
+import { Play, Square, Video } from "lucide-react";
+import { BrowserGatewayClient } from "@pymthouse/builder-sdk/gateway/client";
+import { useUserSession } from "@/components/dashboard/UserSessionContext";
+import type { Model } from "@/lib/dashboard/types";
+import StatusDot from "@/components/dashboard/StatusDot";
+import {
+ DEFAULT_TRICKLE_MIME_TYPE,
+ MpegTsPlayer,
+ MpegTsPublisher,
+} from "@/lib/dashboard/playground/trickle-mpegts";
+import {
+ AsyncSemaphore,
+ InputFrameCapture,
+} from "@/lib/dashboard/playground/stream-capture";
+
+type StreamStatus = "idle" | "connecting" | "live" | "error";
+
+type ConnectPhase =
+ | "signing"
+ | "discovery"
+ | "session"
+ | "starting_loops";
+
+const TEST_PATTERN_FPS = 15;
+const TEST_WIDTH = 320;
+const TEST_HEIGHT = 180;
+const PUBLISH_UPLOAD_CONCURRENCY = 1;
+const FRAMES_PUBLISHED_UI_INTERVAL_MS = 1000;
+
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => window.setTimeout(resolve, ms));
+}
+
+/** Yield until React has committed the latest state (refs populated). */
+function afterReactCommit(): Promise {
+ return new Promise((resolve) => {
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => resolve());
+ });
+ });
+}
+
+function phaseLabel(phase: ConnectPhase | null): string {
+ switch (phase) {
+ case "signing":
+ return "Minting signing credentials…";
+ case "discovery":
+ return "Discovering orchestrators and negotiating job…";
+ case "session":
+ return "Opening gateway session…";
+ case "starting_loops":
+ return "Starting frame relay…";
+ default:
+ return "Connecting…";
+ }
+}
+
+export default function LiveStreamPlayground({ model }: { model: Model }) {
+ const {
+ signing: signerState,
+ ensureSigningAccessToken: ensureAccessToken,
+ refreshSigningToken: refresh,
+ } = useUserSession();
+ const [status, setStatus] = useState("idle");
+ const [connectPhase, setConnectPhase] = useState(null);
+ const [errorMsg, setErrorMsg] = useState(null);
+ const [sessionLabel, setSessionLabel] = useState(null);
+ const [framesPublished, setFramesPublished] = useState(0);
+ const [outputSegmentSeq, setOutputSegmentSeq] = useState(null);
+
+ const sourceVideoRef = useRef(null);
+ const outputVideoRef = useRef(null);
+ const inputCanvasRef = useRef(null);
+ const publishCanvasRef = useRef(null);
+ const streamRef = useRef(null);
+ const clientRef = useRef | null>(null);
+ const mpegTsPublisherRef = useRef(null);
+ const mpegTsPlayerRef = useRef(null);
+ const inputCaptureRef = useRef(null);
+ const publishUploadRef = useRef(new AsyncSemaphore(PUBLISH_UPLOAD_CONCURRENCY));
+ const publishSeqRef = useRef(-1);
+ const framesPublishedCountRef = useRef(0);
+ const lastPublishUiMsRef = useRef(0);
+ const lastOutputSegmentSeqRef = useRef(null);
+ const lastOutputSegmentBytesRef = useRef(0);
+ const publishActiveRef = useRef(false);
+ const subscribeActiveRef = useRef(false);
+ const segmentContentTypeRef = useRef(DEFAULT_TRICKLE_MIME_TYPE);
+ const streamGenerationRef = useRef(0);
+ const pendingOutputAttachRef = useRef(false);
+ const resetStreamResourcesRef = useRef<() => Promise>(async () => undefined);
+ const [outputSurfaceKey, setOutputSurfaceKey] = useState(0);
+
+ const stopLoops = useCallback(() => {
+ publishActiveRef.current = false;
+ subscribeActiveRef.current = false;
+ }, []);
+
+ const stopCamera = useCallback(() => {
+ streamRef.current?.getTracks().forEach((t) => t.stop());
+ streamRef.current = null;
+ if (sourceVideoRef.current) {
+ sourceVideoRef.current.srcObject = null;
+ }
+ }, []);
+
+ /** Tear down gateway + encode/play pipeline; optionally keep the camera open. */
+ const resetGatewaySession = useCallback(
+ async (preserveCamera: boolean) => {
+ streamGenerationRef.current += 1;
+ pendingOutputAttachRef.current = false;
+ stopLoops();
+ if (!preserveCamera) {
+ stopCamera();
+ }
+ await mpegTsPublisherRef.current?.stop().catch(() => undefined);
+ mpegTsPublisherRef.current = null;
+ mpegTsPlayerRef.current?.destroy();
+ mpegTsPlayerRef.current = null;
+ await clientRef.current?.stop().catch(() => undefined);
+ clientRef.current = null;
+ setSessionLabel(null);
+ framesPublishedCountRef.current = 0;
+ lastPublishUiMsRef.current = 0;
+ lastOutputSegmentSeqRef.current = null;
+ lastOutputSegmentBytesRef.current = 0;
+ publishSeqRef.current = -1;
+ setOutputSegmentSeq(null);
+ setFramesPublished(0);
+ setConnectPhase(null);
+ },
+ [stopCamera, stopLoops],
+ );
+
+ const resetStreamResources = useCallback(async () => {
+ inputCaptureRef.current?.stop();
+ await resetGatewaySession(false);
+ }, [resetGatewaySession]);
+
+ resetStreamResourcesRef.current = resetStreamResources;
+
+ const tryAttachOutputPlayer = useCallback(() => {
+ if (!pendingOutputAttachRef.current) {
+ return false;
+ }
+ const video = outputVideoRef.current;
+ const player = mpegTsPlayerRef.current;
+ if (!video || !player) {
+ return false;
+ }
+ pendingOutputAttachRef.current = false;
+ return player.attach(video);
+ }, []);
+
+ const bindOutputVideo = useCallback(
+ (node: HTMLVideoElement | null) => {
+ outputVideoRef.current = node;
+ if (node) {
+ tryAttachOutputPlayer();
+ }
+ },
+ [tryAttachOutputPlayer],
+ );
+
+ const publishFrame = useCallback(() => {
+ const client = clientRef.current;
+ const publisher = mpegTsPublisherRef.current;
+ const publishCanvas = publishCanvasRef.current;
+ const inputCanvas = inputCanvasRef.current;
+ if (!client || !publisher || !publishCanvas || !inputCanvas) {
+ return;
+ }
+
+ const ctx = publishCanvas.getContext("2d", { alpha: false });
+ if (!ctx) {
+ return;
+ }
+
+ ctx.drawImage(inputCanvas, 0, 0, publishCanvas.width, publishCanvas.height);
+ publisher.encode(publishCanvas);
+
+ framesPublishedCountRef.current += 1;
+ const now = performance.now();
+ if (now - lastPublishUiMsRef.current >= FRAMES_PUBLISHED_UI_INTERVAL_MS) {
+ lastPublishUiMsRef.current = now;
+ setFramesPublished(framesPublishedCountRef.current);
+ }
+ }, []);
+
+ const startInputCapture = useCallback(() => {
+ const canvas = inputCanvasRef.current;
+ if (!canvas) {
+ return;
+ }
+ inputCaptureRef.current?.stop();
+ const capture = new InputFrameCapture();
+ inputCaptureRef.current = capture;
+ capture.start({
+ canvas,
+ sourceVideo: sourceVideoRef.current,
+ getMediaStream: () => streamRef.current,
+ fps: TEST_PATTERN_FPS,
+ onSample: () => {
+ if (publishActiveRef.current) {
+ publishFrame();
+ }
+ },
+ });
+ }, [publishFrame]);
+
+ const stopAll = useCallback(async () => {
+ await resetStreamResources();
+ setErrorMsg(null);
+ setStatus("idle");
+ startInputCapture();
+ }, [resetStreamResources, startInputCapture]);
+
+ const failStream = useCallback(
+ async (err: unknown) => {
+ await resetGatewaySession(true);
+ setStatus("error");
+ setErrorMsg(err instanceof Error ? err.message : "Failed to start stream");
+ startInputCapture();
+ },
+ [resetGatewaySession, startInputCapture],
+ );
+
+ useEffect(() => {
+ startInputCapture();
+ return () => {
+ inputCaptureRef.current?.stop();
+ void resetStreamResourcesRef.current();
+ };
+ }, [startInputCapture]);
+
+ useLayoutEffect(() => {
+ if (status === "live") {
+ tryAttachOutputPlayer();
+ }
+ }, [status, outputSurfaceKey, tryAttachOutputPlayer]);
+
+ const gatewayModelId = model.gatewayModelId ?? model.id;
+
+ const startCamera = useCallback(async () => {
+ if (!navigator.mediaDevices) {
+ setErrorMsg("Camera API not available");
+ setStatus("error");
+ return;
+ }
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia({
+ video: { width: { ideal: TEST_WIDTH }, height: { ideal: TEST_HEIGHT } },
+ audio: false,
+ });
+ streamRef.current = stream;
+ if (sourceVideoRef.current) {
+ sourceVideoRef.current.srcObject = stream;
+ await sourceVideoRef.current.play().catch(() => undefined);
+ }
+ startInputCapture();
+ if (status === "error") {
+ setStatus("idle");
+ setErrorMsg(null);
+ }
+ } catch (err) {
+ setStatus("error");
+ setErrorMsg(err instanceof Error ? err.message : "Camera denied");
+ }
+ }, [startInputCapture, status]);
+
+ const subscribeLoop = useCallback(async () => {
+ const client = clientRef.current;
+ const player = mpegTsPlayerRef.current;
+ if (!client || !player || !subscribeActiveRef.current) {
+ return;
+ }
+
+ try {
+ const segment = await client.subscribeOutputSegmentStream((chunk: Uint8Array) => {
+ player.pushChunk(chunk);
+ });
+ if (!segment || !subscribeActiveRef.current) {
+ await sleep(50);
+ if (subscribeActiveRef.current) {
+ void subscribeLoop();
+ }
+ return;
+ }
+
+ const lastSeq = lastOutputSegmentSeqRef.current;
+ const lastBytes = lastOutputSegmentBytesRef.current;
+ const segmentBytes = segment.byteCount;
+ const isOlderSegment = segment.segmentSeq < (lastSeq ?? -1);
+ const isExactDuplicate = segment.segmentSeq === lastSeq && segmentBytes <= lastBytes;
+
+ if (isOlderSegment || isExactDuplicate) {
+ await sleep(20);
+ if (subscribeActiveRef.current) {
+ void subscribeLoop();
+ }
+ return;
+ }
+ lastOutputSegmentSeqRef.current = segment.segmentSeq;
+ lastOutputSegmentBytesRef.current = segmentBytes;
+ setOutputSegmentSeq(segment.segmentSeq);
+ player.flushSegment();
+
+ if (subscribeActiveRef.current) {
+ void subscribeLoop();
+ }
+ } catch {
+ if (subscribeActiveRef.current) {
+ await sleep(300);
+ if (subscribeActiveRef.current) {
+ void subscribeLoop();
+ }
+ }
+ }
+ }, []);
+
+ const startStream = useCallback(async () => {
+ setStatus("connecting");
+ setErrorMsg(null);
+ framesPublishedCountRef.current = 0;
+ lastPublishUiMsRef.current = 0;
+ lastOutputSegmentSeqRef.current = null;
+ lastOutputSegmentBytesRef.current = 0;
+ publishSeqRef.current = -1;
+ setOutputSegmentSeq(null);
+ setFramesPublished(0);
+ stopLoops();
+ await resetGatewaySession(true);
+
+ const generation = streamGenerationRef.current;
+
+ try {
+ setConnectPhase("signing");
+ const bearer = await ensureAccessToken();
+ if (streamGenerationRef.current !== generation) {
+ return;
+ }
+
+ setConnectPhase("discovery");
+ const origin = window.location.origin;
+ const client = new BrowserGatewayClient({ baseUrl: origin });
+ client.setSignerToken(bearer);
+
+ setConnectPhase("session");
+ const session = await client.startSession({ modelId: gatewayModelId });
+ if (streamGenerationRef.current !== generation) {
+ await client.stop().catch(() => undefined);
+ return;
+ }
+
+ clientRef.current = client;
+ setSessionLabel(`${session.sessionId.slice(0, 8)}…`);
+ segmentContentTypeRef.current = session.mimeType ?? DEFAULT_TRICKLE_MIME_TYPE;
+ publishSeqRef.current = Math.max(-1, (session.publishSeq ?? 0) - 1);
+
+ const uploadGate = publishUploadRef.current;
+ const publisher = new MpegTsPublisher((chunk) => {
+ if (streamGenerationRef.current !== generation) {
+ return;
+ }
+ const nextPublishSeq = publishSeqRef.current + 1;
+ publishSeqRef.current = nextPublishSeq;
+ void uploadGate
+ .run(() =>
+ client.publishSegment(chunk, {
+ seq: nextPublishSeq,
+ contentType: segmentContentTypeRef.current,
+ }),
+ )
+ .catch(() => undefined);
+ }, TEST_PATTERN_FPS);
+ await publisher.start(TEST_WIDTH, TEST_HEIGHT);
+ if (streamGenerationRef.current !== generation) {
+ await publisher.stop().catch(() => undefined);
+ await client.stop().catch(() => undefined);
+ return;
+ }
+ mpegTsPublisherRef.current = publisher;
+
+ const player = new MpegTsPlayer();
+ mpegTsPlayerRef.current = player;
+ pendingOutputAttachRef.current = true;
+
+ setConnectPhase("starting_loops");
+ setOutputSurfaceKey((key) => key + 1);
+ setStatus("live");
+
+ await afterReactCommit();
+ if (streamGenerationRef.current !== generation) {
+ return;
+ }
+ tryAttachOutputPlayer();
+ await player.waitUntilReady(12_000);
+ if (streamGenerationRef.current !== generation) {
+ return;
+ }
+
+ setConnectPhase(null);
+ publishActiveRef.current = true;
+ subscribeActiveRef.current = true;
+ startInputCapture();
+ void subscribeLoop();
+ } catch (err) {
+ if (streamGenerationRef.current === generation) {
+ await failStream(err);
+ }
+ }
+ }, [
+ ensureAccessToken,
+ failStream,
+ gatewayModelId,
+ resetGatewaySession,
+ startInputCapture,
+ stopLoops,
+ subscribeLoop,
+ tryAttachOutputPlayer,
+ ]);
+
+ const signingReady = signerState.status === "ready";
+ const signingLoading = signerState.status === "loading";
+ const canStart = signingReady && status !== "connecting";
+
+ return (
+
+
+ Stream to an orchestrator through the dashboard gateway relay. Frames are muxed to
+ MPEG-TS (video/mp2t ) like{" "}
+ write_frames.py . A short-lived signing token is
+ minted automatically for your account.
+
+
+
+ {signingLoading &&
Preparing signing token…
}
+ {signingReady && status !== "connecting" && status !== "live" && (
+
+
+
+ Signing token ready
+
+ {" "}
+ · refreshes before expiry
+
+
+
+ )}
+ {status === "connecting" && (
+
{phaseLabel(connectPhase)}
+ )}
+ {signerState.status === "missing_user" && (
+
Sign in to mint a signing token for streaming.
+ )}
+ {signerState.status === "error" && status !== "live" && (
+
+ {signerState.message}{" "}
+ void refresh()}
+ >
+ Retry
+
+
+ )}
+
+
+
+
+
+
Input
+ {status === "live" && (
+
+
+ Streaming
+ {framesPublished > 0 && (
+ · {framesPublished} encoded
+ )}
+
+ )}
+
+
+
+
+
+
+
+ Test pattern frames cycle automatically. Optional camera overrides the pattern
+ when streaming.
+
+
+
void startCamera()}
+ disabled={status === "connecting"}
+ className="btn-secondary flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium disabled:opacity-50"
+ >
+
+ Camera
+
+ {status === "live" ? (
+
void stopAll()}
+ className="btn-secondary flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium"
+ >
+
+ Stop
+
+ ) : (
+
void startStream()}
+ disabled={!canStart}
+ className="btn-primary flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium disabled:opacity-50"
+ >
+ {status === "connecting" ? (
+ <>
+
+ Connecting…
+ >
+ ) : (
+ <>
+
+ Start stream
+ >
+ )}
+
+ )}
+
+
+
+
+
+
Output
+
+ {sessionLabel && (
+ session {sessionLabel}
+ )}
+ {outputSegmentSeq !== null && (
+
+ out seg {outputSegmentSeq}
+
+ )}
+
+
+
+
+ {status !== "live" && (
+
+ {status === "error" && errorMsg
+ ? errorMsg
+ : status === "connecting"
+ ? phaseLabel(connectPhase)
+ : "Orchestrator output (MPEG-TS demuxed) appears here when the stream is live."}
+
+ )}
+
+
+
+
+ {status === "error" && errorMsg && (
+
+ {errorMsg}
+
+ )}
+
+ );
+}
diff --git a/lib/dashboard/device-flow.ts b/lib/dashboard/device-flow.ts
new file mode 100644
index 0000000..037058f
--- /dev/null
+++ b/lib/dashboard/device-flow.ts
@@ -0,0 +1,106 @@
+import { cookies } from "next/headers";
+import { PmtHouseClient, PmtHouseError } from "@pymthouse/builder-sdk";
+
+export const DEVICE_FLOW_COOKIE_NAME = "dashboard_device_flow";
+
+export interface DeviceFlowState {
+ iss: string;
+ targetLinkUri: string;
+ userCode: string;
+ clientId: string;
+}
+
+export async function setDeviceFlowCookie(state: DeviceFlowState): Promise {
+ const jar = await cookies();
+ jar.set(DEVICE_FLOW_COOKIE_NAME, JSON.stringify(state), {
+ httpOnly: true,
+ sameSite: "lax",
+ path: "/",
+ maxAge: 60 * 10,
+ secure: process.env.NODE_ENV === "production",
+ });
+}
+
+export async function readDeviceFlowCookie(): Promise {
+ const jar = await cookies();
+ const raw = jar.get(DEVICE_FLOW_COOKIE_NAME)?.value;
+ if (!raw) {
+ return null;
+ }
+ try {
+ const parsed = JSON.parse(raw) as DeviceFlowState;
+ if (
+ !parsed.userCode ||
+ !parsed.clientId ||
+ !parsed.iss ||
+ !parsed.targetLinkUri
+ ) {
+ return null;
+ }
+ return parsed;
+ } catch {
+ return null;
+ }
+}
+
+export async function clearDeviceFlowCookie(): Promise {
+ const jar = await cookies();
+ jar.delete(DEVICE_FLOW_COOKIE_NAME);
+}
+
+function readPymthouseM2mConfig() {
+ const issuerUrl = process.env.PYMTHOUSE_ISSUER_URL?.trim();
+ const m2mClientId = process.env.PYMTHOUSE_M2M_CLIENT_ID?.trim();
+ const m2mClientSecret = process.env.PYMTHOUSE_M2M_CLIENT_SECRET?.trim();
+ if (!issuerUrl || !m2mClientId || !m2mClientSecret) {
+ return null;
+ }
+ return {
+ issuerUrl,
+ m2mClientId,
+ m2mClientSecret,
+ allowInsecureHttp: process.env.PYMTHOUSE_ALLOW_INSECURE_HTTP === "1",
+ };
+}
+
+export function createPmtHouseClientForPublicApp(publicClientId: string): PmtHouseClient {
+ const config = readPymthouseM2mConfig();
+ if (!config) {
+ throw new PmtHouseError(
+ "Pymthouse is not configured. Set PYMTHOUSE_ISSUER_URL, PYMTHOUSE_M2M_CLIENT_ID, and PYMTHOUSE_M2M_CLIENT_SECRET.",
+ { status: 503, code: "pymthouse_required" },
+ );
+ }
+ return new PmtHouseClient({
+ issuerUrl: config.issuerUrl,
+ publicClientId,
+ m2mClientId: config.m2mClientId,
+ m2mClientSecret: config.m2mClientSecret,
+ allowInsecureHttp: config.allowInsecureHttp,
+ });
+}
+
+export async function completeDashboardDeviceApproval(params: {
+ userCode: string;
+ publicClientId: string;
+ externalUserId: string;
+ email: string;
+}): Promise {
+ const client = createPmtHouseClientForPublicApp(params.publicClientId);
+
+ await client.upsertAppUser({
+ externalUserId: params.externalUserId,
+ email: params.email,
+ status: "active",
+ });
+
+ const userToken = await client.mintUserAccessToken({
+ externalUserId: params.externalUserId,
+ scope: "sign:job",
+ });
+
+ await client.completeDeviceApproval({
+ userJwt: userToken.access_token,
+ userCode: params.userCode,
+ });
+}
diff --git a/lib/dashboard/fetch-signing-token.ts b/lib/dashboard/fetch-signing-token.ts
new file mode 100644
index 0000000..3727d53
--- /dev/null
+++ b/lib/dashboard/fetch-signing-token.ts
@@ -0,0 +1,58 @@
+export type SigningTokenResponse = {
+ access_token: string;
+ expires_in: number;
+ scope: string;
+ token_type: string;
+};
+
+export async function fetchSigningToken(
+ externalUserId: string,
+): Promise {
+ const response = await fetch("/api/pymthouse/session/signing-token", {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ Accept: "application/json",
+ },
+ body: JSON.stringify({
+ externalUserId: externalUserId.trim(),
+ scope: "sign:job",
+ }),
+ cache: "no-store",
+ });
+
+ const body = (await response.json().catch(() => ({}))) as Record;
+ if (!response.ok) {
+ const message =
+ typeof body.error_description === "string"
+ ? body.error_description
+ : typeof body.error === "string"
+ ? body.error
+ : `Signing token request failed (${response.status})`;
+ throw new Error(message);
+ }
+
+ const accessToken =
+ typeof body.access_token === "string" ? body.access_token.trim() : "";
+ if (!accessToken) {
+ throw new Error("Signing token response did not include access_token");
+ }
+
+ const expiresIn =
+ typeof body.expires_in === "number" && Number.isFinite(body.expires_in)
+ ? body.expires_in
+ : 900;
+
+ const scope =
+ typeof body.scope === "string" && body.scope.trim() ? body.scope.trim() : "sign:job";
+
+ return {
+ access_token: accessToken,
+ expires_in: expiresIn,
+ scope,
+ token_type:
+ typeof body.token_type === "string" && body.token_type.trim()
+ ? body.token_type.trim()
+ : "Bearer",
+ };
+}
diff --git a/lib/dashboard/gateway-config.server.ts b/lib/dashboard/gateway-config.server.ts
new file mode 100644
index 0000000..049c1ae
--- /dev/null
+++ b/lib/dashboard/gateway-config.server.ts
@@ -0,0 +1,71 @@
+import "server-only";
+
+import {
+ readGatewayConfigFromEnv,
+ type GatewayServerConfig,
+} from "@pymthouse/builder-sdk/gateway/server";
+
+export type { GatewayServerConfig };
+
+function issuerOriginFromIssuerUrl(issuerUrl: string): string {
+ let base = issuerUrl.trim().replace(/\/+$/, "");
+ if (base.endsWith("/api/v1/oidc")) {
+ base = base.slice(0, -"/api/v1/oidc".length);
+ } else if (base.endsWith("/oidc")) {
+ base = base.slice(0, -"/oidc".length);
+ }
+ return base.replace(/\/+$/, "");
+}
+
+function stripTrailingSlashes(value: string): string {
+ let end = value.length;
+ while (end > 0 && value.codePointAt(end - 1) === 47) {
+ end -= 1;
+ }
+ return value.slice(0, end);
+}
+
+function requestOriginFromRequest(request: Request): string {
+ const forwardedHost = request.headers.get("x-forwarded-host")?.split(",")[0]?.trim();
+ const host = forwardedHost || request.headers.get("host")?.trim();
+ if (host) {
+ const forwardedProto = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
+ const protocol =
+ forwardedProto === "http" || forwardedProto === "https"
+ ? forwardedProto
+ : new URL(request.url).protocol.replace(":", "");
+ return `${protocol}://${host}`;
+ }
+ return new URL(request.url).origin;
+}
+
+export function resolveDashboardSignerUpstreamUrl(): string | null {
+ const env = process.env;
+ const issuerUrl = env.PYMTHOUSE_ISSUER_URL?.trim();
+ const signerUrl =
+ env.PYMTHOUSE_SIGNER_URL?.trim() ||
+ env.SIGNER_PUBLIC_URL?.trim() ||
+ env.GATEWAY_SIGNER_UPSTREAM_URL?.trim() ||
+ (issuerUrl ? `${issuerOriginFromIssuerUrl(issuerUrl)}/api/signer` : "");
+ return signerUrl || null;
+}
+
+function resolveDashboardGatewaySignerUrl(request?: Request): string | null {
+ if (process.env.GATEWAY_SIGNER_FROM_REQUEST_ORIGIN === "1" && request) {
+ return `${stripTrailingSlashes(requestOriginFromRequest(request))}/api/signer`;
+ }
+ return resolveDashboardSignerUpstreamUrl();
+}
+
+/** Per-request gateway config (signer URL matches dashboard host:port when enabled). */
+export function readDashboardGatewayConfig(request?: Request): GatewayServerConfig | null {
+ const base = readGatewayConfigFromEnv(process.env);
+ if (!base) {
+ return null;
+ }
+ const signerUrl = resolveDashboardGatewaySignerUrl(request);
+ if (!signerUrl) {
+ return null;
+ }
+ return { ...base, signerUrl };
+}
diff --git a/lib/dashboard/gateway-public.ts b/lib/dashboard/gateway-public.ts
new file mode 100644
index 0000000..8b0f9a4
--- /dev/null
+++ b/lib/dashboard/gateway-public.ts
@@ -0,0 +1,7 @@
+/**
+ * Client-safe gateway flags. Do not import gateway/server here — it uses Node gRPC.
+ */
+
+export function isGatewayEnabledPublic(): boolean {
+ return process.env.NEXT_PUBLIC_GATEWAY_ENABLED === "1";
+}
diff --git a/lib/dashboard/model-api-url.ts b/lib/dashboard/model-api-url.ts
new file mode 100644
index 0000000..dc40a7c
--- /dev/null
+++ b/lib/dashboard/model-api-url.ts
@@ -0,0 +1,31 @@
+import type { Model } from "@/lib/dashboard/types";
+
+const DEFAULT_GATEWAY_BASE = "https://gateway.livepeer.org/v1";
+
+function isHttpUrl(value: string): boolean {
+ return /^https?:\/\//i.test(value);
+}
+
+/** Gateway base URL for snippets and docs (never a bare capability id). */
+export function getModelApiBaseUrl(model: Model): string {
+ const candidate = model.apiEndpoint?.trim();
+ if (candidate && isHttpUrl(candidate)) {
+ return candidate.replace(/\/$/, "");
+ }
+ return DEFAULT_GATEWAY_BASE;
+}
+
+/** POST target for the model's inference API. */
+export function getModelApiPostUrl(model: Model): string {
+ const base = getModelApiBaseUrl(model);
+ if (model.category === "Language") {
+ return `${base}/chat/completions`;
+ }
+ const pipeline = encodeURIComponent(model.id);
+ return `${base}/${pipeline}`;
+}
+
+/** Host header value for raw HTTP examples. */
+export function getModelApiHost(model: Model): string {
+ return new URL(getModelApiBaseUrl(model)).host;
+}
diff --git a/lib/dashboard/playground/stream-capture.ts b/lib/dashboard/playground/stream-capture.ts
new file mode 100644
index 0000000..8b6692e
--- /dev/null
+++ b/lib/dashboard/playground/stream-capture.ts
@@ -0,0 +1,91 @@
+/** rAF-driven input preview + snapshot source (keeps camera off the React render path). */
+
+export type InputFrameCaptureOptions = {
+ canvas: HTMLCanvasElement;
+ sourceVideo: HTMLVideoElement | null;
+ getMediaStream: () => MediaStream | null;
+ fps: number;
+ /** Called after each painted preview frame (e.g. to drive publish sampling). */
+ onSample?: () => void;
+};
+
+export class InputFrameCapture {
+ private running = false;
+ private rafId = 0;
+ private lastSampleMs = 0;
+ private testFrameIndex = 0;
+ private options: InputFrameCaptureOptions | null = null;
+
+ start(options: InputFrameCaptureOptions): void {
+ this.stop();
+ this.options = options;
+ this.running = true;
+ this.lastSampleMs = 0;
+ this.testFrameIndex = 0;
+ this.rafId = requestAnimationFrame(() => this.tick());
+ }
+
+ stop(): void {
+ this.running = false;
+ if (this.rafId) {
+ cancelAnimationFrame(this.rafId);
+ this.rafId = 0;
+ }
+ this.options = null;
+ }
+
+ private tick(): void {
+ if (!this.running || !this.options) {
+ return;
+ }
+
+ const { canvas, sourceVideo, getMediaStream, fps, onSample } = this.options;
+ const ctx = canvas.getContext("2d", { alpha: false });
+ if (ctx) {
+ const stream = getMediaStream();
+ if (stream && sourceVideo && sourceVideo.readyState >= 2) {
+ ctx.drawImage(sourceVideo, 0, 0, canvas.width, canvas.height);
+ } else {
+ const color = (this.testFrameIndex * 5) % 255;
+ ctx.fillStyle = `rgb(${color}, 0, ${255 - color})`;
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
+ this.testFrameIndex += 1;
+ }
+ }
+
+ const intervalMs = 1000 / fps;
+ const now = performance.now();
+ if (onSample && now - this.lastSampleMs >= intervalMs) {
+ this.lastSampleMs = now;
+ onSample();
+ }
+
+ this.rafId = requestAnimationFrame(() => this.tick());
+ }
+}
+
+/** Limits concurrent async work (e.g. trickle PUTs) so encoding is not blocked. */
+export class AsyncSemaphore {
+ private inFlight = 0;
+ private readonly queue: Array<() => void> = [];
+
+ constructor(private readonly maxConcurrent: number) {}
+
+ async run(task: () => Promise): Promise {
+ if (this.inFlight >= this.maxConcurrent) {
+ await new Promise((resolve) => {
+ this.queue.push(resolve);
+ });
+ }
+ this.inFlight += 1;
+ try {
+ return await task();
+ } finally {
+ this.inFlight -= 1;
+ const next = this.queue.shift();
+ if (next) {
+ next();
+ }
+ }
+ }
+}
diff --git a/lib/dashboard/playground/trickle-mpegts.ts b/lib/dashboard/playground/trickle-mpegts.ts
new file mode 100644
index 0000000..ad6ef7a
--- /dev/null
+++ b/lib/dashboard/playground/trickle-mpegts.ts
@@ -0,0 +1,505 @@
+/** Browser MPEG-TS mux (publish) and demux-to-MSE (subscribe) for LV2V trickle. */
+
+import { DEFAULT_TRICKLE_MIME_TYPE } from "@pymthouse/builder-sdk/gateway";
+
+export { DEFAULT_TRICKLE_MIME_TYPE };
+
+type JmuxerInstance = {
+ feed: (payload: { video: Uint8Array; duration?: number }) => void;
+ destroy: () => void;
+};
+
+type MuxSegment = {
+ initSegment?: Uint8Array;
+ data: Uint8Array;
+};
+
+type Mp4Transmuxer = {
+ on: (event: "data", handler: (segment: MuxSegment) => void) => void;
+ push: (chunk: Uint8Array) => void;
+ flush: () => void;
+};
+
+type MuxJsModule = {
+ mp4: { Transmuxer: new () => Mp4Transmuxer };
+};
+
+function sleep(ms: number): Promise {
+ return new Promise((resolve) => window.setTimeout(resolve, ms));
+}
+
+const MSE_AVC_CODEC_CANDIDATES = [
+ 'video/mp4; codecs="avc1.42E01E"',
+ 'video/mp4; codecs="avc1.4d401e"',
+ 'video/mp4; codecs="avc1.64001E"',
+] as const;
+
+const PROGRESSIVE_FLUSH_INTERVAL_MS = 80;
+const PROGRESSIVE_FLUSH_MIN_BYTES = 8 * 1024;
+const LIVE_EDGE_LAG_SECONDS = 0.3;
+const DEFAULT_LIVE_PLAYBACK_RATE = 1.04;
+const MIN_LIVE_PLAYBACK_RATE = 0.85;
+const MAX_LIVE_PLAYBACK_RATE = 1.08;
+const LOW_BUFFER_LAG_SECONDS = 0.15;
+const TARGET_BUFFER_LAG_SECONDS = 0.6;
+
+function pickMseAvcMimeType(): string {
+ if (typeof MediaSource === "undefined") {
+ return MSE_AVC_CODEC_CANDIDATES[0];
+ }
+ for (const mime of MSE_AVC_CODEC_CANDIDATES) {
+ if (MediaSource.isTypeSupported(mime)) {
+ return mime;
+ }
+ }
+ return MSE_AVC_CODEC_CANDIDATES[0];
+}
+
+/** Encode canvas frames to MPEG-TS trickle segments (PyAV / write_frames.py equivalent). */
+export class MpegTsPublisher {
+ private jmuxer: JmuxerInstance | null = null;
+ /** jmuxer requires a video node in the browser even when only using onData. */
+ private jmuxerSink: HTMLVideoElement | null = null;
+ private encoder: VideoEncoder | null = null;
+ private frameCount = 0;
+ private timestampUs = 0;
+ private segmentChain: Promise = Promise.resolve();
+
+ constructor(
+ private readonly onSegment: (chunk: Uint8Array) => void,
+ private readonly fps: number,
+ ) {}
+
+ async start(
+ width: number,
+ height: number,
+ ): Promise {
+ if (typeof VideoEncoder === "undefined") {
+ throw new Error("WebCodecs VideoEncoder is not available in this browser");
+ }
+
+ const jmuxerModule = await import("jmuxer");
+ const JMuxer = jmuxerModule.default as new (options: Record) => JmuxerInstance;
+
+ const sink = document.createElement("video");
+ sink.muted = true;
+ sink.playsInline = true;
+ sink.setAttribute("aria-hidden", "true");
+ sink.style.cssText =
+ "position:fixed;width:0;height:0;opacity:0;pointer-events:none;overflow:hidden";
+ document.body.appendChild(sink);
+ this.jmuxerSink = sink;
+
+ this.jmuxer = new JMuxer({
+ node: sink,
+ mode: "video",
+ flushingTime: 100,
+ fps: this.fps,
+ clearBuffer: true,
+ onData: (data: ArrayBuffer) => {
+ const bytes = new Uint8Array(data);
+ void this.onSegment(bytes);
+ },
+ });
+
+ this.encoder = new VideoEncoder({
+ output: (chunk) => {
+ const buffer = new Uint8Array(chunk.byteLength);
+ chunk.copyTo(buffer);
+ const duration = Math.round(1000 / this.fps);
+ this.jmuxer?.feed({ video: buffer, duration });
+ },
+ error: (err) => {
+ throw err;
+ },
+ });
+
+ this.encoder.configure({
+ codec: "avc1.42E01E",
+ width,
+ height,
+ bitrate: 1_000_000,
+ framerate: this.fps,
+ latencyMode: "realtime",
+ avc: { format: "annexb" },
+ });
+ }
+
+ encode(canvas: HTMLCanvasElement): void {
+ if (!this.encoder) {
+ return;
+ }
+ const frame = new VideoFrame(canvas, { timestamp: this.timestampUs });
+ this.timestampUs += Math.round(1_000_000 / this.fps);
+ const keyFrame = this.frameCount % Math.max(1, Math.round(this.fps * 2)) === 0;
+ this.encoder.encode(frame, { keyFrame });
+ frame.close();
+ this.frameCount += 1;
+ }
+
+ async stop(): Promise {
+ if (this.encoder) {
+ await this.encoder.flush().catch(() => undefined);
+ this.encoder.close();
+ this.encoder = null;
+ }
+ this.jmuxer?.destroy();
+ this.jmuxer = null;
+ this.jmuxerSink?.remove();
+ this.jmuxerSink = null;
+ await this.segmentChain;
+ }
+}
+
+/** Play MPEG-TS trickle segments on a video element via MSE + mux.js. */
+export class MpegTsPlayer {
+ private mseMimeType: string = MSE_AVC_CODEC_CANDIDATES[0];
+ private video: HTMLVideoElement | null = null;
+ private mediaSource: MediaSource | null = null;
+ private sourceBuffer: SourceBuffer | null = null;
+ private transmuxer: Mp4Transmuxer | null = null;
+ private appendedInit = false;
+ private objectUrl: string | null = null;
+ private appendQueue: Array<{ data: Uint8Array; seekToLive: boolean }> = [];
+ private pendingSegments: Uint8Array[] = [];
+ private appending = false;
+ private pendingSeekToLive = false;
+ private playbackStarted = false;
+ private playbackRateTimer: number | null = null;
+ private bytesSinceFlush = 0;
+ private lastFlushMs = 0;
+ private ready = false;
+ private destroyed = false;
+ private attached = false;
+ private readyResolve: (() => void) | null = null;
+ private readyReject: ((err: Error) => void) | null = null;
+ private readonly readyPromise: Promise;
+
+ constructor() {
+ this.readyPromise = new Promise((resolve, reject) => {
+ this.readyResolve = resolve;
+ this.readyReject = reject;
+ });
+ }
+
+ /** Resolves when MSE source buffer is ready to accept trickle segments. */
+ waitUntilReady(timeoutMs = 12_000): Promise {
+ if (this.ready) {
+ return Promise.resolve();
+ }
+ return Promise.race([
+ this.readyPromise,
+ sleep(timeoutMs).then(() => {
+ throw new Error("Output video player did not become ready in time");
+ }),
+ ]);
+ }
+
+ attach(video: HTMLVideoElement | null): boolean {
+ if (!video || this.destroyed || this.attached) {
+ return false;
+ }
+
+ const mseMime = pickMseAvcMimeType();
+ if (typeof MediaSource === "undefined" || !MediaSource.isTypeSupported(mseMime)) {
+ this.failReady(new Error("MediaSource API is not available for MPEG-TS playback"));
+ return false;
+ }
+ this.mseMimeType = mseMime;
+
+ this.video = video;
+ this.attached = true;
+ this.mediaSource = new MediaSource();
+ this.objectUrl = URL.createObjectURL(this.mediaSource);
+
+ try {
+ video.muted = true;
+ video.playsInline = true;
+ video.src = this.objectUrl;
+ this.playbackRateTimer = window.setInterval(() => {
+ this.updateLivePlaybackRate();
+ }, 250);
+ } catch (err) {
+ this.attached = false;
+ this.failReady(err instanceof Error ? err : new Error("Failed to bind output video"));
+ return false;
+ }
+
+ const onSourceOpen = () => {
+ void this.onSourceOpen();
+ };
+
+ if (this.mediaSource.readyState === "open") {
+ onSourceOpen();
+ } else {
+ this.mediaSource.addEventListener("sourceopen", onSourceOpen, { once: true });
+ }
+
+ return true;
+ }
+
+ private failReady(err: Error): void {
+ this.readyReject?.(err);
+ this.readyResolve = null;
+ this.readyReject = null;
+ }
+
+ private markReady(): void {
+ if (this.ready) {
+ return;
+ }
+ this.ready = true;
+ this.readyResolve?.();
+ this.readyResolve = null;
+ this.readyReject = null;
+ for (const segment of this.pendingSegments) {
+ this.pushSegment(segment);
+ }
+ this.pendingSegments = [];
+ }
+
+ private async onSourceOpen(): Promise {
+ if (this.destroyed || !this.mediaSource) {
+ return;
+ }
+
+ try {
+ const muxjsModule = await import("mux.js");
+ if (this.destroyed || !this.mediaSource) {
+ return;
+ }
+
+ const muxjs = muxjsModule.default as MuxJsModule;
+ this.transmuxer = new muxjs.mp4.Transmuxer();
+ this.transmuxer.on("data", (segment) => {
+ this.enqueueTransmuxedSegment(segment);
+ });
+
+ this.sourceBuffer = this.mediaSource.addSourceBuffer(this.mseMimeType);
+ this.sourceBuffer.mode = "sequence";
+ this.sourceBuffer.addEventListener("updateend", () => {
+ this.appending = false;
+ this.updateLivePlaybackRate();
+ if (this.pendingSeekToLive) {
+ this.pendingSeekToLive = false;
+ this.seekVideoToLiveEdge();
+ }
+ void this.drainAppendQueue();
+ });
+ this.markReady();
+ void this.drainAppendQueue();
+ } catch (err) {
+ this.failReady(err instanceof Error ? err : new Error("MPEG-TS demux setup failed"));
+ }
+ }
+
+ pushSegment(tsBytes: Uint8Array): void {
+ if (this.destroyed) {
+ return;
+ }
+ if (!this.pushChunk(tsBytes)) {
+ return;
+ }
+ this.flushSegment();
+ }
+
+ /** Feed bytes from an in-flight trickle segment. Call flushSegment() at EOF. */
+ pushChunk(tsBytes: Uint8Array): boolean {
+ if (this.destroyed) {
+ return false;
+ }
+ if (!this.ready || !this.transmuxer) {
+ this.pendingSegments.push(tsBytes);
+ if (this.pendingSegments.length > 8) {
+ this.pendingSegments.shift();
+ }
+ return false;
+ }
+ this.transmuxer.push(tsBytes);
+ this.bytesSinceFlush += tsBytes.byteLength;
+ this.flushSegmentIfNeeded();
+ return true;
+ }
+
+ /** Flush mux.js once the current trickle segment response has closed. */
+ flushSegment(): void {
+ if (this.destroyed || !this.ready || !this.transmuxer) {
+ return;
+ }
+ this.transmuxer.flush();
+ this.bytesSinceFlush = 0;
+ this.lastFlushMs = Date.now();
+ }
+
+ private enqueueTransmuxedSegment(segment: MuxSegment): void {
+ let payload: Uint8Array;
+ if (segment.initSegment) {
+ if (!this.appendedInit) {
+ payload = new Uint8Array(segment.initSegment.byteLength + segment.data.byteLength);
+ payload.set(segment.initSegment, 0);
+ payload.set(segment.data, segment.initSegment.byteLength);
+ this.appendedInit = true;
+ } else {
+ payload = segment.data;
+ }
+ } else {
+ payload = segment.data;
+ }
+ this.enqueueAppend(payload, this.shouldSeekToLiveEdge());
+ }
+
+ private flushSegmentIfNeeded(): void {
+ const now = Date.now();
+ if (
+ this.bytesSinceFlush >= PROGRESSIVE_FLUSH_MIN_BYTES ||
+ now - this.lastFlushMs >= PROGRESSIVE_FLUSH_INTERVAL_MS
+ ) {
+ this.flushSegment();
+ }
+ }
+
+ private shouldSeekToLiveEdge(): boolean {
+ const video = this.video;
+ if (!video) {
+ return false;
+ }
+ if (!this.playbackStarted || video.paused) {
+ this.playbackStarted = true;
+ return true;
+ }
+ if (video.buffered.length === 0) {
+ return false;
+ }
+
+ const end = video.buffered.end(video.buffered.length - 1);
+ if (end - video.currentTime > LIVE_EDGE_LAG_SECONDS) {
+ return true;
+ }
+ return false;
+ }
+
+ private seekVideoToLiveEdge(): void {
+ const video = this.video;
+ if (!video || video.buffered.length === 0) {
+ return;
+ }
+ try {
+ const end = video.buffered.end(video.buffered.length - 1);
+ if (end > 0.05) {
+ video.currentTime = Math.max(0, end - 0.05);
+ }
+ void video.play().catch(() => undefined);
+ } catch {
+ // ignore seek failures while buffer is updating
+ }
+ }
+
+ private enqueueAppend(data: Uint8Array, seekToLive = false): void {
+ this.appendQueue.push({ data, seekToLive });
+ void this.drainAppendQueue();
+ }
+
+ private async drainAppendQueue(): Promise {
+ if (this.destroyed || this.appending || !this.sourceBuffer || this.appendQueue.length === 0) {
+ return;
+ }
+ if (this.sourceBuffer.updating) {
+ return;
+ }
+
+ const next = this.appendQueue.shift();
+ if (!next) {
+ return;
+ }
+
+ this.appending = true;
+ try {
+ const chunk = new Uint8Array(next.data);
+ const buffer = chunk.buffer.slice(
+ chunk.byteOffset,
+ chunk.byteOffset + chunk.byteLength,
+ );
+ if (next.seekToLive) {
+ this.pendingSeekToLive = true;
+ }
+ this.sourceBuffer.appendBuffer(buffer);
+ } catch {
+ this.appending = false;
+ }
+ }
+
+ private updateLivePlaybackRate(): void {
+ const video = this.video;
+ if (!video || video.buffered.length === 0) {
+ return;
+ }
+
+ const bufferedEnd = video.buffered.end(video.buffered.length - 1);
+ const bufferedLag = bufferedEnd - video.currentTime;
+
+ let playbackRate: number;
+ if (bufferedLag < LOW_BUFFER_LAG_SECONDS) {
+ playbackRate = MIN_LIVE_PLAYBACK_RATE;
+ } else if (bufferedLag > TARGET_BUFFER_LAG_SECONDS) {
+ playbackRate = MAX_LIVE_PLAYBACK_RATE;
+ } else {
+ const lagScale =
+ (bufferedLag - LOW_BUFFER_LAG_SECONDS) /
+ (TARGET_BUFFER_LAG_SECONDS - LOW_BUFFER_LAG_SECONDS);
+ playbackRate =
+ DEFAULT_LIVE_PLAYBACK_RATE +
+ (MAX_LIVE_PLAYBACK_RATE - DEFAULT_LIVE_PLAYBACK_RATE) * lagScale;
+ }
+ video.playbackRate = playbackRate;
+ }
+
+ destroy(): void {
+ this.destroyed = true;
+ this.attached = false;
+ this.appendQueue = [];
+ this.pendingSegments = [];
+ this.transmuxer = null;
+ this.appendedInit = false;
+ this.playbackStarted = false;
+ if (this.playbackRateTimer !== null) {
+ window.clearInterval(this.playbackRateTimer);
+ this.playbackRateTimer = null;
+ }
+ this.bytesSinceFlush = 0;
+ this.lastFlushMs = 0;
+ this.sourceBuffer = null;
+
+ const mediaSource = this.mediaSource;
+ this.mediaSource = null;
+ if (mediaSource && mediaSource.readyState === "open") {
+ try {
+ mediaSource.endOfStream();
+ } catch {
+ // ignore
+ }
+ }
+
+ const video = this.video;
+ this.video = null;
+ const objectUrl = this.objectUrl;
+ this.objectUrl = null;
+ if (objectUrl) {
+ URL.revokeObjectURL(objectUrl);
+ }
+ if (video) {
+ try {
+ video.removeAttribute("src");
+ video.load();
+ } catch {
+ // ignore
+ }
+ }
+
+ this.ready = false;
+ this.appending = false;
+ const reject = this.readyReject;
+ this.readyResolve = null;
+ this.readyReject = null;
+ reject?.(new Error("Output player destroyed"));
+ }
+}
diff --git a/lib/dashboard/pymthouse-bff.ts b/lib/dashboard/pymthouse-bff.ts
new file mode 100644
index 0000000..c6ae92c
--- /dev/null
+++ b/lib/dashboard/pymthouse-bff.ts
@@ -0,0 +1,224 @@
+import { PmtHouseError } from "@pymthouse/builder-sdk";
+import { createPmtHouseClientForPublicApp } from "@/lib/dashboard/device-flow";
+import {
+ dailyRequestSeriesForPipeline,
+ utcDateKeysForPeriod,
+} from "@/lib/dashboard/usage-capability-display";
+
+export type AccountUsageBalance = {
+ externalUserId: string;
+ balanceUsdMicros: string;
+ consumedUsdMicros: string;
+ lifetimeGrantedUsdMicros: string;
+ hasAccess: boolean;
+};
+
+export type AccountUsageDailyPipelineRow = {
+ pipeline: string;
+ modelId: string;
+ date: string;
+ requestCount: number;
+ networkFeeUsdMicros: string;
+};
+
+export type AccountUsagePipelineRow = {
+ pipeline: string;
+ modelId: string;
+ requestCount: number;
+ networkFeeUsdMicros: string;
+ endUserBillableUsdMicros: string;
+ /** OpenMeter daily buckets aligned to `period` (oldest → newest). */
+ dailyRequests: number[];
+};
+
+export type AccountUsagePayload = {
+ clientId: string;
+ period: { start: string; end: string };
+ /** UTC YYYY-MM-DD keys aligned with `pipelineModels[].dailyRequests` (oldest → newest). */
+ periodDayKeys: string[];
+ priorPeriod: { start: string; end: string };
+ balance: AccountUsageBalance | null;
+ current: {
+ requestCount: number;
+ networkFeeUsdMicros: string;
+ endUserBillableUsdMicros: string;
+ pipelineModels: AccountUsagePipelineRow[];
+ dailyByPipeline: AccountUsageDailyPipelineRow[];
+ };
+ prior: {
+ requestCount: number;
+ pipelineModels: AccountUsagePipelineRow[];
+ };
+};
+
+function readPublicClientId(): string {
+ const id =
+ process.env.PYMTHOUSE_PUBLIC_CLIENT_ID?.trim() ||
+ process.env.DASHBOARD_DEVICE_PUBLIC_CLIENT_ID?.trim();
+ if (!id) {
+ throw new PmtHouseError(
+ "PYMTHOUSE_PUBLIC_CLIENT_ID (or DASHBOARD_DEVICE_PUBLIC_CLIENT_ID) is required",
+ { status: 503, code: "pymthouse_required" },
+ );
+ }
+ return id;
+}
+
+function rollingPeriodDays(days: number, now = new Date()): {
+ startDate: string;
+ endDate: string;
+ priorStartDate: string;
+ priorEndDate: string;
+} {
+ const end = new Date(now);
+ end.setUTCHours(23, 59, 59, 999);
+ const start = new Date(end);
+ start.setUTCDate(start.getUTCDate() - (days - 1));
+ start.setUTCHours(0, 0, 0, 0);
+
+ const priorEnd = new Date(start);
+ priorEnd.setUTCMilliseconds(priorEnd.getUTCMilliseconds() - 1);
+ const priorStart = new Date(priorEnd);
+ priorStart.setUTCDate(priorStart.getUTCDate() - (days - 1));
+ priorStart.setUTCHours(0, 0, 0, 0);
+
+ return {
+ startDate: start.toISOString(),
+ endDate: end.toISOString(),
+ priorStartDate: priorStart.toISOString(),
+ priorEndDate: priorEnd.toISOString(),
+ };
+}
+
+async function fetchUsageBalance(
+ publicClientId: string,
+ externalUserId: string,
+): Promise {
+ const issuerUrl = process.env.PYMTHOUSE_ISSUER_URL?.trim();
+ if (!issuerUrl) {
+ return null;
+ }
+ const appsOrigin = issuerUrl.replace(/\/api\/v1\/oidc\/?$/i, "");
+ const url = new URL(
+ `${appsOrigin}/api/v1/apps/${encodeURIComponent(publicClientId)}/usage/balance`,
+ );
+ url.searchParams.set("externalUserId", externalUserId);
+
+ const m2mId = process.env.PYMTHOUSE_M2M_CLIENT_ID?.trim();
+ const m2mSecret = process.env.PYMTHOUSE_M2M_CLIENT_SECRET?.trim();
+ if (!m2mId || !m2mSecret) {
+ return null;
+ }
+
+ const basic = Buffer.from(`${m2mId}:${m2mSecret}`).toString("base64");
+ const response = await fetch(url.toString(), {
+ method: "GET",
+ headers: {
+ Authorization: `Basic ${basic}`,
+ Accept: "application/json",
+ },
+ cache: "no-store",
+ });
+
+ if (!response.ok) {
+ return null;
+ }
+
+ const body = (await response.json()) as {
+ externalUserId?: string;
+ balanceUsdMicros?: string;
+ consumedUsdMicros?: string;
+ lifetimeGrantedUsdMicros?: string;
+ hasAccess?: boolean;
+ };
+
+ return {
+ externalUserId: body.externalUserId ?? externalUserId,
+ balanceUsdMicros: body.balanceUsdMicros ?? "0",
+ consumedUsdMicros: body.consumedUsdMicros ?? "0",
+ lifetimeGrantedUsdMicros: body.lifetimeGrantedUsdMicros ?? "0",
+ hasAccess: Boolean(body.hasAccess),
+ };
+}
+
+export async function fetchAccountUsageForExternalUser(input: {
+ externalUserId: string;
+ periodDays?: number;
+}): Promise {
+ const publicClientId = readPublicClientId();
+ const days = input.periodDays ?? 30;
+ // Rolling window so the chart's last bucket is always UTC today (not month-end).
+ const period = rollingPeriodDays(days);
+
+ const client = createPmtHouseClientForPublicApp(publicClientId);
+
+ const [balance, currentScope, priorScope] = await Promise.all([
+ fetchUsageBalance(publicClientId, input.externalUserId),
+ client.fetchUsageForExternalUser({
+ externalUserId: input.externalUserId,
+ startDate: period.startDate,
+ endDate: period.endDate,
+ }),
+ client.fetchUsageForExternalUser({
+ externalUserId: input.externalUserId,
+ startDate: period.priorStartDate,
+ endDate: period.priorEndDate,
+ }),
+ ]);
+
+ const periodBounds = { start: period.startDate, end: period.endDate };
+ const dayKeys = utcDateKeysForPeriod(periodBounds.start, periodBounds.end);
+ const dailyByPipeline = (currentScope.currentUser.dailyByPipeline ?? []).map((row) => ({
+ pipeline: row.pipeline,
+ modelId: row.modelId,
+ date: row.date,
+ requestCount: row.requestCount,
+ networkFeeUsdMicros: row.networkFeeUsdMicros,
+ }));
+
+ const mapPipeline = (
+ rows: typeof currentScope.currentUser.pipelineModels,
+ ): AccountUsagePipelineRow[] =>
+ rows.map((row) => ({
+ pipeline: row.pipeline,
+ modelId: row.modelId,
+ requestCount: row.requestCount,
+ networkFeeUsdMicros: row.networkFeeUsdMicros,
+ endUserBillableUsdMicros: row.endUserBillableUsdMicros,
+ dailyRequests: dailyRequestSeriesForPipeline({
+ pipeline: row.pipeline,
+ modelId: row.modelId,
+ dayKeys,
+ dailyByPipeline,
+ }),
+ }));
+
+ return {
+ clientId: currentScope.clientId,
+ period: periodBounds,
+ periodDayKeys: dayKeys,
+ priorPeriod: { start: period.priorStartDate, end: period.priorEndDate },
+ balance,
+ current: {
+ requestCount: currentScope.currentUser.requestCount,
+ networkFeeUsdMicros: currentScope.currentUser.networkFeeUsdMicros,
+ endUserBillableUsdMicros: currentScope.currentUser.endUserBillableUsdMicros,
+ pipelineModels: mapPipeline(currentScope.currentUser.pipelineModels),
+ dailyByPipeline,
+ },
+ prior: {
+ requestCount: priorScope.currentUser.requestCount,
+ pipelineModels: mapPipeline(
+ priorScope.currentUser.pipelineModels,
+ ).map((row) => ({
+ ...row,
+ dailyRequests: dailyRequestSeriesForPipeline({
+ pipeline: row.pipeline,
+ modelId: row.modelId,
+ dayKeys: utcDateKeysForPeriod(period.priorStartDate, period.priorEndDate),
+ dailyByPipeline: [],
+ }),
+ })),
+ },
+ };
+}
diff --git a/lib/dashboard/pymthouse-keys-bff.ts b/lib/dashboard/pymthouse-keys-bff.ts
new file mode 100644
index 0000000..2ebf8d4
--- /dev/null
+++ b/lib/dashboard/pymthouse-keys-bff.ts
@@ -0,0 +1,183 @@
+import { PmtHouseError } from "@pymthouse/builder-sdk";
+
+export type DashboardApiKeyRow = {
+ id: string;
+ label: string | null;
+ prefix: string;
+ suffix: string;
+ status: string;
+ createdAt: string;
+ revokedAt: string | null;
+};
+
+function readPublicClientId(): string {
+ const id =
+ process.env.PYMTHOUSE_PUBLIC_CLIENT_ID?.trim() ||
+ process.env.DASHBOARD_DEVICE_PUBLIC_CLIENT_ID?.trim();
+ if (!id) {
+ throw new PmtHouseError(
+ "PYMTHOUSE_PUBLIC_CLIENT_ID (or DASHBOARD_DEVICE_PUBLIC_CLIENT_ID) is required",
+ { status: 503, code: "pymthouse_required" },
+ );
+ }
+ return id;
+}
+
+function readM2mAuthHeader(): string {
+ const m2mId = process.env.PYMTHOUSE_M2M_CLIENT_ID?.trim();
+ const m2mSecret = process.env.PYMTHOUSE_M2M_CLIENT_SECRET?.trim();
+ if (!m2mId || !m2mSecret) {
+ throw new PmtHouseError(
+ "PYMTHOUSE_M2M_CLIENT_ID and PYMTHOUSE_M2M_CLIENT_SECRET are required",
+ { status: 503, code: "pymthouse_required" },
+ );
+ }
+ return `Basic ${Buffer.from(`${m2mId}:${m2mSecret}`).toString("base64")}`;
+}
+
+function appsOrigin(): string {
+ const issuerUrl = process.env.PYMTHOUSE_ISSUER_URL?.trim();
+ if (!issuerUrl) {
+ throw new PmtHouseError("PYMTHOUSE_ISSUER_URL is required", {
+ status: 503,
+ code: "pymthouse_required",
+ });
+ }
+ return issuerUrl.replace(/\/api\/v1\/oidc\/?$/i, "");
+}
+
+function userKeysUrl(publicClientId: string, externalUserId: string): string {
+ return `${appsOrigin()}/api/v1/apps/${encodeURIComponent(publicClientId)}/users/${encodeURIComponent(externalUserId)}/keys`;
+}
+
+async function readErrorMessage(response: Response): Promise {
+ try {
+ const body = (await response.json()) as { error?: string; error_description?: string };
+ return body.error_description ?? body.error ?? `Request failed (${response.status})`;
+ } catch {
+ return `Request failed (${response.status})`;
+ }
+}
+
+export async function listDashboardApiKeys(
+ externalUserId: string,
+): Promise {
+ const publicClientId = readPublicClientId();
+ const response = await fetch(userKeysUrl(publicClientId, externalUserId), {
+ method: "GET",
+ headers: {
+ Authorization: readM2mAuthHeader(),
+ Accept: "application/json",
+ },
+ cache: "no-store",
+ });
+
+ if (!response.ok) {
+ throw new PmtHouseError(await readErrorMessage(response), {
+ status: response.status,
+ code: "api_keys_list_failed",
+ });
+ }
+
+ const body = (await response.json()) as { keys?: DashboardApiKeyRow[] };
+ return (body.keys ?? []).filter((row) => row.status === "active");
+}
+
+export async function ensureAppUserProvisioned(
+ publicClientId: string,
+ externalUserId: string,
+) {
+ const response = await fetch(`${appsOrigin()}/api/v1/apps/${encodeURIComponent(publicClientId)}/users`, {
+ method: "POST",
+ headers: {
+ Authorization: readM2mAuthHeader(),
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ externalUserId,
+ email: externalUserId,
+ status: "active",
+ }),
+ cache: "no-store",
+ });
+ if (!response.ok && response.status !== 409) {
+ throw new PmtHouseError(await readErrorMessage(response), {
+ status: response.status,
+ code: "app_user_provision_failed",
+ });
+ }
+}
+
+export async function createDashboardApiKey(input: {
+ externalUserId: string;
+ label?: string;
+}): Promise<{ apiKey: string; row: DashboardApiKeyRow }> {
+ const publicClientId = readPublicClientId();
+ await ensureAppUserProvisioned(publicClientId, input.externalUserId);
+
+ const response = await fetch(userKeysUrl(publicClientId, input.externalUserId), {
+ method: "POST",
+ headers: {
+ Authorization: readM2mAuthHeader(),
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(input.label ? { label: input.label } : {}),
+ cache: "no-store",
+ });
+
+ if (!response.ok) {
+ throw new PmtHouseError(await readErrorMessage(response), {
+ status: response.status,
+ code: "api_key_create_failed",
+ });
+ }
+
+ const body = (await response.json()) as {
+ apiKey: string;
+ id: string;
+ prefix: string;
+ suffix: string;
+ label: string | null;
+ createdAt: string;
+ };
+
+ return {
+ apiKey: body.apiKey,
+ row: {
+ id: body.id,
+ label: body.label,
+ prefix: body.prefix,
+ suffix: body.suffix,
+ status: "active",
+ createdAt: body.createdAt,
+ revokedAt: null,
+ },
+ };
+}
+
+export async function revokeDashboardApiKey(input: {
+ externalUserId: string;
+ keyId: string;
+}): Promise {
+ const publicClientId = readPublicClientId();
+ const url = new URL(userKeysUrl(publicClientId, input.externalUserId));
+ url.searchParams.set("keyId", input.keyId);
+
+ const response = await fetch(url.toString(), {
+ method: "DELETE",
+ headers: {
+ Authorization: readM2mAuthHeader(),
+ Accept: "application/json",
+ },
+ cache: "no-store",
+ });
+
+ if (!response.ok) {
+ throw new PmtHouseError(await readErrorMessage(response), {
+ status: response.status,
+ code: "api_key_revoke_failed",
+ });
+ }
+}
diff --git a/lib/dashboard/pymthouse-signing-bff.ts b/lib/dashboard/pymthouse-signing-bff.ts
new file mode 100644
index 0000000..7bb9c6c
--- /dev/null
+++ b/lib/dashboard/pymthouse-signing-bff.ts
@@ -0,0 +1,119 @@
+import { PmtHouseError } from "@pymthouse/builder-sdk";
+import { ensureAppUserProvisioned } from "@/lib/dashboard/pymthouse-keys-bff";
+
+function readPublicClientId(): string {
+ const id =
+ process.env.PYMTHOUSE_PUBLIC_CLIENT_ID?.trim() ||
+ process.env.DASHBOARD_DEVICE_PUBLIC_CLIENT_ID?.trim();
+ if (!id) {
+ throw new PmtHouseError(
+ "PYMTHOUSE_PUBLIC_CLIENT_ID (or DASHBOARD_DEVICE_PUBLIC_CLIENT_ID) is required",
+ { status: 503, code: "pymthouse_required" },
+ );
+ }
+ return id;
+}
+
+function readM2mAuthHeader(): string {
+ const m2mId = process.env.PYMTHOUSE_M2M_CLIENT_ID?.trim();
+ const m2mSecret = process.env.PYMTHOUSE_M2M_CLIENT_SECRET?.trim();
+ if (!m2mId || !m2mSecret) {
+ throw new PmtHouseError(
+ "PYMTHOUSE_M2M_CLIENT_ID and PYMTHOUSE_M2M_CLIENT_SECRET are required",
+ { status: 503, code: "pymthouse_required" },
+ );
+ }
+ return `Basic ${Buffer.from(`${m2mId}:${m2mSecret}`).toString("base64")}`;
+}
+
+function appsOrigin(): string {
+ const issuerUrl = process.env.PYMTHOUSE_ISSUER_URL?.trim();
+ if (!issuerUrl) {
+ throw new PmtHouseError("PYMTHOUSE_ISSUER_URL is required", {
+ status: 503,
+ code: "pymthouse_required",
+ });
+ }
+ return issuerUrl.replace(/\/api\/v1\/oidc\/?$/i, "");
+}
+
+async function readErrorMessage(response: Response): Promise {
+ try {
+ const body = (await response.json()) as { error?: string; error_description?: string };
+ return body.error_description ?? body.error ?? `Request failed (${response.status})`;
+ } catch {
+ return `Request failed (${response.status})`;
+ }
+}
+
+export type MintedSigningToken = {
+ access_token: string;
+ expires_in: number;
+ scope: string;
+ token_type: string;
+};
+
+export async function mintDashboardUserSigningToken(input: {
+ externalUserId: string;
+ scope?: string;
+}): Promise {
+ const externalUserId = input.externalUserId.trim();
+ if (!externalUserId) {
+ throw new PmtHouseError("externalUserId is required", {
+ status: 400,
+ code: "invalid_request",
+ });
+ }
+
+ const publicClientId = readPublicClientId();
+ await ensureAppUserProvisioned(publicClientId, externalUserId);
+
+ const scope = input.scope?.trim() || "sign:job";
+ const url = `${appsOrigin()}/api/v1/apps/${encodeURIComponent(publicClientId)}/users/${encodeURIComponent(externalUserId)}/token`;
+
+ const response = await fetch(url, {
+ method: "POST",
+ headers: {
+ Authorization: readM2mAuthHeader(),
+ Accept: "application/json",
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ scope }),
+ cache: "no-store",
+ });
+
+ if (!response.ok) {
+ throw new PmtHouseError(await readErrorMessage(response), {
+ status: response.status,
+ code: "signing_token_mint_failed",
+ });
+ }
+
+ const body = (await response.json()) as Record;
+ const accessToken =
+ typeof body.access_token === "string" ? body.access_token.trim() : "";
+ if (!accessToken) {
+ throw new PmtHouseError("Token response missing access_token", {
+ status: 502,
+ code: "invalid_token_response",
+ });
+ }
+
+ const expiresIn =
+ typeof body.expires_in === "number" && Number.isFinite(body.expires_in)
+ ? body.expires_in
+ : 900;
+
+ const scopeOut =
+ typeof body.scope === "string" && body.scope.trim() ? body.scope.trim() : scope;
+
+ return {
+ access_token: accessToken,
+ expires_in: expiresIn,
+ scope: scopeOut,
+ token_type:
+ typeof body.token_type === "string" && body.token_type.trim()
+ ? body.token_type.trim()
+ : "Bearer",
+ };
+}
diff --git a/lib/dashboard/sdk-streaming-example.ts b/lib/dashboard/sdk-streaming-example.ts
new file mode 100644
index 0000000..9b7dbad
--- /dev/null
+++ b/lib/dashboard/sdk-streaming-example.ts
@@ -0,0 +1,131 @@
+import type { Model } from "@/lib/dashboard/types";
+
+/** Public Dashboard origin used in SDK examples (device exchange facade). */
+export function getDashboardFacadeOrigin(): string {
+ const fromEnv = process.env.NEXT_PUBLIC_DASHBOARD_ORIGIN?.trim();
+ if (fromEnv) return fromEnv.replace(/\/$/, "");
+ if (typeof window !== "undefined" && window.location?.origin) {
+ return window.location.origin;
+ }
+ return "http://localhost:3001";
+}
+
+/** OIDC issuer for RFC 8628 device login (Keycloak clearinghouse realm). */
+export function getClearinghouseOidcIssuerUrl(): string {
+ return (
+ process.env.NEXT_PUBLIC_CLEARINGHOUSE_OIDC_ISSUER_URL?.trim() ||
+ process.env.NEXT_PUBLIC_SIGNER_OIDC_ISSUER_URL?.trim() ||
+ "http://127.0.0.1:8080/realms/clearinghouse"
+ ).replace(/\/$/, "");
+}
+
+/** Public app client id for device authorization (app_*). */
+export function getClearinghousePublicClientId(): string {
+ return (
+ process.env.NEXT_PUBLIC_CLEARINGHOUSE_PUBLIC_CLIENT_ID?.trim() || "app_demo"
+ );
+}
+
+/** Apache DMZ / remote signer base for discovery and payment hot path. */
+export function getClearinghouseSignerBaseUrl(): string {
+ return (
+ process.env.NEXT_PUBLIC_CLEARINGHOUSE_API_URL?.trim() ||
+ "http://127.0.0.1:8080"
+ ).replace(/\/$/, "");
+}
+
+export function isStreamingCapabilityModel(model: Model): boolean {
+ const id = model.id.toLowerCase();
+ return (
+ model.realtime === true ||
+ id.includes("streamdiffusion") ||
+ id.includes("live-video") ||
+ model.category === "Live Transcoding"
+ );
+}
+
+export function buildPythonSdkStreamingSnippet(model: Model): string {
+ const dashboardOrigin = getDashboardFacadeOrigin();
+ const issuerUrl = getClearinghouseOidcIssuerUrl();
+ const publicClientId = getClearinghousePublicClientId();
+ const signerBase = getClearinghouseSignerBaseUrl();
+ const discoveryUrl = `${signerBase}/discover-orchestrators?cap=${encodeURIComponent(model.id)}`;
+ const modelId = model.id.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
+
+ return `import asyncio
+
+from livepeer_gateway.lv2v import StartJobRequest, start_lv2v
+from livepeer_gateway.media_publish import MediaPublishConfig, VideoOutputConfig
+
+# 1) Device login at the clearinghouse OIDC issuer (RFC 8628).
+# 2) Exchange the user access token for a long-lived signer JWT via the Dashboard facade.
+# 3) Stream with signer/discovery headers on the clearinghouse hot path.
+
+DASHBOARD_ORIGIN = "${dashboardOrigin}"
+OIDC_ISSUER_URL = "${issuerUrl}"
+PUBLIC_CLIENT_ID = "${publicClientId}"
+SIGNER_BASE_URL = "${signerBase}"
+DISCOVERY_URL = "${discoveryUrl}"
+MODEL_ID = "${modelId}"
+
+
+async def main() -> None:
+ job = start_lv2v(
+ orch_url=None,
+ req=StartJobRequest(model_id=MODEL_ID),
+ billing_url=DASHBOARD_ORIGIN,
+ issuer_url=OIDC_ISSUER_URL,
+ signer_url=SIGNER_BASE_URL,
+ discovery_url=DISCOVERY_URL,
+ oidc_client_id=PUBLIC_CLIENT_ID,
+ scope="sign:job openid profile",
+ headless=True,
+ )
+
+ print("publish_url:", job.publish_url)
+ print("subscribe_url:", job.subscribe_url)
+
+ media = job.start_media(MediaPublishConfig(tracks=[VideoOutputConfig(fps=30.0)]))
+ # await media.write_frame(frame) # your frames here
+ await job.close()
+
+
+if __name__ == "__main__":
+ asyncio.run(main())`;
+}
+
+export function getStreamingFacadeEndpointLabel(_model: Model): string {
+ const origin = getDashboardFacadeOrigin();
+ return `POST ${origin}/api/pymthouse/keys/exchange → POST ${origin}/api/gateway/sessions`;
+}
+
+export function buildGatewayStreamingSnippet(model: Model): string {
+ const dashboardOrigin = getDashboardFacadeOrigin();
+ const modelId = model.id.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
+
+ return `import { BrowserGatewayClient } from "@pymthouse/builder-sdk/gateway/client";
+
+const DASHBOARD_ORIGIN = "${dashboardOrigin}";
+const API_KEY = process.env.PMTH_API_KEY!; // pmth_* from Settings → API keys
+const MODEL_ID = "${modelId}";
+
+const client = new BrowserGatewayClient({ baseUrl: DASHBOARD_ORIGIN });
+
+await client.connect({
+ type: "apiKey",
+ apiKey: API_KEY,
+ facadeUrl: DASHBOARD_ORIGIN,
+ scope: "sign:job",
+});
+
+const { sessionId, manifestId } = await client.startSession({ modelId: MODEL_ID });
+console.log("session:", sessionId, "manifest:", manifestId);
+
+// Publish JPEG segments (e.g. from canvas.toBlob) via same-origin relay:
+// await client.publishSegment(bytes, { contentType: "image/jpeg" });
+
+// Poll orchestrator output segments:
+// const out = await client.subscribeSegment();
+
+await client.stop();`;
+}
diff --git a/lib/dashboard/signer-proxy.server.ts b/lib/dashboard/signer-proxy.server.ts
new file mode 100644
index 0000000..aee98c9
--- /dev/null
+++ b/lib/dashboard/signer-proxy.server.ts
@@ -0,0 +1,75 @@
+import "server-only";
+
+import { resolveDashboardSignerUpstreamUrl } from "@/lib/dashboard/gateway-config.server";
+
+const ALLOWED_SIGNER_PROXY_SUFFIXES = new Set([
+ "sign-orchestrator-info",
+ "generate-live-payment",
+ "discover-orchestrators",
+]);
+
+export function isAllowedSignerProxyPath(pathSegments: string[]): boolean {
+ if (pathSegments.length !== 1) {
+ return false;
+ }
+ return ALLOWED_SIGNER_PROXY_SUFFIXES.has(pathSegments[0]);
+}
+
+export async function forwardSignerProxyRequest(
+ request: Request,
+ pathSegments: string[],
+): Promise {
+ const upstreamBase = resolveDashboardSignerUpstreamUrl();
+ if (!upstreamBase) {
+ return Response.json(
+ {
+ error: "server_misconfigured",
+ error_description:
+ "PYMTHOUSE_SIGNER_URL or PYMTHOUSE_ISSUER_URL is required for signer proxy",
+ },
+ { status: 503 },
+ );
+ }
+
+ if (!isAllowedSignerProxyPath(pathSegments)) {
+ return Response.json({ error: "not_found" }, { status: 404 });
+ }
+
+ const suffix = pathSegments[0];
+ const target = `${upstreamBase.replace(/\/+$/, "")}/${suffix}`;
+ const headers = new Headers();
+ const authorization = request.headers.get("authorization");
+ if (authorization) {
+ headers.set("Authorization", authorization);
+ }
+ const contentType = request.headers.get("content-type");
+ if (contentType) {
+ headers.set("Content-Type", contentType);
+ }
+ headers.set("Accept", request.headers.get("accept") ?? "application/json");
+
+ const method = request.method.toUpperCase();
+ const body =
+ method === "GET" || method === "HEAD" ? undefined : await request.arrayBuffer();
+
+ const upstream = await fetch(target, {
+ method,
+ headers,
+ body,
+ });
+
+ const responseHeaders = new Headers();
+ const upstreamContentType = upstream.headers.get("content-type");
+ if (upstreamContentType) {
+ responseHeaders.set("Content-Type", upstreamContentType);
+ }
+ const livepeerOrch = upstream.headers.get("Livepeer-Orchestrator-URL");
+ if (livepeerOrch) {
+ responseHeaders.set("Livepeer-Orchestrator-URL", livepeerOrch);
+ }
+
+ return new Response(upstream.body, {
+ status: upstream.status,
+ headers: responseHeaders,
+ });
+}
diff --git a/lib/dashboard/signing-token-storage.ts b/lib/dashboard/signing-token-storage.ts
new file mode 100644
index 0000000..d559e9d
--- /dev/null
+++ b/lib/dashboard/signing-token-storage.ts
@@ -0,0 +1,59 @@
+const STORAGE_KEY = "livepeer.dashboard.signingToken";
+
+export type StoredSigningToken = {
+ externalUserId: string;
+ accessToken: string;
+ expiresAtMs: number;
+ scope: string;
+};
+
+export function getStoredSigningToken(
+ externalUserId: string,
+): StoredSigningToken | null {
+ if (typeof window === "undefined") {
+ return null;
+ }
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (!raw) {
+ return null;
+ }
+ const parsed = JSON.parse(raw) as StoredSigningToken;
+ if (
+ parsed.externalUserId !== externalUserId.trim() ||
+ typeof parsed.accessToken !== "string" ||
+ !parsed.accessToken.trim() ||
+ typeof parsed.expiresAtMs !== "number"
+ ) {
+ return null;
+ }
+ if (parsed.expiresAtMs <= Date.now() + 5_000) {
+ return null;
+ }
+ return parsed;
+ } catch {
+ return null;
+ }
+}
+
+export function setStoredSigningToken(entry: StoredSigningToken): void {
+ if (typeof window === "undefined") {
+ return;
+ }
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(entry));
+ } catch {
+ // ignore quota / private mode
+ }
+}
+
+export function clearStoredSigningToken(): void {
+ if (typeof window === "undefined") {
+ return;
+ }
+ try {
+ localStorage.removeItem(STORAGE_KEY);
+ } catch {
+ // ignore
+ }
+}
diff --git a/lib/dashboard/streaming-playground.ts b/lib/dashboard/streaming-playground.ts
new file mode 100644
index 0000000..1b5f938
--- /dev/null
+++ b/lib/dashboard/streaming-playground.ts
@@ -0,0 +1,76 @@
+import type { Model, PlaygroundConfig } from "@/lib/dashboard/types";
+
+/** Discovery capability ids that get the LV2V webcam / gateway playground. */
+export function isLv2vPlaygroundCapability(capability: string): boolean {
+ const id = capability.toLowerCase();
+ return (
+ id.includes("streamdiffusion") ||
+ id === "live-video-to-video" ||
+ id.startsWith("live-video")
+ );
+}
+
+/**
+ * model_id for POST /api/gateway/sessions and signer discovery caps.
+ * Discovery page id may differ from orchestrator pipeline model name.
+ */
+export function resolveGatewayModelId(capability: string): string {
+ const id = capability.trim();
+ const lower = id.toLowerCase();
+ if (lower === "streamdiffusion") {
+ return "streamdiffusion";
+ }
+ if (lower === "live-video-to-video") {
+ return "streamdiffusion-sdxl";
+ }
+ return id;
+}
+
+export function buildLv2vPlaygroundConfig(capability: string): PlaygroundConfig {
+ return {
+ fields: [
+ {
+ name: "prompt",
+ label: "Prompt",
+ type: "textarea",
+ placeholder: "Describe the look or style for the stream…",
+ description: "Optional pipeline prompt (passed when starting the LV2V job).",
+ },
+ {
+ name: "style",
+ label: "Style preset",
+ type: "select",
+ options: ["none", "cinematic", "anime", "watercolor", "neon", "sketch"],
+ defaultValue: "none",
+ description: "Local preview label only until full pipeline params are wired.",
+ },
+ {
+ name: "strength",
+ label: "Strength",
+ type: "range",
+ min: 0,
+ max: 1,
+ step: 0.05,
+ defaultValue: 0.6,
+ },
+ ],
+ outputType: "video",
+ playgroundVariant: "webcam",
+ mockOutputUrl: "https://picsum.photos/seed/streamdiffusion/640/360",
+ };
+}
+
+export function enrichDiscoveryModelForStreaming(model: Model): Model {
+ if (!isLv2vPlaygroundCapability(model.id)) {
+ return model;
+ }
+
+ return {
+ ...model,
+ realtime: true,
+ category:
+ model.category === "Language" ? "Video Generation" : model.category,
+ gatewayModelId: resolveGatewayModelId(model.id),
+ playgroundConfig: model.playgroundConfig ?? buildLv2vPlaygroundConfig(model.id),
+ };
+}
diff --git a/lib/dashboard/types.ts b/lib/dashboard/types.ts
index eca492e..8e23bd7 100644
--- a/lib/dashboard/types.ts
+++ b/lib/dashboard/types.ts
@@ -85,6 +85,8 @@ export interface Model {
featured?: boolean;
/** Supports streaming (WebRTC) inference in addition to request/response. The differentiator on the network — flagged as a capability pill and filterable on Explore. */
realtime?: boolean;
+ /** LV2V model_id for gateway sessions when different from discovery capability `id`. */
+ gatewayModelId?: string;
/** ISO-8601 date the model was published on the network. Drives the "NEW" badge and Recently-added sort. */
releasedAt?: string;
tags?: string[];
diff --git a/lib/dashboard/usage-capability-display.test.ts b/lib/dashboard/usage-capability-display.test.ts
new file mode 100644
index 0000000..12630be
--- /dev/null
+++ b/lib/dashboard/usage-capability-display.test.ts
@@ -0,0 +1,61 @@
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+import {
+ buildUsageCapabilityRows,
+ dailyRequestSeriesForPipeline,
+ utcDateKeysForPeriod,
+} from "./usage-capability-display";
+
+describe("usage-capability-display", () => {
+ it("utcDateKeysForPeriod returns inclusive UTC days", () => {
+ const keys = utcDateKeysForPeriod(
+ "2026-06-01T00:00:00.000Z",
+ "2026-06-03T23:59:59.999Z",
+ );
+ assert.deepEqual(keys, ["2026-06-01", "2026-06-02", "2026-06-03"]);
+ });
+
+ it("dailyRequestSeriesForPipeline aligns OpenMeter day buckets", () => {
+ const dayKeys = ["2026-06-01", "2026-06-02", "2026-06-03"];
+ const series = dailyRequestSeriesForPipeline({
+ pipeline: "live-video-to-video",
+ modelId: "streamdiffusion",
+ dayKeys,
+ dailyByPipeline: [
+ {
+ pipeline: "live-video-to-video",
+ modelId: "streamdiffusion",
+ date: "2026-06-02",
+ requestCount: 5,
+ },
+ {
+ pipeline: "live-video-to-video",
+ modelId: "streamdiffusion",
+ date: "2026-06-03",
+ requestCount: 14,
+ },
+ ],
+ });
+ assert.deepEqual(series, [0, 5, 14]);
+ assert.equal(series.reduce((a, b) => a + b, 0), 19);
+ });
+
+ it("buildUsageCapabilityRows uses dailyRequests from API", () => {
+ const rows = buildUsageCapabilityRows({
+ period: { start: "2026-06-01T00:00:00.000Z", end: "2026-06-03T23:59:59.999Z" },
+ current: [
+ {
+ pipeline: "live-video-to-video",
+ modelId: "streamdiffusion",
+ requestCount: 19,
+ networkFeeUsdMicros: "113277",
+ endUserBillableUsdMicros: "0",
+ dailyRequests: [0, 5, 14],
+ },
+ ],
+ prior: [],
+ });
+ assert.equal(rows.length, 1);
+ assert.deepEqual(rows[0]!.data, [0, 5, 14]);
+ });
+});
diff --git a/lib/dashboard/usage-capability-display.ts b/lib/dashboard/usage-capability-display.ts
new file mode 100644
index 0000000..8e6ab5c
--- /dev/null
+++ b/lib/dashboard/usage-capability-display.ts
@@ -0,0 +1,151 @@
+import type { AccountUsagePipelineRow } from "@/lib/dashboard/pymthouse-bff";
+
+const CAPABILITY_COLORS = [
+ "#4ade80",
+ "#38bdf8",
+ "#a78bfa",
+ "#fb923c",
+ "#f472b6",
+ "#facc15",
+ "#2dd4bf",
+ "#818cf8",
+];
+
+export type UsageCapabilityRow = AccountUsagePipelineRow & {
+ id: string;
+ name: string;
+ color: string;
+ spendUsd: number;
+ data: number[];
+ priorSum: number;
+ delta: number;
+};
+
+function humanizePipelineModel(pipeline: string, modelId: string): string {
+ const segment = modelId && modelId !== "*" ? modelId : pipeline;
+ const raw = segment.includes(":") ? segment.split(":").slice(-1)[0]! : segment;
+ return raw
+ .split(/[-_./|:]+/)
+ .filter(Boolean)
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
+ .join(" ");
+}
+
+function microsToUsd(micros: string): number {
+ try {
+ return Number(BigInt(micros)) / 1_000_000;
+ } catch {
+ return 0;
+ }
+}
+
+/** UTC calendar dates (YYYY-MM-DD) from period start through end inclusive. */
+export function utcDateKeysForPeriod(startIso: string, endIso: string): string[] {
+ const start = new Date(startIso);
+ const end = new Date(endIso);
+ if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
+ return [];
+ }
+ const keys: string[] = [];
+ const cursor = new Date(
+ Date.UTC(start.getUTCFullYear(), start.getUTCMonth(), start.getUTCDate()),
+ );
+ const endDay = new Date(
+ Date.UTC(end.getUTCFullYear(), end.getUTCMonth(), end.getUTCDate()),
+ );
+ while (cursor <= endDay) {
+ keys.push(cursor.toISOString().slice(0, 10));
+ cursor.setUTCDate(cursor.getUTCDate() + 1);
+ }
+ return keys;
+}
+
+export function dailyRequestSeriesForPipeline(input: {
+ pipeline: string;
+ modelId: string;
+ dayKeys: string[];
+ dailyByPipeline: Array<{
+ pipeline: string;
+ modelId: string;
+ date: string;
+ requestCount: number;
+ }>;
+}): number[] {
+ const countsByDay = new Map();
+ const key = `${input.pipeline}|${input.modelId}`;
+ for (const row of input.dailyByPipeline) {
+ if (`${row.pipeline}|${row.modelId}` !== key) continue;
+ countsByDay.set(row.date, (countsByDay.get(row.date) ?? 0) + row.requestCount);
+ }
+ return input.dayKeys.map((day) => countsByDay.get(day) ?? 0);
+}
+
+export function buildUsageCapabilityRows(input: {
+ current: AccountUsagePipelineRow[];
+ prior: AccountUsagePipelineRow[];
+ period: { start: string; end: string };
+ dailyByPipeline?: Array<{
+ pipeline: string;
+ modelId: string;
+ date: string;
+ requestCount: number;
+ }>;
+}): UsageCapabilityRow[] {
+ const priorByKey = new Map(
+ input.prior.map((row) => [`${row.pipeline}|${row.modelId}`, row]),
+ );
+ const dayKeys = utcDateKeysForPeriod(input.period.start, input.period.end);
+
+ return input.current
+ .map((row, index) => {
+ const key = `${row.pipeline}|${row.modelId}`;
+ const priorRow = priorByKey.get(key);
+ const priorSum = priorRow?.requestCount ?? 0;
+ const delta =
+ priorSum > 0 ? ((row.requestCount - priorSum) / priorSum) * 100 : row.requestCount > 0 ? 100 : 0;
+ const spendUsd = microsToUsd(row.endUserBillableUsdMicros || row.networkFeeUsdMicros);
+ const seriesSum = row.dailyRequests.reduce((a, b) => a + b, 0);
+ const data =
+ row.dailyRequests.length > 0 && seriesSum > 0
+ ? row.dailyRequests
+ : input.dailyByPipeline?.length && dayKeys.length > 0
+ ? dailyRequestSeriesForPipeline({
+ pipeline: row.pipeline,
+ modelId: row.modelId,
+ dayKeys,
+ dailyByPipeline: input.dailyByPipeline,
+ })
+ : row.dailyRequests;
+ return {
+ ...row,
+ id: key,
+ name: humanizePipelineModel(row.pipeline, row.modelId),
+ color: CAPABILITY_COLORS[index % CAPABILITY_COLORS.length]!,
+ spendUsd,
+ data,
+ priorSum,
+ delta,
+ };
+ })
+ .sort((a, b) => b.requestCount - a.requestCount);
+}
+
+export function microsToUsdDisplay(micros: string): string {
+ const usd = microsToUsd(micros);
+ if (usd >= 100) return usd.toFixed(2);
+ if (usd >= 1) return usd.toFixed(2);
+ if (usd >= 0.01) return usd.toFixed(3);
+ return usd.toFixed(4);
+}
+
+export function formatPeriodResetLabel(periodEndIso: string): string {
+ try {
+ const end = new Date(periodEndIso);
+ const next = new Date(
+ Date.UTC(end.getUTCFullYear(), end.getUTCMonth() + 1, 1, 0, 0, 0, 0),
+ );
+ return next.toLocaleDateString(undefined, { month: "short", day: "numeric" });
+ } catch {
+ return "next period";
+ }
+}
diff --git a/lib/dashboard/useAccountUsage.ts b/lib/dashboard/useAccountUsage.ts
new file mode 100644
index 0000000..41c1dba
--- /dev/null
+++ b/lib/dashboard/useAccountUsage.ts
@@ -0,0 +1,48 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import type { AccountUsagePayload } from "@/lib/dashboard/pymthouse-bff";
+
+export type AccountUsageState =
+ | { status: "idle" }
+ | { status: "loading" }
+ | { status: "ready"; data: AccountUsagePayload }
+ | { status: "error"; message: string };
+
+export function useAccountUsage(externalUserId: string | undefined, periodDays = 30) {
+ const [state, setState] = useState({ status: "idle" });
+
+ const load = useCallback(async () => {
+ if (!externalUserId?.trim()) {
+ setState({ status: "error", message: "Sign in to load usage for your account." });
+ return;
+ }
+
+ setState({ status: "loading" });
+ try {
+ const params = new URLSearchParams({
+ externalUserId: externalUserId.trim(),
+ days: String(periodDays),
+ });
+ const response = await fetch(`/api/pymthouse/account-usage?${params}`);
+ const body = (await response.json()) as AccountUsagePayload & {
+ error?: string;
+ };
+ if (!response.ok) {
+ throw new Error(body.error ?? `Usage fetch failed (${response.status})`);
+ }
+ setState({ status: "ready", data: body });
+ } catch (error) {
+ setState({
+ status: "error",
+ message: error instanceof Error ? error.message : "Failed to load usage",
+ });
+ }
+ }, [externalUserId, periodDays]);
+
+ useEffect(() => {
+ void load();
+ }, [load]);
+
+ return { ...state, reload: load };
+}
diff --git a/lib/dashboard/useApiKeys.ts b/lib/dashboard/useApiKeys.ts
new file mode 100644
index 0000000..115be2e
--- /dev/null
+++ b/lib/dashboard/useApiKeys.ts
@@ -0,0 +1,94 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import type { DashboardApiKeyRow } from "@/lib/dashboard/pymthouse-keys-bff";
+
+export type ApiKeysState =
+ | { status: "idle" }
+ | { status: "loading" }
+ | { status: "ready"; keys: DashboardApiKeyRow[] }
+ | { status: "error"; message: string };
+
+export function useApiKeys(externalUserId: string | undefined) {
+ const [state, setState] = useState({ status: "idle" });
+
+ const load = useCallback(async () => {
+ if (!externalUserId?.trim()) {
+ setState({
+ status: "error",
+ message: "Sign in to manage API keys for your account.",
+ });
+ return;
+ }
+
+ setState({ status: "loading" });
+ try {
+ const params = new URLSearchParams({ externalUserId: externalUserId.trim() });
+ const response = await fetch(`/api/pymthouse/keys?${params}`);
+ const body = (await response.json()) as { keys?: DashboardApiKeyRow[]; error?: string };
+ if (!response.ok) {
+ throw new Error(body.error ?? `API keys fetch failed (${response.status})`);
+ }
+ setState({ status: "ready", keys: body.keys ?? [] });
+ } catch (error) {
+ setState({
+ status: "error",
+ message: error instanceof Error ? error.message : "Failed to load API keys",
+ });
+ }
+ }, [externalUserId]);
+
+ useEffect(() => {
+ void load();
+ }, [load]);
+
+ const createKey = useCallback(
+ async (label?: string) => {
+ if (!externalUserId?.trim()) {
+ throw new Error("Sign in required");
+ }
+ const response = await fetch("/api/pymthouse/keys", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ externalUserId: externalUserId.trim(),
+ label,
+ }),
+ });
+ const body = (await response.json()) as {
+ apiKey?: string;
+ row?: DashboardApiKeyRow;
+ error?: string;
+ };
+ if (!response.ok || !body.apiKey) {
+ throw new Error(body.error ?? `Create failed (${response.status})`);
+ }
+ await load();
+ return body.apiKey;
+ },
+ [externalUserId, load],
+ );
+
+ const revokeKey = useCallback(
+ async (keyId: string) => {
+ if (!externalUserId?.trim()) {
+ throw new Error("Sign in required");
+ }
+ const params = new URLSearchParams({
+ externalUserId: externalUserId.trim(),
+ keyId,
+ });
+ const response = await fetch(`/api/pymthouse/keys?${params}`, {
+ method: "DELETE",
+ });
+ const body = (await response.json()) as { error?: string };
+ if (!response.ok) {
+ throw new Error(body.error ?? `Revoke failed (${response.status})`);
+ }
+ await load();
+ },
+ [externalUserId, load],
+ );
+
+ return { state, reload: load, createKey, revokeKey };
+}
diff --git a/lib/dashboard/useDiscoveryModel.ts b/lib/dashboard/useDiscoveryModel.ts
new file mode 100644
index 0000000..03dcfa4
--- /dev/null
+++ b/lib/dashboard/useDiscoveryModel.ts
@@ -0,0 +1,63 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import type { Model } from "@/lib/dashboard/types";
+import { DEFAULT_DISCOVERY_SERVICE_TYPE } from "@/lib/discovery/constants";
+
+type ModelState =
+ | { status: "loading" }
+ | { status: "ready"; model: Model }
+ | { status: "not_found" }
+ | { status: "error"; message: string };
+
+export function useDiscoveryModel(capabilityId: string | undefined): ModelState {
+ const [state, setState] = useState({ status: "loading" });
+
+ useEffect(() => {
+ if (!capabilityId) {
+ setState({ status: "not_found" });
+ return;
+ }
+
+ let cancelled = false;
+ setState({ status: "loading" });
+
+ const params = new URLSearchParams({ serviceType: DEFAULT_DISCOVERY_SERVICE_TYPE });
+ const path = `/api/discovery/models/${encodeURIComponent(capabilityId)}?${params}`;
+
+ void (async () => {
+ try {
+ const response = await fetch(path);
+ const body = (await response.json()) as { model?: Model; error?: string };
+
+ if (cancelled) return;
+
+ if (response.status === 404) {
+ setState({ status: "not_found" });
+ return;
+ }
+ if (!response.ok || !body.model) {
+ setState({
+ status: "error",
+ message: body.error ?? `Failed to load capability (${response.status})`,
+ });
+ return;
+ }
+
+ setState({ status: "ready", model: body.model });
+ } catch (error) {
+ if (cancelled) return;
+ setState({
+ status: "error",
+ message: error instanceof Error ? error.message : "Failed to load capability",
+ });
+ }
+ })();
+
+ return () => {
+ cancelled = true;
+ };
+ }, [capabilityId]);
+
+ return state;
+}
diff --git a/lib/dashboard/useExploreModels.ts b/lib/dashboard/useExploreModels.ts
new file mode 100644
index 0000000..eb52ab7
--- /dev/null
+++ b/lib/dashboard/useExploreModels.ts
@@ -0,0 +1,86 @@
+"use client";
+
+import { useCallback, useEffect, useState } from "react";
+import type { ExploreApiResponse } from "@/lib/discovery/types";
+import type { Model } from "@/lib/dashboard/types";
+import {
+ DEFAULT_DISCOVERY_SERVICE_TYPE,
+ type DiscoveryServiceType,
+} from "@/lib/discovery/constants";
+
+export type { DiscoveryServiceType } from "@/lib/discovery/constants";
+
+type ExploreState =
+ | { status: "loading"; models: Model[] }
+ | { status: "ready"; models: Model[]; capabilityCount: number; serviceType: string }
+ | { status: "error"; models: Model[]; error: string };
+
+let exploreCache: {
+ key: string;
+ payload: ExploreApiResponse;
+ fetchedAt: number;
+} | null = null;
+
+const CACHE_TTL_MS = 60_000;
+
+export function useExploreModels(
+ serviceType: DiscoveryServiceType = DEFAULT_DISCOVERY_SERVICE_TYPE,
+): ExploreState & { reload: () => void } {
+ const [state, setState] = useState({ status: "loading", models: [] });
+ const cacheKey = serviceType;
+
+ const load = useCallback(async () => {
+ const cached =
+ exploreCache &&
+ exploreCache.key === cacheKey &&
+ Date.now() - exploreCache.fetchedAt < CACHE_TTL_MS
+ ? exploreCache.payload
+ : null;
+
+ if (cached) {
+ setState({
+ status: "ready",
+ models: cached.models,
+ capabilityCount: cached.capabilityCount,
+ serviceType: cached.serviceType,
+ });
+ return;
+ }
+
+ setState((prev) => ({ ...prev, status: "loading" }));
+
+ try {
+ const params = new URLSearchParams({ serviceType });
+ const response = await fetch(`/api/discovery/explore?${params}`);
+ const body = (await response.json()) as ExploreApiResponse & { error?: string };
+
+ if (!response.ok) {
+ throw new Error(body.error ?? `Explore fetch failed (${response.status})`);
+ }
+
+ exploreCache = { key: cacheKey, payload: body, fetchedAt: Date.now() };
+ setState({
+ status: "ready",
+ models: body.models,
+ capabilityCount: body.capabilityCount,
+ serviceType: body.serviceType,
+ });
+ } catch (error) {
+ const message = error instanceof Error ? error.message : "Failed to load capabilities";
+ setState({ status: "error", models: [], error: message });
+ }
+ }, [cacheKey, serviceType]);
+
+ useEffect(() => {
+ void load();
+ }, [load]);
+
+ const reload = useCallback(() => {
+ if (exploreCache?.key === cacheKey) {
+ exploreCache = null;
+ }
+ void load();
+ }, [cacheKey, load]);
+
+ return { ...state, reload };
+}
diff --git a/lib/dashboard/useSigningSession.ts b/lib/dashboard/useSigningSession.ts
new file mode 100644
index 0000000..7888bc7
--- /dev/null
+++ b/lib/dashboard/useSigningSession.ts
@@ -0,0 +1,167 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import { fetchSigningToken } from "@/lib/dashboard/fetch-signing-token";
+import {
+ clearStoredSigningToken,
+ getStoredSigningToken,
+ setStoredSigningToken,
+} from "@/lib/dashboard/signing-token-storage";
+
+export type SigningSessionState =
+ | { status: "idle" }
+ | { status: "loading" }
+ | {
+ status: "ready";
+ accessToken: string;
+ expiresAtMs: number;
+ scope: string;
+ }
+ | { status: "missing_user" }
+ | { status: "error"; message: string };
+
+const REFRESH_RATIO = 0.8;
+const MIN_REFRESH_LEAD_MS = 30_000;
+
+function refreshDelayMs(expiresInSec: number): number {
+ const ttlMs = expiresInSec * 1000;
+ return Math.max(MIN_REFRESH_LEAD_MS, Math.floor(ttlMs * REFRESH_RATIO));
+}
+
+export function useSigningSession(enabled: boolean, externalUserId: string | undefined) {
+ const [state, setState] = useState({ status: "idle" });
+ const refreshTimerRef = useRef | null>(null);
+ const userIdRef = useRef(null);
+
+ const clearRefreshTimer = useCallback(() => {
+ if (refreshTimerRef.current) {
+ clearTimeout(refreshTimerRef.current);
+ refreshTimerRef.current = null;
+ }
+ }, []);
+
+ const mintToken = useCallback(
+ async (userId: string, options?: { skipCache?: boolean }): Promise => {
+ const trimmed = userId.trim();
+ userIdRef.current = trimmed;
+
+ if (!options?.skipCache) {
+ const cached = getStoredSigningToken(trimmed);
+ if (cached) {
+ setState({
+ status: "ready",
+ accessToken: cached.accessToken,
+ expiresAtMs: cached.expiresAtMs,
+ scope: cached.scope,
+ });
+ const remainingMs = cached.expiresAtMs - Date.now();
+ clearRefreshTimer();
+ refreshTimerRef.current = setTimeout(() => {
+ if (userIdRef.current === trimmed) {
+ void mintToken(trimmed, { skipCache: true });
+ }
+ }, Math.max(MIN_REFRESH_LEAD_MS, Math.floor(remainingMs * REFRESH_RATIO)));
+ return cached.accessToken;
+ }
+ }
+
+ setState({ status: "loading" });
+ try {
+ const minted = await fetchSigningToken(trimmed);
+ const expiresAtMs = Date.now() + minted.expires_in * 1000;
+ setStoredSigningToken({
+ externalUserId: trimmed,
+ accessToken: minted.access_token,
+ expiresAtMs,
+ scope: minted.scope,
+ });
+ setState({
+ status: "ready",
+ accessToken: minted.access_token,
+ expiresAtMs,
+ scope: minted.scope,
+ });
+
+ clearRefreshTimer();
+ refreshTimerRef.current = setTimeout(() => {
+ if (userIdRef.current === trimmed) {
+ void mintToken(trimmed, { skipCache: true });
+ }
+ }, refreshDelayMs(minted.expires_in));
+
+ return minted.access_token;
+ } catch (error) {
+ const message =
+ error instanceof Error ? error.message : "Signing token mint failed";
+ setState({ status: "error", message });
+ throw error instanceof Error ? error : new Error(message);
+ }
+ },
+ [clearRefreshTimer],
+ );
+
+ const bootstrap = useCallback(async () => {
+ const userId = externalUserId?.trim();
+ if (!userId) {
+ setState({ status: "missing_user" });
+ return;
+ }
+ await mintToken(userId);
+ }, [externalUserId, mintToken]);
+
+ const ensureAccessToken = useCallback(async () => {
+ if (
+ state.status === "ready" &&
+ state.expiresAtMs > Date.now() + 5_000 &&
+ userIdRef.current === externalUserId?.trim()
+ ) {
+ return state.accessToken;
+ }
+ const userId = externalUserId?.trim();
+ if (!userId) {
+ throw new Error("Sign in to stream");
+ }
+ return mintToken(userId);
+ }, [externalUserId, mintToken, state]);
+
+ const clearSession = useCallback(() => {
+ userIdRef.current = null;
+ clearStoredSigningToken();
+ clearRefreshTimer();
+ setState({ status: "missing_user" });
+ }, [clearRefreshTimer]);
+
+ useEffect(() => {
+ if (!enabled) {
+ clearRefreshTimer();
+ setState({ status: "idle" });
+ return;
+ }
+ void bootstrap();
+ return () => {
+ clearRefreshTimer();
+ };
+ }, [enabled, bootstrap, clearRefreshTimer]);
+
+ useEffect(() => {
+ if (!enabled) {
+ return;
+ }
+ const userId = externalUserId?.trim();
+ if (!userId) {
+ setState({ status: "missing_user" });
+ return;
+ }
+ if (userIdRef.current && userIdRef.current !== userId) {
+ clearStoredSigningToken();
+ void mintToken(userId);
+ }
+ }, [enabled, externalUserId, mintToken]);
+
+ return {
+ state,
+ refresh: bootstrap,
+ ensureAccessToken,
+ clearSession,
+ };
+}
diff --git a/lib/discovery/client.ts b/lib/discovery/client.ts
new file mode 100644
index 0000000..96871a6
--- /dev/null
+++ b/lib/discovery/client.ts
@@ -0,0 +1,102 @@
+import { readDiscoveryServiceUrl } from "./config";
+import { DEFAULT_DISCOVERY_SERVICE_TYPE, type DiscoveryServiceType } from "./constants";
+import { mapCapabilityToModel } from "./map-to-model";
+import type {
+ DiscoveryCapabilitiesResponse,
+ DiscoveryFreshnessResponse,
+ DiscoveryQueryResponse,
+ ExploreApiResponse,
+} from "./types";
+
+export { DEFAULT_DISCOVERY_SERVICE_TYPE, type DiscoveryServiceType } from "./constants";
+
+async function discoveryFetch(path: string, init?: RequestInit): Promise {
+ const baseUrl = readDiscoveryServiceUrl();
+ const response = await fetch(`${baseUrl}${path}`, {
+ ...init,
+ headers: {
+ Accept: "application/json",
+ ...(init?.headers ?? {}),
+ },
+ next: { revalidate: 60 },
+ });
+
+ if (!response.ok) {
+ const body = await response.text();
+ throw new Error(`Discovery Service ${response.status}: ${body || response.statusText}`);
+ }
+
+ return response.json() as Promise;
+}
+
+export async function fetchDiscoveryCapabilities(
+ serviceType: DiscoveryServiceType = DEFAULT_DISCOVERY_SERVICE_TYPE,
+): Promise {
+ const params = new URLSearchParams({ serviceType });
+ return discoveryFetch(
+ `/v1/discovery/capabilities?${params}`,
+ );
+}
+
+export async function fetchDiscoveryFreshness(): Promise {
+ return discoveryFetch("/v1/discovery/freshness");
+}
+
+export async function queryDiscoveryCapabilities(
+ capabilities: string[],
+ serviceType: DiscoveryServiceType = DEFAULT_DISCOVERY_SERVICE_TYPE,
+): Promise {
+ if (capabilities.length === 0) {
+ return { results: {} };
+ }
+
+ return discoveryFetch("/v1/discovery/query", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ capabilities,
+ serviceTypes: [serviceType],
+ topN: 50,
+ sortBy: "avail",
+ }),
+ });
+}
+
+export async function fetchExploreModels(
+ serviceType: DiscoveryServiceType = DEFAULT_DISCOVERY_SERVICE_TYPE,
+): Promise {
+ const [capabilitiesResponse, freshness] = await Promise.all([
+ fetchDiscoveryCapabilities(serviceType),
+ fetchDiscoveryFreshness().catch(() => undefined),
+ ]);
+
+ const entries = capabilitiesResponse.entries ?? [];
+ const capabilityNames =
+ capabilitiesResponse.capabilities.length > 0
+ ? capabilitiesResponse.capabilities
+ : entries.map((entry) => entry.capability);
+
+ const entryByCapability = new Map(entries.map((entry) => [entry.capability, entry]));
+
+ const queryResponse = await queryDiscoveryCapabilities(capabilityNames, serviceType);
+
+ const models = capabilityNames.map((capability) =>
+ mapCapabilityToModel(
+ capability,
+ entryByCapability.get(capability),
+ queryResponse.results[capability] ?? [],
+ ),
+ );
+
+ models.sort((a, b) => {
+ if (a.status !== b.status) return a.status === "hot" ? -1 : 1;
+ return b.orchestrators - a.orchestrators;
+ });
+
+ return {
+ models,
+ capabilityCount: capabilityNames.length,
+ serviceType,
+ freshness,
+ };
+}
diff --git a/lib/discovery/config.ts b/lib/discovery/config.ts
new file mode 100644
index 0000000..d3911d5
--- /dev/null
+++ b/lib/discovery/config.ts
@@ -0,0 +1,7 @@
+export function readDiscoveryServiceUrl(): string {
+ const url = process.env.DISCOVERY_SERVICE_URL?.trim();
+ if (!url) {
+ throw new Error("DISCOVERY_SERVICE_URL is not configured");
+ }
+ return url.replace(/\/$/, "");
+}
diff --git a/lib/discovery/constants.ts b/lib/discovery/constants.ts
new file mode 100644
index 0000000..b0abb0e
--- /dev/null
+++ b/lib/discovery/constants.ts
@@ -0,0 +1,3 @@
+export const DEFAULT_DISCOVERY_SERVICE_TYPE = "legacy" as const;
+
+export type DiscoveryServiceType = "legacy" | "registry";
diff --git a/lib/discovery/map-to-model.ts b/lib/discovery/map-to-model.ts
new file mode 100644
index 0000000..a04ad50
--- /dev/null
+++ b/lib/discovery/map-to-model.ts
@@ -0,0 +1,119 @@
+import type { Model, ModelCategory, ModelStatus, PricingUnit } from "@/lib/dashboard/types";
+import { enrichDiscoveryModelForStreaming } from "@/lib/dashboard/streaming-playground";
+import type { DiscoveryCapabilityEntry, DiscoveryDatasetRow } from "./types";
+
+function inferCategory(capability: string): ModelCategory {
+ const c = capability.toLowerCase();
+
+ if (c.startsWith("video:transcode") || c === "video:live.rtmp") {
+ return "Live Transcoding";
+ }
+ if (
+ c.includes("streamdiffusion") ||
+ c.includes("stable-video") ||
+ c.includes("img2vid") ||
+ c.startsWith("video:")
+ ) {
+ return "Video Generation";
+ }
+ if (
+ c.includes("whisper") ||
+ c.startsWith("openai:audio") ||
+ c.includes("tts") ||
+ c.includes("parler")
+ ) {
+ return "Speech";
+ }
+ if (
+ c.startsWith("openai:images") ||
+ c.includes("flux") ||
+ c.includes("sdxl") ||
+ c.includes("diffusion") ||
+ c.includes("pix2pix") ||
+ c.includes("upscaler") ||
+ c.includes("realvis") ||
+ c.includes("instruct-pix")
+ ) {
+ return "Image Generation";
+ }
+ if (c.includes("sam2") || c.includes("vision")) {
+ return "Video Understanding";
+ }
+ return "Language";
+}
+
+function inferPricingUnit(workUnit: string | undefined, capability: string): PricingUnit {
+ if (workUnit === "tokens") return "M Tokens";
+ if (workUnit?.includes("second")) return "Second";
+ if (capability.startsWith("video:")) return "Minute";
+ return "Request";
+}
+
+function humanizeCapabilityName(capability: string): string {
+ const segment = capability.includes(":")
+ ? capability.split(":").slice(-1)[0]!
+ : capability;
+ return segment
+ .split(/[-_./]+/)
+ .filter(Boolean)
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
+ .join(" ");
+}
+
+function aggregateRows(rows: DiscoveryDatasetRow[]): {
+ orchestrators: number;
+ status: ModelStatus;
+ latency: number;
+ price: number;
+ realtime: boolean;
+} {
+ const orchUris = new Set(rows.map((row) => row.orchUri).filter(Boolean));
+ const warm = rows.some((row) => row.avail > 0 || row.totalCap > 0);
+ const latencies = rows
+ .map((row) => row.avgLatMs ?? row.bestLatMs)
+ .filter((value): value is number => value != null && value > 0);
+ const prices = rows.map((row) => row.pricePerUnit).filter((value) => value > 0);
+
+ return {
+ orchestrators: orchUris.size,
+ status: warm ? "hot" : "cold",
+ latency:
+ latencies.length > 0
+ ? latencies.reduce((sum, value) => sum + value, 0) / latencies.length
+ : 0,
+ price: prices.length > 0 ? Math.min(...prices) : 0,
+ realtime: rows.some((row) => row.interactionMode?.includes("stream") ?? false),
+ };
+}
+
+export function mapCapabilityToModel(
+ capability: string,
+ entry: DiscoveryCapabilityEntry | undefined,
+ rows: DiscoveryDatasetRow[],
+): Model {
+ const stats = aggregateRows(rows);
+ const sample = rows[0];
+ const provider =
+ entry?.offeringIds?.[0] ??
+ (entry?.serviceType === "registry" ? "Registry" : "Livepeer network");
+
+ return enrichDiscoveryModelForStreaming({
+ id: capability,
+ name: humanizeCapabilityName(capability),
+ provider,
+ category: inferCategory(capability),
+ description: `${humanizeCapabilityName(capability)} on the Livepeer open GPU network (${stats.orchestrators} orchestrator${stats.orchestrators === 1 ? "" : "s"}).`,
+ status: stats.status,
+ pricing: {
+ amount: stats.price > 0 ? stats.price : 0.001,
+ unit: inferPricingUnit(sample?.workUnit, capability),
+ },
+ latency: stats.latency,
+ orchestrators: stats.orchestrators,
+ runs7d: Math.max(stats.orchestrators * 8, stats.orchestrators > 0 ? 1 : 0),
+ uptime: stats.status === "hot" ? 99.2 : 0,
+ realtime: stats.realtime,
+ featured: stats.realtime && stats.status === "hot",
+ tags: entry?.serviceType ? [entry.serviceType] : undefined,
+ });
+}
diff --git a/lib/discovery/types.ts b/lib/discovery/types.ts
new file mode 100644
index 0000000..75ea409
--- /dev/null
+++ b/lib/discovery/types.ts
@@ -0,0 +1,54 @@
+/** Discovery Service API shapes (see discovery-service openapi). */
+
+export interface DiscoveryCapabilityEntry {
+ serviceType: string;
+ capability: string;
+ offeringIds?: string[];
+}
+
+export interface DiscoveryCapabilitiesResponse {
+ capabilities: string[];
+ entries?: DiscoveryCapabilityEntry[];
+}
+
+export interface DiscoveryDatasetRow {
+ serviceType?: string;
+ ethAddress?: string;
+ offeringId?: string;
+ interactionMode?: string;
+ workUnit?: string;
+ pricePerUnitWei?: string;
+ orchUri: string;
+ gpuName?: string;
+ gpuGb?: number;
+ avail: number;
+ totalCap: number;
+ pricePerUnit: number;
+ bestLatMs?: number | null;
+ avgLatMs?: number | null;
+ swapRatio?: number | null;
+ avgAvail?: number | null;
+ score?: number;
+ slaScore?: number | null;
+}
+
+export interface DiscoveryQueryResponse {
+ results: Record;
+ datasetVersion?: number;
+ queryTimeMs?: number;
+}
+
+export interface DiscoveryFreshnessResponse {
+ populated?: boolean;
+ refreshedAt?: number;
+ ageMs?: number;
+ capabilityCount?: number;
+ totalRows?: number;
+}
+
+export interface ExploreApiResponse {
+ models: import("@/lib/dashboard/types").Model[];
+ capabilityCount: number;
+ serviceType: string;
+ freshness?: DiscoveryFreshnessResponse;
+}
diff --git a/next.config.ts b/next.config.ts
index 16313bf..721cca7 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -18,6 +18,26 @@ const nextConfig: NextConfig = {
};
return config;
},
+ /**
+ * @pymthouse/builder-sdk gateway calls `/sign-orchestrator-info` at the origin root
+ * (httpOrigin strips `/api/signer`). Map those paths to the dashboard signer proxy.
+ */
+ async rewrites() {
+ return [
+ {
+ source: "/sign-orchestrator-info",
+ destination: "/api/signer/sign-orchestrator-info",
+ },
+ {
+ source: "/discover-orchestrators",
+ destination: "/api/signer/discover-orchestrators",
+ },
+ {
+ source: "/generate-live-payment",
+ destination: "/api/signer/generate-live-payment",
+ },
+ ];
+ },
async redirects() {
return [
// Old livepeer.org routes → new site equivalents
diff --git a/package.json b/package.json
index b56e308..712f330 100644
--- a/package.json
+++ b/package.json
@@ -13,13 +13,17 @@
"format:check": "prettier . --check"
},
"dependencies": {
+ "@pymthouse/builder-sdk": "link:../builder-sdk",
"framer-motion": "^11.15.0",
"geist": "^1.7.0",
+ "jmuxer": "^2.1.0",
"lucide-react": "^1.6.0",
+ "mux.js": "^6.3.0",
"next": "^15.1.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"recharts": "^3.8.1",
+ "server-only": "^0.0.1",
"wavesurfer.js": "^7.12.6"
},
"devDependencies": {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index de56b7d..b1d3a1a 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -8,15 +8,24 @@ importers:
.:
dependencies:
+ '@pymthouse/builder-sdk':
+ specifier: link:../builder-sdk
+ version: link:../builder-sdk
framer-motion:
specifier: ^11.15.0
version: 11.18.2(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
geist:
specifier: ^1.7.0
version: 1.7.0(next@15.5.14(react-dom@19.2.4(react@19.2.4))(react@19.2.4))
+ jmuxer:
+ specifier: ^2.1.0
+ version: 2.1.0
lucide-react:
specifier: ^1.6.0
version: 1.7.0(react@19.2.4)
+ mux.js:
+ specifier: ^6.3.0
+ version: 6.3.0
next:
specifier: ^15.1.0
version: 15.5.14(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
@@ -29,6 +38,9 @@ importers:
recharts:
specifier: ^3.8.1
version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react-is@16.13.1)(react@19.2.4)(redux@5.0.1)
+ server-only:
+ specifier: ^0.0.1
+ version: 0.0.1
wavesurfer.js:
specifier: ^7.12.6
version: 7.12.7
@@ -76,6 +88,10 @@ packages:
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
engines: {node: '>=10'}
+ '@babel/runtime@7.29.7':
+ resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==}
+ engines: {node: '>=6.9.0'}
+
'@emnapi/core@1.9.2':
resolution: {integrity: sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA==}
@@ -956,6 +972,9 @@ packages:
resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==}
engines: {node: '>=0.10.0'}
+ dom-walk@0.1.2:
+ resolution: {integrity: sha512-6QvTW9mrGeIegrFXdtQi9pk7O/nSK6lSdXW2eqUspN5LWD7UTji2Fqw5V2YLjBpHEoU9Xl/eUWNpDeZvoyOv2w==}
+
dunder-proto@1.0.1:
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
engines: {node: '>= 0.4'}
@@ -1239,6 +1258,9 @@ packages:
resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==}
engines: {node: '>=10.13.0'}
+ global@4.4.0:
+ resolution: {integrity: sha512-wv/LAoHdRE3BeTGz53FAamhGlPLhlssK45usmGFThIi4XqnBmjKQ16u+RNbP7WvigRZDxUsM0J3gcQ5yicaL0w==}
+
globals@14.0.0:
resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==}
engines: {node: '>=18'}
@@ -1428,6 +1450,9 @@ packages:
resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==}
hasBin: true
+ jmuxer@2.1.0:
+ resolution: {integrity: sha512-iizwBTIV11RFKrOp0s/SKrb00yz2epwSOdWxdphSfV7gWlAi9ZXpDdNk/m67Dp0M3+4uGL0AcBQmhB2THxABpQ==}
+
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
@@ -1571,6 +1596,9 @@ packages:
resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==}
engines: {node: '>=8.6'}
+ min-document@2.19.2:
+ resolution: {integrity: sha512-8S5I8db/uZN8r9HSLFVWPdJCvYOejMcEC82VIzNUc6Zkklf/d1gg2psfE79/vyhWOj4+J8MtwmoOz3TmvaGu5A==}
+
minimatch@10.2.5:
resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==}
engines: {node: 18 || 20 || >=22}
@@ -1590,6 +1618,11 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+ mux.js@6.3.0:
+ resolution: {integrity: sha512-/QTkbSAP2+w1nxV+qTcumSDN5PA98P0tjrADijIzQHe85oBK3Akhy9AHlH0ne/GombLMz1rLyvVsmrgRxoPDrQ==}
+ engines: {node: '>=8', npm: '>=5'}
+ hasBin: true
+
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
@@ -1723,6 +1756,10 @@ packages:
engines: {node: '>=14'}
hasBin: true
+ process@0.11.10:
+ resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==}
+ engines: {node: '>= 0.6.0'}
+
prop-types@15.8.1:
resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==}
@@ -1827,6 +1864,9 @@ packages:
engines: {node: '>=10'}
hasBin: true
+ server-only@0.0.1:
+ resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==}
+
set-function-length@1.2.2:
resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==}
engines: {node: '>= 0.4'}
@@ -2042,6 +2082,8 @@ snapshots:
'@alloc/quick-lru@5.2.0': {}
+ '@babel/runtime@7.29.7': {}
+
'@emnapi/core@1.9.2':
dependencies:
'@emnapi/wasi-threads': 1.2.1
@@ -2825,6 +2867,8 @@ snapshots:
dependencies:
esutils: 2.0.3
+ dom-walk@0.1.2: {}
+
dunder-proto@1.0.1:
dependencies:
call-bind-apply-helpers: 1.0.2
@@ -3258,6 +3302,11 @@ snapshots:
dependencies:
is-glob: 4.0.3
+ global@4.4.0:
+ dependencies:
+ min-document: 2.19.2
+ process: 0.11.10
+
globals@14.0.0: {}
globalthis@1.0.4:
@@ -3441,6 +3490,8 @@ snapshots:
jiti@2.6.1: {}
+ jmuxer@2.1.0: {}
+
js-tokens@4.0.0: {}
js-yaml@4.1.1:
@@ -3555,6 +3606,10 @@ snapshots:
braces: 3.0.3
picomatch: 2.3.2
+ min-document@2.19.2:
+ dependencies:
+ dom-walk: 0.1.2
+
minimatch@10.2.5:
dependencies:
brace-expansion: 5.0.5
@@ -3573,6 +3628,11 @@ snapshots:
ms@2.1.3: {}
+ mux.js@6.3.0:
+ dependencies:
+ '@babel/runtime': 7.29.7
+ global: 4.4.0
+
nanoid@3.3.11: {}
napi-postinstall@0.3.4: {}
@@ -3708,6 +3768,8 @@ snapshots:
prettier@3.8.1: {}
+ process@0.11.10: {}
+
prop-types@15.8.1:
dependencies:
loose-envify: 1.4.0
@@ -3828,6 +3890,8 @@ snapshots:
semver@7.7.4: {}
+ server-only@0.0.1: {}
+
set-function-length@1.2.2:
dependencies:
define-data-property: 1.1.4
diff --git a/types/jmuxer.d.ts b/types/jmuxer.d.ts
new file mode 100644
index 0000000..c2e50fc
--- /dev/null
+++ b/types/jmuxer.d.ts
@@ -0,0 +1,8 @@
+declare module "jmuxer" {
+ export default class JMuxer {
+ constructor(options: Record);
+ feed(payload: { video?: Uint8Array; audio?: Uint8Array; duration?: number }): void;
+ destroy(): void;
+ reset(): void;
+ }
+}
diff --git a/types/muxjs.d.ts b/types/muxjs.d.ts
new file mode 100644
index 0000000..ab82480
--- /dev/null
+++ b/types/muxjs.d.ts
@@ -0,0 +1,18 @@
+declare module "mux.js" {
+ type MuxSegment = {
+ initSegment?: Uint8Array;
+ data: Uint8Array;
+ };
+
+ const muxjs: {
+ mp4: {
+ Transmuxer: new () => {
+ on: (event: "data", handler: (segment: MuxSegment) => void) => void;
+ push: (chunk: Uint8Array) => void;
+ flush: () => void;
+ };
+ };
+ };
+
+ export default muxjs;
+}