HomePilot / frontend /src /ui /InteractivePlayer.tsx
HomePilot Deploy Bot
chore(hf): sync HomePilot to HF Space
921d377
Raw
History Blame Contribute Delete
38.6 kB
/**
* InteractivePlayer β€” full-bleed live-play surface.
*
* Mirrors the candy.ai-style layout from the screenshots:
*
* β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
* β”‚ VideoStage (scene video, or idle) β”‚
* β”‚ β”‚
* β”‚ β”ŒTopBar─────────────────────────────┐ β”‚
* β”‚ β”‚ β—€ avatar Β· name Β· lvl Β· πŸ”Š β‹― βœ• β”‚ β”‚
* β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
* β”‚ β”‚
* β”‚ β”ŒChatOverlay (last N turns)┐ β”‚
* β”‚ β”‚ viewer: Hi β”‚ β”‚
* β”‚ β”‚ char: Hey trouble… β”‚ β”‚
* β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
* β”‚ β”‚
* β”‚ β”ŒInput──────────────────────── β–Ά ┐ β”‚
* β”‚ β”‚ Ask Anything… β”‚ β”‚
* β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚
* β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
*
* The video stage stays full-bleed behind chat β€” when the player
* mounts we render a placeholder backdrop with the experience
* gradient; as scene jobs complete via /pending, the most recent
* ``status === 'ready'`` job becomes the current scene. Phase-1
* asset ids are stubs so we show a small debug label; phase-2
* replaces the placeholder with a <video> element sourced from
* the real Animate asset URL.
*
* Mutations are additive-only: the component never touches the
* editor DB state or the authoring routes. Every write goes
* through POST /play/.../chat which the backend already proved
* in PLAY-4 keeps the policy gate in front of the render job.
*
* Production fixes in this version:
* - Persona idle portrait is centered and never face-cropped.
* - Portrait-oriented scene stills are auto-detected and rendered
* with a contain/center foreground plus blurred cinematic fill.
* - Avatar chip uses object-top so faces crop more naturally.
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
ArrowLeft,
Eye,
EyeOff,
Gamepad2,
MoreHorizontal,
Send,
Volume2,
VolumeX,
Workflow,
X,
} from "lucide-react";
import { createInteractiveApi, type InteractiveApi } from "./interactive/api";
import { LiveActionSheet } from "./interactive/LiveActionSheet";
import { PersonaLiveRuntimeShell } from "./interactive/personaLiveRuntime";
import { StandardPlayer } from "./interactive/StandardPlayer";
import { XPRewardsSheet } from "./interactive/XPRewardsSheet";
import type {
AudienceProfile,
CatalogItemView,
ChatResult,
Experience,
InteractionType,
ResolveResult,
SceneJobView,
} from "./interactive/types";
import { InteractiveApiError, resolveInteractionType } from "./interactive/types";
import {
ErrorBanner,
PrimaryButton,
SecondaryButton,
StatusBadge,
ToastProvider,
useAsyncResource,
useToast,
} from "./interactive/ui";
// Adaptive polling. The previous fixed 1.5s interval flooded backend
// access logs with GET /v1/interactive/play/sessions/*/pending entries
// (~40/min per open session). We now poll fast only while there's
// actual work in flight and back off when the server has nothing new.
const POLL_INTERVAL_ACTIVE_MS = 1500; // something pending β†’ stay responsive
const POLL_INTERVAL_IDLE_MS = 5000; // nothing pending β†’ cool off
const POLL_INTERVAL_MAX_MS = 15000; // ceiling for error backoff
const MAX_VISIBLE_TURNS = 6;
type ChatRole = "user" | "assistant" | "system";
interface ChatBubble {
id: string;
role: ChatRole;
text: string;
ts: number;
intent?: string;
blocked?: boolean;
blockedReason?: string;
}
export interface InteractivePlayerProps {
backendUrl: string;
apiKey?: string;
projectId: string;
onExit: () => void;
}
export default function InteractivePlayer(props: InteractivePlayerProps) {
return (
<ToastProvider>
<InteractivePlayerBody {...props} />
</ToastProvider>
);
}
function InteractivePlayerBody({
backendUrl,
apiKey,
projectId,
onExit,
}: InteractivePlayerProps) {
const _readBool = (v: unknown): boolean | null => {
if (typeof v === "boolean") return v;
const s = String(v ?? "").trim().toLowerCase();
if (!s) return null;
if (["1", "true", "yes", "on"].includes(s)) return true;
if (["0", "false", "no", "off"].includes(s)) return false;
return null;
};
const _initialMuteFromAudience = (ap: Record<string, unknown> | null | undefined): boolean => {
const explicit =
_readBool(ap?.tts_muted) ??
_readBool(ap?.mute_tts) ??
_readBool(ap?.muted);
// Keep autoplay-safe default (muted) unless experience-level
// settings explicitly opt in/out.
return explicit ?? true;
};
const api = useMemo(() => createInteractiveApi(backendUrl, apiKey), [backendUrl, apiKey]);
const toast = useToast();
// ── Session bootstrap ───────────────────────────────────────
const exp = useAsyncResource<Experience>(
(signal) => api.getExperience(projectId, signal),
[api, projectId],
);
const session = useAsyncResource<{
id: string;
initial_scene?: {
node_id: string;
asset_id: string;
asset_url: string;
media_kind: "image" | "video" | "unknown" | string;
duration_sec: number;
title: string;
narration?: string;
subtitles?: string;
status?: "pending" | "rendering" | "ready" | "failed" | string;
} | null;
opening_turn?: { reply_text: string; scene_prompt: string; character_turn_id: string };
persona_portrait_url?: string;
render_media_type?: "image" | "video";
}>(
async () => {
const sess = await api.startSession({
experience_id: projectId,
viewer_ref: `player_${Date.now()}`,
});
return {
id: sess.id,
initial_scene: sess.initial_scene || null,
opening_turn: sess.opening_turn,
persona_portrait_url: sess.persona_portrait_url,
render_media_type: sess.render_media_type,
};
},
[api, projectId],
);
const sessionId = session.data?.id || "";
const openingTurn = session.data?.opening_turn;
const personaPortraitUrl = session.data?.persona_portrait_url || "";
// ── Player state ────────────────────────────────────────────
const [bubbles, setBubbles] = useState<ChatBubble[]>([]);
const [input, setInput] = useState("");
const [sending, setSending] = useState(false);
const [mood, setMood] = useState("neutral");
const [affinity, setAffinity] = useState(0.5);
const [muted, setMuted] = useState(true);
const [hideChat, setHideChat] = useState(false);
const [currentScene, setCurrentScene] = useState<SceneJobView | null>(null);
const [pendingScene, setPendingScene] = useState<SceneJobView | null>(null);
const [cursor, setCursor] = useState<string>("");
const [liveActionOpen, setLiveActionOpen] = useState(false);
const [xpRewardsOpen, setXpRewardsOpen] = useState(false);
const [pollFailures, setPollFailures] = useState(0);
useEffect(() => {
const initial = session.data?.initial_scene;
if (!initial) return;
// Debug: dump what the backend delivered so the next "Play shows
// empty" bug tells us whether the miss is in the payload
// (backend) or in how we mount it (frontend). Remove once the
// player's bootstrap is proven stable end-to-end.
// eslint-disable-next-line no-console
console.log("[InteractivePlayer] initial_scene from backend:", {
node_id: initial.node_id,
status: initial.status,
asset_id: initial.asset_id,
asset_url: initial.asset_url,
media_kind: initial.media_kind,
duration_sec: initial.duration_sec,
});
setCurrentScene({
id: `initial_${initial.node_id || "scene"}`,
session_id: sessionId,
turn_id: "",
status: (String(initial.status || "").toLowerCase() as SceneJobView["status"]) || "pending",
job_id: "",
asset_id: initial.asset_id || "",
media_kind: initial.media_kind || "unknown",
asset_url: initial.asset_url || "",
prompt: initial.title || "",
narration: initial.narration || "",
subtitles: initial.subtitles || "",
duration_sec: Number(initial.duration_sec || 5),
error: "",
created_at: "",
updated_at: "",
});
}, [session.data?.initial_scene, sessionId]);
// Apply experience-level default mute once when the experience loads.
const seededMuteFromExperienceRef = useRef(false);
useEffect(() => {
if (seededMuteFromExperienceRef.current) return;
const ap = (exp.data?.audience_profile || null) as Record<string, unknown> | null;
if (!ap) return;
setMuted(_initialMuteFromAudience(ap));
seededMuteFromExperienceRef.current = true;
}, [exp.data?.audience_profile]);
const cursorRef = useRef<string>("");
useEffect(() => {
cursorRef.current = cursor;
}, [cursor]);
const seededOpeningForRef = useRef<string>("");
useEffect(() => {
if (!sessionId || !openingTurn?.reply_text) return;
if (seededOpeningForRef.current === sessionId) return;
seededOpeningForRef.current = sessionId;
setBubbles((prev) => [
...prev,
{
id: `a_open_${sessionId}`,
role: "assistant",
text: openingTurn.reply_text,
ts: Date.now(),
},
]);
}, [sessionId, openingTurn]);
// ── Polling loop ────────────────────────────────────────────
useEffect(() => {
if (!sessionId) return;
let cancelled = false;
let timer: number | undefined;
let failures = 0;
async function tick() {
if (cancelled) return;
// Default delay for next tick β€” reassigned based on activity/errors.
let nextDelay = POLL_INTERVAL_IDLE_MS;
try {
const res = await api.pending(sessionId, { since_id: cursorRef.current || undefined });
if (cancelled) return;
const hasItems = res.items.length > 0;
let stillPending: typeof res.items[number] | null = null;
if (hasItems) {
const latestReady = [...res.items].reverse().find((j) => j.status === "ready");
if (latestReady) setCurrentScene(latestReady);
stillPending = res.items.find(
(j) => j.status === "pending" || j.status === "rendering",
) || null;
setPendingScene(stillPending);
setCursor(res.cursor || cursorRef.current);
}
// Poll fast while there's work in flight; otherwise cool off.
nextDelay = stillPending ? POLL_INTERVAL_ACTIVE_MS : POLL_INTERVAL_IDLE_MS;
failures = 0;
setPollFailures(0);
} catch {
failures = Math.min(failures + 1, 6);
setPollFailures((n) => Math.min(n + 1, 99));
// Exponential backoff on transient failures: 1.5s β†’ 3s β†’ 6s β†’ 12s β†’ cap.
nextDelay = Math.min(POLL_INTERVAL_ACTIVE_MS * 2 ** failures, POLL_INTERVAL_MAX_MS);
} finally {
if (!cancelled) timer = window.setTimeout(tick, nextDelay);
}
}
timer = window.setTimeout(tick, POLL_INTERVAL_ACTIVE_MS);
return () => {
cancelled = true;
if (timer) window.clearTimeout(timer);
};
}, [api, sessionId]);
// ── Send handler ────────────────────────────────────────────
const onSend = useCallback(async () => {
const text = input.trim();
if (!text || !sessionId || sending) return;
setSending(true);
const optimisticId = `u_${Date.now()}`;
setBubbles((prev) => [
...prev,
{ id: optimisticId, role: "user", text, ts: Date.now() },
]);
setInput("");
try {
const result: ChatResult = await api.chat(sessionId, { text });
if (result.status === "blocked") {
setBubbles((prev) => [
...prev,
{
id: `sys_${Date.now()}`,
role: "system",
text: policyMessage(result.decision.reason_code, result.decision.message),
ts: Date.now(),
blocked: true,
blockedReason: result.decision.reason_code,
},
]);
toast.toast({
variant: "warning",
title: "That message was blocked by policy",
message: result.decision.reason_code || "Try rephrasing.",
});
return;
}
if (result.reply_text) {
setBubbles((prev) => [
...prev,
{
id: `a_${Date.now()}`,
role: "assistant",
text: result.reply_text,
ts: Date.now(),
intent: result.intent_code,
},
]);
}
if (typeof result.mood === "string" && result.mood) setMood(result.mood);
if (typeof result.affinity_score === "number") setAffinity(result.affinity_score);
if (result.video_asset_id && result.video_job_status === "ready") {
const resolvedUrl = result.video_asset_url || "";
setCurrentScene({
id: result.video_job_id,
session_id: sessionId,
turn_id: result.character_turn_id || "",
status: "ready",
job_id: "",
asset_id: result.video_asset_id,
media_kind: String(result.video_media_kind || inferMediaKind(resolvedUrl)),
asset_url: resolvedUrl,
prompt: result.scene_prompt || "",
duration_sec: result.duration_sec || 5,
error: "",
created_at: "",
updated_at: "",
});
}
} catch (err) {
const e = err as InteractiveApiError;
toast.toast({
variant: "error",
title: "Message failed",
message: e.message || "Try again.",
});
} finally {
setSending(false);
}
}, [api, input, sending, sessionId, toast]);
// ── Action catalog resolution (Live Action sheet) ───────────
const onActionResolved = useCallback(
(resolved: ResolveResult, action: CatalogItemView) => {
const ts = Date.now();
setBubbles((prev) => [
...prev,
{ id: `a_user_${ts}`, role: "user", text: `β–Ά ${action.label}`, ts },
{
id: `a_ast_${ts}`,
role: "assistant",
text: resolved.level_description?.display
? `${action.label} Β· ${resolved.level_description.display}`
: action.label,
ts,
intent: resolved.intent_code,
},
]);
if (typeof resolved.mood === "string" && resolved.mood) setMood(resolved.mood);
if (typeof resolved.affinity_score === "number") setAffinity(resolved.affinity_score);
setLiveActionOpen(false);
// Scene navigation. The Standard player's choice card click
// surfaces a ResolveResult whose ``transition.to_node_id`` is
// the destination scene the player should now show. The
// previous wiring stopped at "update mood/affinity/bubbles"
// β€” the on-screen scene never advanced, so users reported
// "the click does nothing". Now: optimistically swap the
// current scene to a "transitioning" stub so the user sees
// immediate response, then fetch the destination node + asset
// URL and replace.
const nextNodeId = String(resolved.transition?.to_node_id || "").trim();
if (!nextNodeId) return;
// Optimistic stub: set status to "rendering" so the player
// shows its built-in "Generating scene…" placeholder while
// we resolve the real asset. This is the visible feedback
// the user was missing.
setCurrentScene((prev) => ({
id: nextNodeId,
session_id: prev?.session_id || sessionId,
turn_id: "",
status: "rendering",
job_id: "",
asset_id: "",
media_kind: "unknown",
asset_url: "",
prompt: action.label,
narration: "",
subtitles: "",
duration_sec: 5,
error: "",
created_at: "",
updated_at: "",
}));
// Async resolution: fetch the experience graph (cheap β€” one
// call, results land in the in-memory list), find the
// destination node, resolve its first asset id to a URL, and
// commit. If anything fails we leave the polling loop to
// catch up β€” that path still works as a fallback.
void (async () => {
try {
const nodes = await api.listNodes(projectId);
const node = nodes.find((n) => n.id === nextNodeId);
if (!node) return;
const assetId = (node.asset_ids || [])[0] || "";
let assetUrl = "";
if (assetId) {
try {
assetUrl = (await api.resolveAssetUrl(assetId)) || "";
} catch {
/* fall back to polling */
}
}
setCurrentScene({
id: node.id,
session_id: sessionId,
turn_id: "",
status: assetUrl ? "ready" : "pending",
job_id: "",
asset_id: assetId,
media_kind: "unknown",
asset_url: assetUrl,
prompt: node.title || action.label,
narration: node.narration || "",
subtitles: "",
duration_sec: Number(node.duration_sec || 5),
error: "",
created_at: "",
updated_at: "",
});
} catch {
/* polling fallback */
}
})();
},
[api, projectId, sessionId],
);
// ── Keyboard: Enter submits, Shift+Enter newline ────────────
const onInputKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
onSend();
}
},
[onSend],
);
// ── Derived ──────────────────────────────────────────────────
const visibleBubbles = useMemo(() => bubbles.slice(-MAX_VISIBLE_TURNS), [bubbles]);
// ── Render ───────────────────────────────────────────────────
if (exp.error || session.error) {
return (
<div className="min-h-full bg-[#0f0f0f] text-[#f1f1f1] p-6">
<SecondaryButton
onClick={onExit}
size="sm"
icon={<ArrowLeft className="w-3.5 h-3.5" aria-hidden />}
>
Back
</SecondaryButton>
<div className="mt-6 max-w-xl mx-auto">
<ErrorBanner
title="Couldn't start the live session"
message={(exp.error || session.error) || "Unknown error"}
onRetry={() => {
exp.reload();
session.reload();
}}
/>
</div>
</div>
);
}
if (exp.loading || session.loading || !exp.data || !sessionId) {
return (
<div className="min-h-full bg-[#0f0f0f] text-[#f1f1f1] flex items-center justify-center">
<div className="flex flex-col items-center gap-3">
<Workflow className="w-10 h-10 text-[#3ea6ff] animate-pulse" aria-hidden />
<div className="text-sm text-[#aaa]">Warming up the stage…</div>
</div>
</div>
);
}
const experience = exp.data;
const level = Math.max(1, Math.round(affinity * 5) + 1);
const audience = (experience.audience_profile || {}) as AudienceProfile;
const interactionType: InteractionType = resolveInteractionType(experience);
const displayName =
(interactionType === "persona_live_play" && audience.persona_label) ||
experience.title ||
"Interactive";
const avatarUrl = audience.persona_avatar_url || "";
if (interactionType === "standard_project") {
return (
<div className="relative min-h-screen bg-black text-[#f1f1f1] overflow-hidden select-none">
<StandardPlayer
api={api}
sessionId={sessionId}
experienceId={projectId}
experience={exp.data || null}
scene={currentScene}
onExit={onExit}
onResolved={onActionResolved}
/>
</div>
);
}
if ((experience.project_type || "") === "persona_live") {
return (
<div className="relative min-h-screen bg-black text-[#f1f1f1] overflow-hidden select-none">
<PersonaLiveRuntimeShell api={api} experience={experience} onExit={onExit} />
</div>
);
}
const effectiveAvatarUrl = avatarUrl || personaPortraitUrl;
return (
<div className="relative min-h-screen bg-black text-[#f1f1f1] overflow-hidden select-none">
<VideoStage
scene={currentScene}
mood={mood}
muted={muted}
portraitUrl={personaPortraitUrl}
/>
<TopBar
title={displayName}
subtitle={moodDescriptor(mood, affinity)}
avatarUrl={effectiveAvatarUrl}
level={level}
onOpenXp={() => setXpRewardsOpen(true)}
muted={muted}
onToggleMute={() => setMuted((m) => !m)}
hideChat={hideChat}
onToggleHideChat={() => setHideChat((h) => !h)}
onExit={onExit}
/>
{pendingScene && <GeneratingHint prompt={pendingScene.prompt} />}
{pollFailures >= 3 && <ReconnectChip />}
{!hideChat && <ChatOverlay bubbles={visibleBubbles} />}
{!hideChat && (
<>
<LiveActionFab onClick={() => setLiveActionOpen(true)} />
<ChatInput
value={input}
onChange={setInput}
onKeyDown={onInputKeyDown}
onSend={onSend}
sending={sending}
/>
</>
)}
<LiveActionSheet
open={liveActionOpen}
onClose={() => setLiveActionOpen(false)}
api={api}
sessionId={sessionId}
currentLevel={level}
onResolved={onActionResolved}
/>
<XPRewardsSheet
open={xpRewardsOpen}
onClose={() => setXpRewardsOpen(false)}
api={api}
sessionId={sessionId}
/>
</div>
);
}
function inferMediaKind(url: string): "image" | "video" | "unknown" {
const u = (url || "").toLowerCase();
if ([".mp4", ".webm", ".mov", ".mkv", ".m4v"].some((ext) => u.endsWith(ext) || u.includes(`${ext}?`))) {
return "video";
}
if ([".png", ".jpg", ".jpeg", ".webp", ".gif", ".avif"].some((ext) => u.endsWith(ext) || u.includes(`${ext}?`))) {
return "image";
}
return "unknown";
}
function LiveActionFab({ onClick }: { onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
aria-label="Open Live Action catalog"
className={[
"absolute bottom-[92px] right-4 z-20",
"w-12 h-12 rounded-full",
"bg-gradient-to-br from-[#6366f1] to-[#3ea6ff]",
"shadow-lg flex items-center justify-center",
"focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3ea6ff] focus-visible:ring-offset-2 focus-visible:ring-offset-black",
"hover:scale-105 active:scale-95 transition-transform",
].join(" ")}
>
<Gamepad2 className="w-5 h-5 text-white" aria-hidden />
</button>
);
}
// ────────────────────────────────────────────────────────────────
// Subcomponents
// ────────────────────────────────────────────────────────────────
function VideoStage({
scene,
mood,
muted,
portraitUrl,
}: {
scene: SceneJobView | null;
mood: string;
muted: boolean;
portraitUrl?: string;
}) {
const tint = moodTint(mood);
const url = scene?.status === "ready" ? (scene.asset_url || "") : "";
const isVideo = url && /\.(mp4|webm|mov|mkv|m4v)(\?|$)/i.test(url);
const isImage = url && !isVideo;
if (isVideo) {
return (
<div
key={scene!.id}
className="absolute inset-0 -z-10 bg-black animate-scene-fade"
aria-hidden
>
<video
src={url}
autoPlay
loop
muted={muted}
playsInline
preload="auto"
className="absolute inset-0 w-full h-full object-cover"
// Face-safe focal point: persona faces sit in the upper third of the
// frame. Default ``object-position: center`` chops them when the
// container is wider than the asset's aspect. 50% 18% keeps the
// face visible without changing full-bleed composition.
style={{ objectPosition: '50% 18%' }}
/>
<SceneStamp scene={scene!} />
</div>
);
}
if (isImage) {
return (
<div
key={scene!.id}
className="absolute inset-0 -z-10 bg-black animate-scene-fade"
aria-hidden
>
<ResponsiveStageImage
src={url}
alt=""
forcePortraitContain={false}
overlay={
<div
className="absolute inset-0 pointer-events-none"
style={{
background:
"linear-gradient(to bottom, rgba(0,0,0,0.10), rgba(0,0,0,0.22))",
}}
/>
}
/>
<SceneStamp scene={scene!} />
</div>
);
}
if (portraitUrl) {
return (
<div
key={`portrait-${portraitUrl}`}
className="absolute inset-0 -z-10 bg-black animate-scene-fade"
aria-hidden
>
<ResponsiveStageImage
src={portraitUrl}
alt=""
forcePortraitContain
overlay={
<>
<div
className="absolute inset-0 pointer-events-none animate-mood-breathe"
style={{
background: `radial-gradient(1200px 800px at 50% 40%, ${tint.mid}22 0%, transparent 55%, #0006 100%)`,
}}
/>
<div
className="absolute inset-0 pointer-events-none"
style={{
background:
"linear-gradient(to bottom, rgba(0,0,0,0.14), rgba(0,0,0,0.26))",
}}
/>
</>
}
/>
</div>
);
}
return (
<div
key={scene?.id || `idle-${mood}`}
className="absolute inset-0 -z-10 animate-scene-fade animate-mood-breathe"
style={{
background: `radial-gradient(1200px 800px at 50% 40%, ${tint.mid} 0%, #0a0a0a 70%, #000 100%)`,
}}
aria-hidden
>
{scene && scene.status === "ready" && <SceneStamp scene={scene} />}
</div>
);
}
/**
* ResponsiveStageImage
*
* Production behavior:
* - Landscape images stay full-bleed with object-cover.
* - Portrait images switch to a blurred background fill plus a
* centered object-contain foreground, so faces are not cropped.
* - `forcePortraitContain` is used for Persona Live idle portraits
* where we always want a safe, centered composition.
*/
function ResponsiveStageImage({
src,
alt,
forcePortraitContain = false,
overlay,
}: {
src: string;
alt: string;
forcePortraitContain?: boolean;
overlay?: React.ReactNode;
}) {
const [broken, setBroken] = useState(false);
const [isPortrait, setIsPortrait] = useState(forcePortraitContain);
if (!src || broken) return null;
const useContain = forcePortraitContain || isPortrait;
return (
<div className="absolute inset-0 overflow-hidden bg-black">
{/* cinematic fill */}
<img
src={src}
alt=""
className="absolute inset-0 w-full h-full object-cover scale-105 blur-xl opacity-35"
onError={() => setBroken(true)}
onLoad={(e) => {
const img = e.currentTarget;
if (!forcePortraitContain && img.naturalHeight > img.naturalWidth * 1.05) {
setIsPortrait(true);
}
}}
/>
{/* main visible asset */}
<img
src={src}
alt={alt}
className={[
"absolute inset-0 w-full h-full transition-[transform,opacity] duration-300 ease-out",
// Landscape cover: bias the focal point toward the upper third so
// persona faces aren't cropped by default center-cover. Portrait
// path uses object-contain so the whole image is always visible.
useContain ? "object-contain object-center" : "object-cover",
].join(" ")}
style={!useContain ? { objectPosition: '50% 18%' } : undefined}
onError={() => setBroken(true)}
onLoad={(e) => {
const img = e.currentTarget;
if (!forcePortraitContain && img.naturalHeight > img.naturalWidth * 1.05) {
setIsPortrait(true);
}
}}
/>
{overlay}
</div>
);
}
function SceneStamp({ scene }: { scene: SceneJobView }) {
return (
<div className="absolute bottom-28 left-1/2 -translate-x-1/2 text-[10px] uppercase tracking-widest text-white/40 backdrop-blur-sm bg-black/30 rounded px-2 py-1 pointer-events-none">
scene Β· {scene.id.slice(-6)} Β· {scene.duration_sec}s
</div>
);
}
function ReconnectChip() {
return (
<div
role="status"
className="absolute top-20 right-4 z-10 bg-amber-500/90 text-black rounded-full px-3 py-1.5 text-[11px] font-medium flex items-center gap-2 shadow-lg"
>
<span className="w-1.5 h-1.5 rounded-full bg-black animate-live-dot" aria-hidden />
Reconnecting…
</div>
);
}
function TopBar({
title,
subtitle,
avatarUrl,
level,
onOpenXp,
muted,
onToggleMute,
hideChat,
onToggleHideChat,
onExit,
}: {
title: string;
subtitle: string;
avatarUrl?: string;
level: number;
onOpenXp: () => void;
muted: boolean;
onToggleMute: () => void;
hideChat: boolean;
onToggleHideChat: () => void;
onExit: () => void;
}) {
return (
<div className="absolute top-0 inset-x-0 z-20 px-4 py-3 flex items-center justify-between gap-3 bg-gradient-to-b from-black/70 to-transparent">
<div className="flex items-center gap-2.5 min-w-0">
<button
type="button"
onClick={onExit}
aria-label="Back to projects"
className="w-9 h-9 rounded-full bg-white/10 hover:bg-white/15 flex items-center justify-center transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3ea6ff]"
>
<ArrowLeft className="w-4 h-4" aria-hidden />
</button>
<Avatar url={avatarUrl} label={title} />
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold truncate max-w-[200px]">{title}</span>
<button
type="button"
onClick={onOpenXp}
aria-label={`View XP rewards β€” level ${level}`}
className="focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3ea6ff] rounded-full"
>
<LevelPill level={level} />
</button>
</div>
<div className="text-[11px] text-white/60 truncate">{subtitle}</div>
</div>
</div>
<div className="flex items-center gap-2">
<RoundIconBtn onClick={onToggleMute} label={muted ? "Unmute" : "Mute"}>
{muted ? (
<VolumeX className="w-4 h-4" aria-hidden />
) : (
<Volume2 className="w-4 h-4" aria-hidden />
)}
</RoundIconBtn>
<RoundIconBtn onClick={onToggleHideChat} label={hideChat ? "Show chat" : "Hide chat"}>
{hideChat ? (
<EyeOff className="w-4 h-4" aria-hidden />
) : (
<Eye className="w-4 h-4" aria-hidden />
)}
</RoundIconBtn>
<RoundIconBtn onClick={() => {}} label="More options">
<MoreHorizontal className="w-4 h-4" aria-hidden />
</RoundIconBtn>
<RoundIconBtn onClick={onExit} label="Close">
<X className="w-4 h-4" aria-hidden />
</RoundIconBtn>
</div>
</div>
);
}
function RoundIconBtn({
children,
onClick,
label,
}: {
children: React.ReactNode;
onClick: () => void;
label: string;
}) {
return (
<button
type="button"
onClick={onClick}
aria-label={label}
className="w-9 h-9 rounded-full bg-white/10 hover:bg-white/15 flex items-center justify-center transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-[#3ea6ff]"
>
{children}
</button>
);
}
function LevelPill({ level }: { level: number }) {
return (
<span className="inline-flex items-center justify-center w-6 h-6 rounded-full bg-black/60 border border-white/20 text-[11px] font-semibold">
{level}
</span>
);
}
function Avatar({ url, label }: { url?: string; label: string }) {
const initial = (label || "?").trim().charAt(0).toUpperCase();
return (
<div
className="w-8 h-8 rounded-full overflow-hidden border border-white/20 bg-gradient-to-br from-[#6366f1]/70 to-[#3ea6ff]/70 shrink-0 flex items-center justify-center text-[11px] font-semibold text-white"
aria-hidden
>
{url ? (
<img
src={url}
alt=""
className="w-full h-full object-cover object-top"
onError={(e) => {
(e.currentTarget as HTMLImageElement).style.display = "none";
}}
/>
) : (
<span>{initial}</span>
)}
</div>
);
}
function ChatOverlay({ bubbles }: { bubbles: ChatBubble[] }) {
if (bubbles.length === 0) return null;
return (
<div className="absolute inset-x-0 bottom-24 z-10 px-4 pb-4 pointer-events-none">
<ol className="max-w-xl mx-auto flex flex-col gap-2">
{bubbles.map((b) => (
<li key={b.id} className={b.role === "user" ? "self-start" : "self-start w-full"}>
<ChatBubbleView bubble={b} />
</li>
))}
</ol>
</div>
);
}
function ChatBubbleView({ bubble }: { bubble: ChatBubble }) {
if (bubble.role === "user") {
return (
<div className="inline-block rounded-2xl px-3 py-1.5 bg-[#3ea6ff]/80 text-black text-sm font-medium shadow-md">
{bubble.text}
</div>
);
}
if (bubble.role === "system") {
return (
<div className="inline-block rounded-2xl px-3 py-2 bg-amber-500/15 border border-amber-400/40 text-amber-200 text-xs">
{bubble.text}
</div>
);
}
return (
<div className="rounded-2xl px-4 py-3 bg-black/55 backdrop-blur-sm text-white text-sm leading-snug shadow-xl border border-white/10 max-w-[540px]">
{bubble.text}
</div>
);
}
function GeneratingHint({ prompt }: { prompt: string }) {
return (
<div
role="status"
className="absolute top-20 left-1/2 -translate-x-1/2 z-10 bg-black/60 backdrop-blur-sm border border-white/15 rounded-full px-3 py-1.5 text-[11px] text-white/80 flex items-center gap-2 pointer-events-none"
>
<span className="w-1.5 h-1.5 rounded-full bg-[#3ea6ff] animate-pulse" aria-hidden />
Generating next scene Β· <span className="max-w-[160px] truncate">{prompt}</span>
</div>
);
}
function ChatInput({
value,
onChange,
onKeyDown,
onSend,
sending,
}: {
value: string;
onChange: (v: string) => void;
onKeyDown: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
onSend: () => void;
sending: boolean;
}) {
return (
<div className="absolute inset-x-0 bottom-0 z-20 px-4 pb-5 pt-3 bg-gradient-to-t from-black/85 to-transparent">
<div className="max-w-xl mx-auto flex gap-2">
<div className="relative flex-1">
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Ask anything…"
rows={1}
className="w-full bg-black/60 backdrop-blur-sm border border-white/15 rounded-full px-5 py-3 text-sm outline-none focus:border-[#3ea6ff] resize-none leading-tight"
disabled={sending}
/>
</div>
<PrimaryButton
onClick={onSend}
disabled={!value.trim()}
loading={sending}
aria-label="Send message"
icon={!sending ? <Send className="w-4 h-4" aria-hidden /> : undefined}
>
Send
</PrimaryButton>
</div>
</div>
);
}
// ────────────────────────────────────────────────────────────────
// Helpers
// ────────────────────────────────────────────────────────────────
function moodDescriptor(mood: string, affinity: number): string {
const tier =
affinity >= 0.75
? "close"
: affinity >= 0.5
? "warm"
: affinity >= 0.2
? "friendly"
: "stranger";
return `${tier} Β· mood ${mood}`;
}
function moodTint(mood: string): { mid: string } {
const map: Record<string, string> = {
neutral: "#162030",
shy: "#2a1c30",
flirty: "#3a1626",
playful: "#1e2a3a",
warm: "#3a2a1a",
cold: "#12202f",
};
return { mid: map[mood] || map.neutral };
}
function policyMessage(code: string, fallback: string): string {
if (code === "policy_blocked") {
return "That message can't be sent β€” try rephrasing.";
}
if (code === "consent_required") {
return "Mature content requires a consent check first.";
}
if (code === "region_blocked") {
return "This experience isn't available in your region.";
}
return fallback || "Message blocked by policy.";
}
export { StatusBadge };