-
- 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/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/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/pymthouse-bff.ts b/lib/dashboard/pymthouse-bff.ts
new file mode 100644
index 0000000..40a44c7
--- /dev/null
+++ b/lib/dashboard/pymthouse-bff.ts
@@ -0,0 +1,194 @@
+import { PmtHouseError, type PmtHouseClient } 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(
+ client: PmtHouseClient,
+ externalUserId: string,
+): Promise {
+ try {
+ const balance = await client.getUsageBalance(externalUserId);
+ return {
+ externalUserId: balance.externalUserId ?? externalUserId,
+ balanceUsdMicros: balance.balanceUsdMicros ?? "0",
+ consumedUsdMicros: balance.consumedUsdMicros ?? "0",
+ lifetimeGrantedUsdMicros: balance.lifetimeGrantedUsdMicros ?? "0",
+ hasAccess: Boolean(balance.hasAccess),
+ };
+ } catch {
+ // Balance is best-effort — usage still renders without the allowance strip.
+ return null;
+ }
+}
+
+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(client, input.externalUserId),
+ client.fetchUsageForExternalUser({
+ externalUserId: input.externalUserId,
+ startDate: period.startDate,
+ endDate: period.endDate,
+ includeRetail: true,
+ }),
+ client.fetchUsageForExternalUser({
+ externalUserId: input.externalUserId,
+ startDate: period.priorStartDate,
+ endDate: period.priorEndDate,
+ includeRetail: true,
+ }),
+ ]);
+
+ 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/signer-proxy.server.ts b/lib/dashboard/signer-proxy.server.ts
new file mode 100644
index 0000000..26b401e
--- /dev/null
+++ b/lib/dashboard/signer-proxy.server.ts
@@ -0,0 +1,93 @@
+import "server-only";
+
+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 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() ||
+ (issuerUrl ? `${issuerOriginFromIssuerUrl(issuerUrl)}/api/signer` : "");
+ return signerUrl || null;
+}
+
+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..e8081c6
--- /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")
+ );
+}
+
+/**
+ * Resolve the orchestrator pipeline model name for a capability.
+ * Discovery page id may differ from the 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..075a33f
--- /dev/null
+++ b/lib/dashboard/useAccountUsage.ts
@@ -0,0 +1,50 @@
+"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}`, {
+ cache: "no-store",
+ });
+ 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/package.json b/package.json
index b56e308..d8b4096 100644
--- a/package.json
+++ b/package.json
@@ -13,13 +13,17 @@
"format:check": "prettier . --check"
},
"dependencies": {
+ "@pymthouse/builder-sdk": "0.4.3",
"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..ad00ab3 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -8,15 +8,24 @@ importers:
.:
dependencies:
+ '@pymthouse/builder-sdk':
+ specifier: 0.4.3
+ version: 0.4.3
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==}
@@ -385,6 +401,10 @@ packages:
resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==}
engines: {node: '>=12.4.0'}
+ '@pymthouse/builder-sdk@0.4.3':
+ resolution: {integrity: sha512-1cwK78PDekyd2BYC59OJeSorT8XT+laNp7dQj+JC9FNcO4TmJs32Yz4EzhWZohUXszIpejas3J+KXWh9HR8hrw==}
+ engines: {node: '>=20'}
+
'@reduxjs/toolkit@2.11.2':
resolution: {integrity: sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==}
peerDependencies:
@@ -956,6 +976,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 +1262,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 +1454,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 +1600,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 +1622,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}
@@ -1628,6 +1665,9 @@ packages:
resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==}
engines: {node: '>= 0.4'}
+ oauth4webapi@3.8.6:
+ resolution: {integrity: sha512-iwemM91xz8nryHti2yTmg5fhyEMVOkOXwHNqbvcATjyajb5oQxCQzrNOA6uElRHuMhQQTKUyFKV9y/CNyg25BQ==}
+
object-assign@4.1.1:
resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
engines: {node: '>=0.10.0'}
@@ -1723,6 +1763,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 +1871,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 +2089,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
@@ -2282,6 +2331,10 @@ snapshots:
'@nolyfill/is-core-module@1.0.39': {}
+ '@pymthouse/builder-sdk@0.4.3':
+ dependencies:
+ oauth4webapi: 3.8.6
+
'@reduxjs/toolkit@2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4)':
dependencies:
'@standard-schema/spec': 1.1.0
@@ -2825,6 +2878,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 +3313,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 +3501,8 @@ snapshots:
jiti@2.6.1: {}
+ jmuxer@2.1.0: {}
+
js-tokens@4.0.0: {}
js-yaml@4.1.1:
@@ -3555,6 +3617,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 +3639,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: {}
@@ -3609,6 +3680,8 @@ snapshots:
object.entries: 1.1.9
semver: 6.3.1
+ oauth4webapi@3.8.6: {}
+
object-assign@4.1.1: {}
object-inspect@1.13.4: {}
@@ -3708,6 +3781,8 @@ snapshots:
prettier@3.8.1: {}
+ process@0.11.10: {}
+
prop-types@15.8.1:
dependencies:
loose-envify: 1.4.0
@@ -3828,6 +3903,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;
+}