Spaces:
Running
Running
AIBRUH Claude Sonnet 4.6 commited on
Commit Β·
7d2802a
1
Parent(s): 40cab4e
feat(clique): CLS loop router + live Runway switcher fully wired
Browse files- CLSTile Layer C: live Runway video element with 200ms crossfade over
the always-running CLS loop; gold pulse ring while LIVE is active
- useCLSRunner: goLive() checks pre-warm cache (instant) then polls
Runway at 1500ms intervals; returnToLoop() clears live state
- AgentTile: imports useCLSRunner per agent; triggers goLive(speak)
on state==live, returnToLoop on exit; pre-warms speak clip 2s
after mount so next switch is instant; passes liveVideoUrl+onLiveEnded
down to CLSTile
- /api/clique/cls-live: Runway gen4_turbo image_to_video endpoint
with speak/nod/react/explain mood prompts
- Roster: TJ, Dev, Zara, Simone, Finn added (21 members total)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- app/api/clique/cls-live/route.ts +105 -0
- components/clique/AgentTile.tsx +27 -2
- components/clique/CLSTile.tsx +53 -1
- lib/clique-roster.ts +2 -2
- lib/use-cls-runner.ts +143 -0
app/api/clique/cls-live/route.ts
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { NextRequest, NextResponse } from "next/server";
|
| 2 |
+
import { CLIQUE_ROSTER } from "@/lib/clique-roster";
|
| 3 |
+
|
| 4 |
+
/**
|
| 5 |
+
* CLS Live Clip Generator
|
| 6 |
+
*
|
| 7 |
+
* Generates a short Runway video of an agent "going live" β speaking and
|
| 8 |
+
* gesturing naturally. Used to switch from the CLS idle loop to an authentic
|
| 9 |
+
* live character moment.
|
| 10 |
+
*
|
| 11 |
+
* POST /api/clique/cls-live
|
| 12 |
+
* Body: { agentId: string, mood?: "speak" | "nod" | "react" | "explain" }
|
| 13 |
+
* Returns: { taskId, status: "PENDING" }
|
| 14 |
+
*
|
| 15 |
+
* GET /api/clique/cls-live?taskId=xxx
|
| 16 |
+
* Returns: { taskId, status, videoUrl? }
|
| 17 |
+
*/
|
| 18 |
+
|
| 19 |
+
const RUNWAY_HOST = "https://api.dev.runwayml.com";
|
| 20 |
+
const RUNWAY_VERSION = "2024-11-06";
|
| 21 |
+
|
| 22 |
+
const LIVE_PROMPTS: Record<string, string> = {
|
| 23 |
+
speak: "person speaking naturally and confidently, slight hand gesture, engaged expression, mouth moving naturally, waist-up portrait, navy blazer, crest on pocket",
|
| 24 |
+
nod: "person nodding slowly while listening, thoughtful expression, slight forward lean, waist-up portrait, navy blazer, crest on pocket",
|
| 25 |
+
react: "person reacting with warm expression, subtle smile forming, eyes bright and engaged, waist-up portrait, navy blazer, crest on pocket",
|
| 26 |
+
explain: "person explaining something with a small deliberate hand gesture, clear direct gaze, professional, waist-up portrait, navy blazer, crest on pocket",
|
| 27 |
+
};
|
| 28 |
+
|
| 29 |
+
const SUFFIX = " Photorealistic. Seamless loopable clip. Cinematic lighting. No cuts.";
|
| 30 |
+
|
| 31 |
+
async function getPortraitBase64(portraitPath: string): Promise<string> {
|
| 32 |
+
// portraitPath is like /characters/AMANDA_SHIELD.png β resolve from public/
|
| 33 |
+
const fs = await import("fs");
|
| 34 |
+
const path = await import("path");
|
| 35 |
+
const filePath = path.join(process.cwd(), "public", portraitPath);
|
| 36 |
+
const buf = fs.readFileSync(filePath);
|
| 37 |
+
const ext = path.extname(filePath).slice(1).toLowerCase();
|
| 38 |
+
const mime = ext === "jpg" || ext === "jpeg" ? "image/jpeg" : "image/png";
|
| 39 |
+
return `data:${mime};base64,${buf.toString("base64")}`;
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
export async function POST(req: NextRequest) {
|
| 43 |
+
try {
|
| 44 |
+
const { agentId, mood = "speak" } = await req.json();
|
| 45 |
+
if (!agentId) return NextResponse.json({ error: "agentId required" }, { status: 400 });
|
| 46 |
+
|
| 47 |
+
const agent = CLIQUE_ROSTER.find(a => a.id === agentId);
|
| 48 |
+
if (!agent) return NextResponse.json({ error: "Agent not found" }, { status: 404 });
|
| 49 |
+
|
| 50 |
+
const promptBase = LIVE_PROMPTS[mood] ?? LIVE_PROMPTS.speak;
|
| 51 |
+
const promptText = promptBase + SUFFIX;
|
| 52 |
+
|
| 53 |
+
const portraitB64 = await getPortraitBase64(agent.portrait);
|
| 54 |
+
|
| 55 |
+
const body = {
|
| 56 |
+
model: "gen4_turbo",
|
| 57 |
+
promptImage: portraitB64,
|
| 58 |
+
promptText,
|
| 59 |
+
duration: 5,
|
| 60 |
+
ratio: "720:1280",
|
| 61 |
+
};
|
| 62 |
+
|
| 63 |
+
const res = await fetch(`${RUNWAY_HOST}/v1/image_to_video`, {
|
| 64 |
+
method: "POST",
|
| 65 |
+
headers: {
|
| 66 |
+
Authorization: `Bearer ${process.env.RUNWAY_API_KEY}`,
|
| 67 |
+
"X-Runway-Version": RUNWAY_VERSION,
|
| 68 |
+
"Content-Type": "application/json",
|
| 69 |
+
},
|
| 70 |
+
body: JSON.stringify(body),
|
| 71 |
+
});
|
| 72 |
+
|
| 73 |
+
if (!res.ok) {
|
| 74 |
+
return NextResponse.json({ error: await res.text() }, { status: res.status });
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
const data = await res.json();
|
| 78 |
+
return NextResponse.json({ taskId: data.id, status: "PENDING", agentId, mood });
|
| 79 |
+
} catch (e) {
|
| 80 |
+
return NextResponse.json({ error: String(e) }, { status: 500 });
|
| 81 |
+
}
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
export async function GET(req: NextRequest) {
|
| 85 |
+
const taskId = req.nextUrl.searchParams.get("taskId");
|
| 86 |
+
if (!taskId) return NextResponse.json({ error: "taskId required" }, { status: 400 });
|
| 87 |
+
|
| 88 |
+
const res = await fetch(`${RUNWAY_HOST}/v1/tasks/${taskId}`, {
|
| 89 |
+
headers: {
|
| 90 |
+
Authorization: `Bearer ${process.env.RUNWAY_API_KEY}`,
|
| 91 |
+
"X-Runway-Version": RUNWAY_VERSION,
|
| 92 |
+
},
|
| 93 |
+
});
|
| 94 |
+
|
| 95 |
+
if (!res.ok) return NextResponse.json({ error: await res.text() }, { status: res.status });
|
| 96 |
+
|
| 97 |
+
const data = await res.json();
|
| 98 |
+
if (data.status === "SUCCEEDED") {
|
| 99 |
+
return NextResponse.json({ taskId, status: "SUCCEEDED", videoUrl: data.output?.[0] ?? null });
|
| 100 |
+
}
|
| 101 |
+
if (data.status === "FAILED") {
|
| 102 |
+
return NextResponse.json({ taskId, status: "FAILED", error: data.failure }, { status: 500 });
|
| 103 |
+
}
|
| 104 |
+
return NextResponse.json({ taskId, status: data.status, videoUrl: null });
|
| 105 |
+
}
|
components/clique/AgentTile.tsx
CHANGED
|
@@ -1,10 +1,11 @@
|
|
| 1 |
"use client";
|
| 2 |
-
import { useMemo } from "react";
|
| 3 |
import { CliqueAgent } from "@/lib/clique-roster";
|
| 4 |
import { QCRProfile, rapportColor, rapportLabel } from "@/lib/qcr";
|
| 5 |
import CLSTile from "./CLSTile";
|
| 6 |
import { CLSVariant } from "@/lib/clique-cls";
|
| 7 |
import { CLSRouterState } from "@/lib/cls-loop-router";
|
|
|
|
| 8 |
|
| 9 |
interface Props {
|
| 10 |
agent: CliqueAgent;
|
|
@@ -39,6 +40,28 @@ export default function AgentTile({ agent, state, size = "md", qcr, onClick, cls
|
|
| 39 |
const moodColor = MOOD_COLOR[qcr?.moodRead ?? "neutral"];
|
| 40 |
const showDesire = desire > 0.75 && !isLive;
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
// Stable variant override per mount β only used when router is bypassed
|
| 43 |
const variantOverride = useMemo(
|
| 44 |
() => clsVariant ?? undefined,
|
|
@@ -101,7 +124,7 @@ export default function AgentTile({ agent, state, size = "md", qcr, onClick, cls
|
|
| 101 |
flexShrink: 0,
|
| 102 |
animation: isLive ? `live-ring-${agent.id} 1.2s ease-out infinite` : "none",
|
| 103 |
}}>
|
| 104 |
-
{/* CLS animated loop
|
| 105 |
<CLSTile
|
| 106 |
agent={agent}
|
| 107 |
clsState={routerState}
|
|
@@ -109,6 +132,8 @@ export default function AgentTile({ agent, state, size = "md", qcr, onClick, cls
|
|
| 109 |
size={dim}
|
| 110 |
borderRadius={borderRadius}
|
| 111 |
isCSA={agent.isCSA}
|
|
|
|
|
|
|
| 112 |
/>
|
| 113 |
|
| 114 |
{/* LIVE gradient overlay */}
|
|
|
|
| 1 |
"use client";
|
| 2 |
+
import { useEffect, useMemo } from "react";
|
| 3 |
import { CliqueAgent } from "@/lib/clique-roster";
|
| 4 |
import { QCRProfile, rapportColor, rapportLabel } from "@/lib/qcr";
|
| 5 |
import CLSTile from "./CLSTile";
|
| 6 |
import { CLSVariant } from "@/lib/clique-cls";
|
| 7 |
import { CLSRouterState } from "@/lib/cls-loop-router";
|
| 8 |
+
import { useCLSRunner } from "@/lib/use-cls-runner";
|
| 9 |
|
| 10 |
interface Props {
|
| 11 |
agent: CliqueAgent;
|
|
|
|
| 40 |
const moodColor = MOOD_COLOR[qcr?.moodRead ?? "neutral"];
|
| 41 |
const showDesire = desire > 0.75 && !isLive;
|
| 42 |
|
| 43 |
+
// CLS β Live Runway switcher for this specific agent
|
| 44 |
+
const { liveVideoUrl, goLive, returnToLoop, prewarm } = useCLSRunner(agent.id);
|
| 45 |
+
|
| 46 |
+
// Trigger goLive when parent sets state to "live", clean up when leaving
|
| 47 |
+
useEffect(() => {
|
| 48 |
+
if (isLive) {
|
| 49 |
+
goLive("speak");
|
| 50 |
+
} else {
|
| 51 |
+
returnToLoop();
|
| 52 |
+
}
|
| 53 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 54 |
+
}, [isLive]);
|
| 55 |
+
|
| 56 |
+
// Pre-warm "speak" clip as soon as agent enters listening state
|
| 57 |
+
useEffect(() => {
|
| 58 |
+
if (!isLive && !isOff) {
|
| 59 |
+
const t = setTimeout(() => prewarm("speak"), 2_000);
|
| 60 |
+
return () => clearTimeout(t);
|
| 61 |
+
}
|
| 62 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 63 |
+
}, [isLive, isOff]);
|
| 64 |
+
|
| 65 |
// Stable variant override per mount β only used when router is bypassed
|
| 66 |
const variantOverride = useMemo(
|
| 67 |
() => clsVariant ?? undefined,
|
|
|
|
| 124 |
flexShrink: 0,
|
| 125 |
animation: isLive ? `live-ring-${agent.id} 1.2s ease-out infinite` : "none",
|
| 126 |
}}>
|
| 127 |
+
{/* CLS animated loop + live Runway layer */}
|
| 128 |
<CLSTile
|
| 129 |
agent={agent}
|
| 130 |
clsState={routerState}
|
|
|
|
| 132 |
size={dim}
|
| 133 |
borderRadius={borderRadius}
|
| 134 |
isCSA={agent.isCSA}
|
| 135 |
+
liveVideoUrl={liveVideoUrl}
|
| 136 |
+
onLiveEnded={returnToLoop}
|
| 137 |
/>
|
| 138 |
|
| 139 |
{/* LIVE gradient overlay */}
|
components/clique/CLSTile.tsx
CHANGED
|
@@ -27,11 +27,19 @@ interface Props {
|
|
| 27 |
isCSA?: boolean;
|
| 28 |
onReady?: () => void;
|
| 29 |
onStateComplete?: (state: CLSRouterState) => void;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
}
|
| 31 |
|
| 32 |
export default function CLSTile({
|
| 33 |
agent, clsState = "listen", variant: variantOverride,
|
| 34 |
size, borderRadius = 8, isCSA, onReady, onStateComplete,
|
|
|
|
| 35 |
}: Props) {
|
| 36 |
const { currentVariant, nextVariant, transitioning } = useCLSLoopRouter(
|
| 37 |
agent.id,
|
|
@@ -61,7 +69,9 @@ export default function CLSTile({
|
|
| 61 |
bRef.current?.load();
|
| 62 |
}, [bVariant]);
|
| 63 |
|
| 64 |
-
const FADE
|
|
|
|
|
|
|
| 65 |
|
| 66 |
return (
|
| 67 |
<div style={{
|
|
@@ -168,6 +178,48 @@ export default function CLSTile({
|
|
| 168 |
)}
|
| 169 |
</>
|
| 170 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
</div>
|
| 172 |
);
|
| 173 |
}
|
|
|
|
| 27 |
isCSA?: boolean;
|
| 28 |
onReady?: () => void;
|
| 29 |
onStateComplete?: (state: CLSRouterState) => void;
|
| 30 |
+
/**
|
| 31 |
+
* Live Runway video URL β when set, instantly crossfades from CLS loop
|
| 32 |
+
* to the live clip. Clear to null to return to CLS loop.
|
| 33 |
+
*/
|
| 34 |
+
liveVideoUrl?: string | null;
|
| 35 |
+
/** Called when the live Runway clip finishes playing β parent should returnToLoop() */
|
| 36 |
+
onLiveEnded?: () => void;
|
| 37 |
}
|
| 38 |
|
| 39 |
export default function CLSTile({
|
| 40 |
agent, clsState = "listen", variant: variantOverride,
|
| 41 |
size, borderRadius = 8, isCSA, onReady, onStateComplete,
|
| 42 |
+
liveVideoUrl, onLiveEnded,
|
| 43 |
}: Props) {
|
| 44 |
const { currentVariant, nextVariant, transitioning } = useCLSLoopRouter(
|
| 45 |
agent.id,
|
|
|
|
| 69 |
bRef.current?.load();
|
| 70 |
}, [bVariant]);
|
| 71 |
|
| 72 |
+
const FADE = "opacity 0.6s ease-in-out";
|
| 73 |
+
const LIVE_FADE = "opacity 0.2s ease-in-out"; // live switch is faster β near-instant
|
| 74 |
+
const showLive = !!liveVideoUrl;
|
| 75 |
|
| 76 |
return (
|
| 77 |
<div style={{
|
|
|
|
| 178 |
)}
|
| 179 |
</>
|
| 180 |
)}
|
| 181 |
+
|
| 182 |
+
{/* ββ LAYER C: LIVE RUNWAY VIDEO βββββββββββββββββββββββββββββββββββββ
|
| 183 |
+
Sits on top of everything. Fades in instantly when liveVideoUrl is
|
| 184 |
+
set, fades out when cleared. The CLS loop keeps playing underneath
|
| 185 |
+
so the return transition is seamless.
|
| 186 |
+
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ */}
|
| 187 |
+
{liveVideoUrl && (
|
| 188 |
+
<video
|
| 189 |
+
key={liveVideoUrl}
|
| 190 |
+
src={liveVideoUrl}
|
| 191 |
+
autoPlay
|
| 192 |
+
muted
|
| 193 |
+
playsInline
|
| 194 |
+
onEnded={onLiveEnded}
|
| 195 |
+
style={{
|
| 196 |
+
position: "absolute", inset: 0,
|
| 197 |
+
width: "100%", height: "100%",
|
| 198 |
+
objectFit: "cover", objectPosition: "top center",
|
| 199 |
+
opacity: showLive ? 1 : 0,
|
| 200 |
+
transition: LIVE_FADE,
|
| 201 |
+
zIndex: 10,
|
| 202 |
+
}}
|
| 203 |
+
/>
|
| 204 |
+
)}
|
| 205 |
+
|
| 206 |
+
{/* Gold LIVE pulse ring β shows while Runway layer is active */}
|
| 207 |
+
{showLive && (
|
| 208 |
+
<div style={{
|
| 209 |
+
position: "absolute", inset: 0,
|
| 210 |
+
borderRadius,
|
| 211 |
+
boxShadow: "inset 0 0 0 2px rgba(200,169,81,0.6), 0 0 20px rgba(200,169,81,0.3)",
|
| 212 |
+
zIndex: 11,
|
| 213 |
+
pointerEvents: "none",
|
| 214 |
+
animation: "live-pulse 1.5s ease-in-out infinite",
|
| 215 |
+
}} />
|
| 216 |
+
)}
|
| 217 |
+
<style>{`
|
| 218 |
+
@keyframes live-pulse {
|
| 219 |
+
0%,100% { box-shadow: inset 0 0 0 2px rgba(200,169,81,0.4), 0 0 14px rgba(200,169,81,0.2); }
|
| 220 |
+
50% { box-shadow: inset 0 0 0 3px rgba(200,169,81,0.9), 0 0 28px rgba(200,169,81,0.5); }
|
| 221 |
+
}
|
| 222 |
+
`}</style>
|
| 223 |
</div>
|
| 224 |
);
|
| 225 |
}
|
lib/clique-roster.ts
CHANGED
|
@@ -32,9 +32,9 @@ export const CLIQUE_ROSTER: CliqueAgent[] = [
|
|
| 32 |
{ id:"cleo", name:"Cleo", role:"Minutes & Meetings", voice:"sage", gstackRole:"retro", persona:"Historian of the clique. Captures every decision, action item, and insight with surgical precision. Nothing is missed. Nothing is padded.", portrait:"/characters/CLEO_SHIELD.png", color:"#c8a951", status:"available" },
|
| 33 |
{ id:"zara", name:"Zara", role:"Brand Strategy", voice:"coral", gstackRole:"landing-report", persona:"Brand architect. Controls the narrative β voice, presence, and cultural resonance. Nothing ships without her eye on it.", portrait:"/characters/ZARA_SHIELD.png", color:"#c8a951", status:"available" },
|
| 34 |
{ id:"simone", name:"Simone", role:"UX Research", voice:"sage", gstackRole:"grill-with-docs", persona:"User advocate. Translates real behavior into design truth. Challenges assumptions with receipts.", portrait:"/characters/SIMONE_SHIELD.png", color:"#4CAF50", status:"available" },
|
| 35 |
-
{ id:"
|
| 36 |
{ id:"finn", name:"Finn", role:"Machine Learning", voice:"ash", gstackRole:"autoplan", persona:"Model whisperer. Finds the training shortcut nobody else saw. Moves fast and ships working prototypes.", portrait:"/characters/FINN_SHIELD.png", color:"#4CAF50", status:"available" },
|
| 37 |
-
{ id:"
|
| 38 |
];
|
| 39 |
|
| 40 |
// Default 9-member team β >6 β grid layout, user featured top
|
|
|
|
| 32 |
{ id:"cleo", name:"Cleo", role:"Minutes & Meetings", voice:"sage", gstackRole:"retro", persona:"Historian of the clique. Captures every decision, action item, and insight with surgical precision. Nothing is missed. Nothing is padded.", portrait:"/characters/CLEO_SHIELD.png", color:"#c8a951", status:"available" },
|
| 33 |
{ id:"zara", name:"Zara", role:"Brand Strategy", voice:"coral", gstackRole:"landing-report", persona:"Brand architect. Controls the narrative β voice, presence, and cultural resonance. Nothing ships without her eye on it.", portrait:"/characters/ZARA_SHIELD.png", color:"#c8a951", status:"available" },
|
| 34 |
{ id:"simone", name:"Simone", role:"UX Research", voice:"sage", gstackRole:"grill-with-docs", persona:"User advocate. Translates real behavior into design truth. Challenges assumptions with receipts.", portrait:"/characters/SIMONE_SHIELD.png", color:"#4CAF50", status:"available" },
|
| 35 |
+
{ id:"tj", name:"TJ", role:"Founder", voice:"onyx", gstackRole:"plan-eng-review", persona:"The architect of the whole operation. Vision, conviction, and the last word.", portrait:"/characters/TJ_SHIELD.png", color:"#c8a951", status:"available" },
|
| 36 |
{ id:"finn", name:"Finn", role:"Machine Learning", voice:"ash", gstackRole:"autoplan", persona:"Model whisperer. Finds the training shortcut nobody else saw. Moves fast and ships working prototypes.", portrait:"/characters/FINN_SHIELD.png", color:"#4CAF50", status:"available" },
|
| 37 |
+
{ id:"dev", name:"Dev", role:"Security", voice:"echo", gstackRole:"review", persona:"Threat modeler. Finds the hole before the adversary does. The one person in the room nobody argues with.", portrait:"/characters/DEV_SHIELD.png", color:"#dc3c3c", status:"available" },
|
| 38 |
];
|
| 39 |
|
| 40 |
// Default 9-member team β >6 β grid layout, user featured top
|
lib/use-cls-runner.ts
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
/**
|
| 3 |
+
* useCLSRunner β CLS β Live Runway Switcher
|
| 4 |
+
*
|
| 5 |
+
* Orchestrates the full lifecycle for one agent:
|
| 6 |
+
* CLS loop (idle) β generate Runway clip β instant switch to LIVE β return to CLS
|
| 7 |
+
*
|
| 8 |
+
* The switch from loop β live happens in < 200ms once the Runway clip is ready.
|
| 9 |
+
* Clips are pre-generated speculatively so the switch feels instant to the user.
|
| 10 |
+
*
|
| 11 |
+
* Usage:
|
| 12 |
+
* const { liveVideoUrl, isLive, goLive, returnToLoop } = useCLSRunner(agentId);
|
| 13 |
+
* // Pass liveVideoUrl to CLSTile β it handles the visual crossfade.
|
| 14 |
+
*/
|
| 15 |
+
|
| 16 |
+
import { useCallback, useEffect, useRef, useState } from "react";
|
| 17 |
+
|
| 18 |
+
export type LiveMood = "speak" | "nod" | "react" | "explain";
|
| 19 |
+
|
| 20 |
+
export interface CLSRunnerHandle {
|
| 21 |
+
/** URL of the ready Runway live clip β null while in CLS loop mode */
|
| 22 |
+
liveVideoUrl: string | null;
|
| 23 |
+
/** True when the live Runway layer is showing */
|
| 24 |
+
isLive: boolean;
|
| 25 |
+
/** True while a Runway clip is being generated */
|
| 26 |
+
isGenerating: boolean;
|
| 27 |
+
/** Trigger a live moment β generates clip + switches instantly when ready */
|
| 28 |
+
goLive: (mood?: LiveMood) => void;
|
| 29 |
+
/** Return to CLS idle loop */
|
| 30 |
+
returnToLoop: () => void;
|
| 31 |
+
/** Pre-warm: silently generate a clip in background so next goLive() is instant */
|
| 32 |
+
prewarm: (mood?: LiveMood) => void;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
const POLL_INTERVAL_MS = 1_500;
|
| 36 |
+
|
| 37 |
+
export function useCLSRunner(agentId: string): CLSRunnerHandle {
|
| 38 |
+
const [liveVideoUrl, setLiveVideoUrl] = useState<string | null>(null);
|
| 39 |
+
const [isLive, setIsLive] = useState(false);
|
| 40 |
+
const [isGenerating, setIsGenerating] = useState(false);
|
| 41 |
+
|
| 42 |
+
// Pre-warmed clip cache: mood β url
|
| 43 |
+
const cache = useRef<Map<string, string>>(new Map());
|
| 44 |
+
const pollTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
| 45 |
+
const currentTask= useRef<string | null>(null);
|
| 46 |
+
const pendingMood= useRef<LiveMood>("speak");
|
| 47 |
+
const unmounted = useRef(false);
|
| 48 |
+
|
| 49 |
+
useEffect(() => () => { unmounted.current = true; stopPoll(); }, []);
|
| 50 |
+
|
| 51 |
+
function stopPoll() {
|
| 52 |
+
if (pollTimer.current) { clearInterval(pollTimer.current); pollTimer.current = null; }
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
async function submitTask(mood: LiveMood): Promise<string | null> {
|
| 56 |
+
try {
|
| 57 |
+
const res = await fetch("/api/clique/cls-live", {
|
| 58 |
+
method: "POST",
|
| 59 |
+
headers: { "Content-Type": "application/json" },
|
| 60 |
+
body: JSON.stringify({ agentId, mood }),
|
| 61 |
+
});
|
| 62 |
+
if (!res.ok) return null;
|
| 63 |
+
const { taskId } = await res.json();
|
| 64 |
+
return taskId ?? null;
|
| 65 |
+
} catch { return null; }
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
async function pollTask(taskId: string): Promise<string | null> {
|
| 69 |
+
try {
|
| 70 |
+
const res = await fetch(`/api/clique/cls-live?taskId=${taskId}`);
|
| 71 |
+
if (!res.ok) return null;
|
| 72 |
+
const data = await res.json();
|
| 73 |
+
if (data.status === "SUCCEEDED") return data.videoUrl ?? null;
|
| 74 |
+
if (data.status === "FAILED") return null;
|
| 75 |
+
return undefined as unknown as null; // still running
|
| 76 |
+
} catch { return null; }
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
// ββ goLive ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 80 |
+
const goLive = useCallback(async (mood: LiveMood = "speak") => {
|
| 81 |
+
if (unmounted.current) return;
|
| 82 |
+
pendingMood.current = mood;
|
| 83 |
+
|
| 84 |
+
// 1. Check cache first β instant switch
|
| 85 |
+
const cached = cache.current.get(mood);
|
| 86 |
+
if (cached) {
|
| 87 |
+
setLiveVideoUrl(cached);
|
| 88 |
+
setIsLive(true);
|
| 89 |
+
cache.current.delete(mood); // consume it
|
| 90 |
+
return;
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
// 2. Not cached β generate now and switch when ready
|
| 94 |
+
setIsGenerating(true);
|
| 95 |
+
const taskId = await submitTask(mood);
|
| 96 |
+
if (!taskId || unmounted.current) { setIsGenerating(false); return; }
|
| 97 |
+
currentTask.current = taskId;
|
| 98 |
+
|
| 99 |
+
stopPoll();
|
| 100 |
+
pollTimer.current = setInterval(async () => {
|
| 101 |
+
if (unmounted.current) { stopPoll(); return; }
|
| 102 |
+
const url = await pollTask(currentTask.current!);
|
| 103 |
+
if (url === null) { stopPoll(); setIsGenerating(false); return; } // failed
|
| 104 |
+
if (typeof url === "string" && url.length > 0) {
|
| 105 |
+
stopPoll();
|
| 106 |
+
setIsGenerating(false);
|
| 107 |
+
if (unmounted.current) return;
|
| 108 |
+
setLiveVideoUrl(url);
|
| 109 |
+
setIsLive(true);
|
| 110 |
+
}
|
| 111 |
+
// undefined = still running, keep polling
|
| 112 |
+
}, POLL_INTERVAL_MS);
|
| 113 |
+
}, [agentId]);
|
| 114 |
+
|
| 115 |
+
// ββ returnToLoop ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 116 |
+
const returnToLoop = useCallback(() => {
|
| 117 |
+
stopPoll();
|
| 118 |
+
setIsLive(false);
|
| 119 |
+
setLiveVideoUrl(null);
|
| 120 |
+
setIsGenerating(false);
|
| 121 |
+
currentTask.current = null;
|
| 122 |
+
}, []);
|
| 123 |
+
|
| 124 |
+
// ββ prewarm βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 125 |
+
const prewarm = useCallback(async (mood: LiveMood = "speak") => {
|
| 126 |
+
if (cache.current.has(mood) || unmounted.current) return;
|
| 127 |
+
|
| 128 |
+
const taskId = await submitTask(mood);
|
| 129 |
+
if (!taskId) return;
|
| 130 |
+
|
| 131 |
+
const timer = setInterval(async () => {
|
| 132 |
+
if (unmounted.current) { clearInterval(timer); return; }
|
| 133 |
+
const url = await pollTask(taskId);
|
| 134 |
+
if (typeof url === "string" && url.length > 0) {
|
| 135 |
+
clearInterval(timer);
|
| 136 |
+
cache.current.set(mood, url);
|
| 137 |
+
}
|
| 138 |
+
if (url === null) clearInterval(timer); // failed silently
|
| 139 |
+
}, POLL_INTERVAL_MS);
|
| 140 |
+
}, [agentId]);
|
| 141 |
+
|
| 142 |
+
return { liveVideoUrl, isLive, isGenerating, goLive, returnToLoop, prewarm };
|
| 143 |
+
}
|