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 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 β€” always alive, never a frozen image */}
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 = "opacity 0.6s ease-in-out";
 
 
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:"cole", name:"Cole", role:"Cloud Architecture", voice:"onyx", gstackRole:"plan-eng-review", persona:"Infrastructure obsessive. Builds systems that scale before they need to. Quiet until it matters.", portrait:"/characters/COLE_SHIELD.png", color:"#1a5f7a", 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:"rhys", name:"Rhys", 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/RHYS_SHIELD.png", color:"#dc3c3c", status:"available" },
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
+ }