@hyperframes/studio 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/assets/index-B1830ANq.js +78 -0
  2. package/dist/assets/index-KoBceNoU.css +1 -0
  3. package/dist/icons/timeline/audio.svg +7 -0
  4. package/dist/icons/timeline/captions.svg +5 -0
  5. package/dist/icons/timeline/composition.svg +12 -0
  6. package/dist/icons/timeline/image.svg +18 -0
  7. package/dist/icons/timeline/music.svg +10 -0
  8. package/dist/icons/timeline/text.svg +3 -0
  9. package/dist/index.html +13 -0
  10. package/package.json +50 -0
  11. package/src/App.tsx +557 -0
  12. package/src/components/editor/FileTree.tsx +70 -0
  13. package/src/components/editor/PropertyPanel.tsx +209 -0
  14. package/src/components/editor/SourceEditor.tsx +116 -0
  15. package/src/components/nle/CompositionBreadcrumb.tsx +57 -0
  16. package/src/components/nle/NLELayout.tsx +252 -0
  17. package/src/components/nle/NLEPreview.tsx +37 -0
  18. package/src/components/ui/Button.tsx +123 -0
  19. package/src/components/ui/index.ts +2 -0
  20. package/src/hooks/useCodeEditor.ts +82 -0
  21. package/src/hooks/useElementPicker.ts +338 -0
  22. package/src/hooks/useMountEffect.ts +18 -0
  23. package/src/icons/SystemIcons.tsx +130 -0
  24. package/src/index.ts +31 -0
  25. package/src/main.tsx +10 -0
  26. package/src/player/components/AgentActivityTrack.tsx +98 -0
  27. package/src/player/components/Player.tsx +120 -0
  28. package/src/player/components/PlayerControls.tsx +181 -0
  29. package/src/player/components/PreviewPanel.tsx +149 -0
  30. package/src/player/components/Timeline.tsx +431 -0
  31. package/src/player/hooks/useTimelinePlayer.ts +465 -0
  32. package/src/player/index.ts +17 -0
  33. package/src/player/lib/time.ts +5 -0
  34. package/src/player/lib/useMountEffect.ts +10 -0
  35. package/src/player/store/playerStore.ts +93 -0
  36. package/src/styles/studio.css +31 -0
  37. package/src/utils/sourcePatcher.ts +149 -0
package/src/index.ts ADDED
@@ -0,0 +1,31 @@
1
+ // NLE Layout
2
+ export { NLELayout } from "./components/nle/NLELayout";
3
+ export { NLEPreview } from "./components/nle/NLEPreview";
4
+ export { CompositionBreadcrumb } from "./components/nle/CompositionBreadcrumb";
5
+ export type { CompositionLevel } from "./components/nle/CompositionBreadcrumb";
6
+
7
+ // Player (preview, timeline, playback controls)
8
+ export {
9
+ Player,
10
+ PlayerControls,
11
+ Timeline,
12
+ PreviewPanel,
13
+ AgentActivityTrack,
14
+ useTimelinePlayer,
15
+ usePlayerStore,
16
+ liveTime,
17
+ formatTime,
18
+ } from "./player";
19
+ export type { AgentActivity, TimelineElement, ActiveEdits } from "./player";
20
+
21
+ // Editor
22
+ export { SourceEditor } from "./components/editor/SourceEditor";
23
+ export { PropertyPanel } from "./components/editor/PropertyPanel";
24
+ export { FileTree } from "./components/editor/FileTree";
25
+
26
+ // App
27
+ export { StudioApp } from "./App";
28
+
29
+ // Hooks
30
+ export { useCodeEditor } from "./hooks/useCodeEditor";
31
+ export { useElementPicker } from "./hooks/useElementPicker";
package/src/main.tsx ADDED
@@ -0,0 +1,10 @@
1
+ import { StrictMode } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { StudioApp } from "./App";
4
+ import "./styles/studio.css";
5
+
6
+ createRoot(document.getElementById("root")!).render(
7
+ <StrictMode>
8
+ <StudioApp />
9
+ </StrictMode>,
10
+ );
@@ -0,0 +1,98 @@
1
+ import { memo } from "react";
2
+
3
+ const TRACK_H = 20;
4
+ const GUTTER = 32;
5
+
6
+ export interface AgentActivity {
7
+ agentId: string;
8
+ name: string;
9
+ color: string;
10
+ /** Active work periods mapped to VIDEO time (not wall clock) */
11
+ periods: Array<{ start: number; end: number }>;
12
+ /** Element creation events at specific video times */
13
+ events: Array<{ time: number; type: "create" | "modify" }>;
14
+ }
15
+
16
+ interface AgentActivityTrackProps {
17
+ agents: AgentActivity[];
18
+ duration: number;
19
+ }
20
+
21
+ export const AgentActivityTrack = memo(function AgentActivityTrack({ agents, duration }: AgentActivityTrackProps) {
22
+ if (agents.length === 0 || duration <= 0) return null;
23
+
24
+ return (
25
+ <div className="border-t border-neutral-800/30">
26
+ {/* Section header */}
27
+ <div className="flex items-center gap-1.5 px-2 py-1 text-[9px] text-neutral-600 font-medium uppercase tracking-wider">
28
+ <svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="4" /></svg>
29
+ Agent Activity
30
+ </div>
31
+
32
+ {agents.map((agent) => (
33
+ <div
34
+ key={agent.agentId}
35
+ className="relative flex"
36
+ style={{ height: TRACK_H }}
37
+ >
38
+ {/* Gutter: agent name */}
39
+ <div
40
+ className="flex-shrink-0 flex items-center justify-center"
41
+ style={{ width: GUTTER }}
42
+ title={agent.name}
43
+ >
44
+ <div
45
+ className="w-2 h-2 rounded-full"
46
+ style={{ backgroundColor: agent.color }}
47
+ />
48
+ </div>
49
+
50
+ {/* Lane */}
51
+ <div className="flex-1 relative" style={{ backgroundColor: `${agent.color}06` }}>
52
+ {/* Active work periods */}
53
+ {agent.periods.map((period, i) => {
54
+ const leftPct = (period.start / duration) * 100;
55
+ const widthPct = ((period.end - period.start) / duration) * 100;
56
+ return (
57
+ <div
58
+ key={`period-${i}`}
59
+ className="absolute top-1 bottom-1 rounded-sm"
60
+ style={{
61
+ left: `${leftPct}%`,
62
+ width: `${Math.max(widthPct, 0.5)}%`,
63
+ backgroundColor: `${agent.color}30`,
64
+ border: `1px solid ${agent.color}20`,
65
+ }}
66
+ />
67
+ );
68
+ })}
69
+
70
+ {/* Events: diamonds for create, circles for modify */}
71
+ {agent.events.map((event, i) => {
72
+ const leftPct = (event.time / duration) * 100;
73
+ return (
74
+ <div
75
+ key={`event-${i}`}
76
+ className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2"
77
+ style={{ left: `${leftPct}%` }}
78
+ >
79
+ {event.type === "create" ? (
80
+ <div
81
+ className="w-2 h-2 rotate-45"
82
+ style={{ backgroundColor: agent.color }}
83
+ />
84
+ ) : (
85
+ <div
86
+ className="w-1.5 h-1.5 rounded-full"
87
+ style={{ backgroundColor: agent.color }}
88
+ />
89
+ )}
90
+ </div>
91
+ );
92
+ })}
93
+ </div>
94
+ </div>
95
+ ))}
96
+ </div>
97
+ );
98
+ });
@@ -0,0 +1,120 @@
1
+ import { forwardRef, useRef, useState, useCallback } from "react";
2
+ import { useMountEffect } from "../lib/useMountEffect";
3
+
4
+ const NATIVE_W = 1920;
5
+ const NATIVE_H = 1080;
6
+
7
+ interface PlayerProps {
8
+ projectId?: string;
9
+ directUrl?: string;
10
+ onLoad: () => void;
11
+ portrait?: boolean;
12
+ }
13
+
14
+ export const Player = forwardRef<HTMLIFrameElement, PlayerProps>(({ projectId, directUrl, onLoad, portrait }, ref) => {
15
+ const containerRef = useRef<HTMLDivElement>(null);
16
+ const [scale, setScale] = useState(1);
17
+ const dimsRef = useRef({ w: portrait ? NATIVE_H : NATIVE_W, h: portrait ? NATIVE_W : NATIVE_H });
18
+ const [dims, setDims] = useState(dimsRef.current);
19
+ const loadCountRef = useRef(0);
20
+
21
+ const updateScale = useCallback(() => {
22
+ const el = containerRef.current;
23
+ if (!el) return;
24
+ const rect = el.getBoundingClientRect();
25
+ const d = dimsRef.current;
26
+ setScale(Math.min(rect.width / d.w, rect.height / d.h));
27
+ }, []);
28
+
29
+ useMountEffect(() => {
30
+ updateScale();
31
+ const ro = new ResizeObserver(updateScale);
32
+ if (containerRef.current) ro.observe(containerRef.current);
33
+
34
+ // Listen for stage-size messages from the runtime
35
+ const handleMessage = (e: MessageEvent) => {
36
+ const data = e.data;
37
+ if ((data?.source === "hf-preview" || data?.source === "hf-preview") && data?.type === "stage-size" && data.width > 0 && data.height > 0) {
38
+ if (dimsRef.current.w !== data.width || dimsRef.current.h !== data.height) {
39
+ dimsRef.current = { w: data.width, h: data.height };
40
+ setDims(dimsRef.current);
41
+ updateScale();
42
+ }
43
+ }
44
+ };
45
+ window.addEventListener("message", handleMessage);
46
+
47
+ return () => {
48
+ ro.disconnect();
49
+ window.removeEventListener("message", handleMessage);
50
+ };
51
+ });
52
+
53
+ const handleLoad = useCallback(() => {
54
+ loadCountRef.current++;
55
+
56
+ // Auto-detect dimensions from the composition's data-width/data-height
57
+ try {
58
+ const iframeEl = typeof ref === "function" ? null : ref?.current;
59
+ const doc = iframeEl?.contentDocument;
60
+ if (doc) {
61
+ const root = doc.querySelector("[data-composition-id]");
62
+ if (root) {
63
+ const dw = parseInt(root.getAttribute("data-width") || "0", 10);
64
+ const dh = parseInt(root.getAttribute("data-height") || "0", 10);
65
+ if (dw > 0 && dh > 0 && (dw !== dimsRef.current.w || dh !== dimsRef.current.h)) {
66
+ dimsRef.current = { w: dw, h: dh };
67
+ setDims(dimsRef.current);
68
+ // Recalc scale with new dims
69
+ const el = containerRef.current;
70
+ if (el) {
71
+ const rect = el.getBoundingClientRect();
72
+ setScale(Math.min(rect.width / dw, rect.height / dh));
73
+ }
74
+ }
75
+ }
76
+ }
77
+ } catch {
78
+ // Cross-origin
79
+ }
80
+
81
+ if (loadCountRef.current > 1) {
82
+ const el = containerRef.current;
83
+ if (el) {
84
+ el.classList.remove("preview-revealing");
85
+ void el.offsetWidth;
86
+ el.classList.add("preview-revealing");
87
+ const onEnd = () => el.classList.remove("preview-revealing");
88
+ el.addEventListener("animationend", onEnd, { once: true });
89
+ }
90
+ }
91
+ onLoad();
92
+ }, [onLoad, ref]);
93
+
94
+ return (
95
+ <div
96
+ ref={containerRef}
97
+ className="w-full h-full max-w-full max-h-full overflow-hidden shadow-float border border-neutral-800 bg-black flex items-center justify-center rounded-card-inner"
98
+ >
99
+ <iframe
100
+ ref={ref}
101
+ src={directUrl || `/api/projects/${projectId}/preview`}
102
+ onLoad={handleLoad}
103
+ sandbox="allow-scripts allow-same-origin"
104
+ allow="autoplay; fullscreen"
105
+ referrerPolicy="no-referrer"
106
+ title="Project Preview"
107
+ style={{
108
+ width: dims.w,
109
+ height: dims.h,
110
+ border: "none",
111
+ transform: `scale(${scale})`,
112
+ transformOrigin: "center center",
113
+ flexShrink: 0,
114
+ }}
115
+ />
116
+ </div>
117
+ );
118
+ });
119
+
120
+ Player.displayName = "Player";
@@ -0,0 +1,181 @@
1
+ import { useRef, useState, useCallback, memo } from "react";
2
+ import { formatTime } from "../lib/time";
3
+ import { usePlayerStore, liveTime } from "../store/playerStore";
4
+ import { useMountEffect } from "../lib/useMountEffect";
5
+
6
+ const SPEED_OPTIONS = [0.25, 0.5, 1, 1.5, 2] as const;
7
+
8
+ interface PlayerControlsProps {
9
+ /** @deprecated Pass via store — kept for backwards compat */
10
+ isPlaying?: boolean;
11
+ /** @deprecated Pass via store — kept for backwards compat */
12
+ duration?: number;
13
+ /** @deprecated Pass via store — kept for backwards compat */
14
+ timelineReady?: boolean;
15
+ onTogglePlay: () => void;
16
+ onSeek: (time: number) => void;
17
+ }
18
+
19
+ export const PlayerControls = memo(function PlayerControls({ onTogglePlay, onSeek, ...overrides }: PlayerControlsProps) {
20
+ // Subscribe to only the fields we render — each selector prevents cascading re-renders
21
+ const storeIsPlaying = usePlayerStore((s) => s.isPlaying);
22
+ const storeDuration = usePlayerStore((s) => s.duration);
23
+ const storeTimelineReady = usePlayerStore((s) => s.timelineReady);
24
+ const playbackRate = usePlayerStore((s) => s.playbackRate);
25
+ const setPlaybackRate = usePlayerStore.getState().setPlaybackRate;
26
+ const [showSpeedMenu, setShowSpeedMenu] = useState(false);
27
+
28
+ const isPlaying = overrides.isPlaying ?? storeIsPlaying;
29
+ const duration = overrides.duration ?? storeDuration;
30
+ const timelineReady = overrides.timelineReady ?? storeTimelineReady;
31
+
32
+ const progressFillRef = useRef<HTMLDivElement>(null);
33
+ const progressThumbRef = useRef<HTMLDivElement>(null);
34
+ const timeDisplayRef = useRef<HTMLSpanElement>(null);
35
+ const seekBarRef = useRef<HTMLDivElement>(null);
36
+ const isDraggingRef = useRef(false);
37
+ const currentTimeRef = useRef(0);
38
+
39
+ const durationRef = useRef(duration);
40
+ durationRef.current = duration;
41
+ useMountEffect(() => {
42
+ const unsub = liveTime.subscribe((t) => {
43
+ currentTimeRef.current = t;
44
+ const dur = durationRef.current;
45
+ const pct = dur > 0 ? (t / dur) * 100 : 0;
46
+ if (progressFillRef.current) progressFillRef.current.style.width = `${pct}%`;
47
+ if (progressThumbRef.current) progressThumbRef.current.style.left = `${pct}%`;
48
+ if (timeDisplayRef.current) timeDisplayRef.current.textContent = formatTime(t);
49
+ });
50
+ return unsub;
51
+ });
52
+
53
+ const seekFromClientX = useCallback(
54
+ (clientX: number) => {
55
+ const bar = seekBarRef.current;
56
+ if (!bar || duration <= 0) return;
57
+ const rect = bar.getBoundingClientRect();
58
+ const percent = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
59
+ onSeek(percent * duration);
60
+ },
61
+ [duration, onSeek],
62
+ );
63
+
64
+ const handleMouseDown = useCallback(
65
+ (e: React.MouseEvent) => {
66
+ e.preventDefault();
67
+ isDraggingRef.current = true;
68
+ seekFromClientX(e.clientX);
69
+
70
+ const onMouseMove = (me: MouseEvent) => {
71
+ if (isDraggingRef.current) seekFromClientX(me.clientX);
72
+ };
73
+ const onMouseUp = () => {
74
+ isDraggingRef.current = false;
75
+ window.removeEventListener("mousemove", onMouseMove);
76
+ window.removeEventListener("mouseup", onMouseUp);
77
+ };
78
+
79
+ window.addEventListener("mousemove", onMouseMove);
80
+ window.addEventListener("mouseup", onMouseUp);
81
+ },
82
+ [seekFromClientX],
83
+ );
84
+
85
+ const handleKeyDown = useCallback(
86
+ (e: React.KeyboardEvent) => {
87
+ if (!timelineReady || duration <= 0) return;
88
+ const step = e.shiftKey ? 5 : 1;
89
+ if (e.key === "ArrowLeft") {
90
+ e.preventDefault();
91
+ onSeek(Math.max(0, currentTimeRef.current - step));
92
+ } else if (e.key === "ArrowRight") {
93
+ e.preventDefault();
94
+ onSeek(Math.min(duration, currentTimeRef.current + step));
95
+ }
96
+ },
97
+ [timelineReady, duration, onSeek],
98
+ );
99
+
100
+ return (
101
+ <div className="px-3 py-2 flex items-center gap-3">
102
+ <button
103
+ type="button"
104
+ aria-label={isPlaying ? "Pause" : "Play"}
105
+ onClick={onTogglePlay}
106
+ disabled={!timelineReady}
107
+ className="flex-shrink-0 w-7 h-7 flex items-center justify-center rounded-md text-neutral-300 hover:text-white hover:bg-neutral-800 disabled:opacity-40 disabled:pointer-events-none transition-colors"
108
+ >
109
+ {isPlaying ? (
110
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
111
+ <rect x="6" y="4" width="4" height="16" rx="1" />
112
+ <rect x="14" y="4" width="4" height="16" rx="1" />
113
+ </svg>
114
+ ) : (
115
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
116
+ <path d="M8 5v14l11-7z" />
117
+ </svg>
118
+ )}
119
+ </button>
120
+
121
+ <span className="text-neutral-500 font-mono text-xs tabular-nums flex-shrink-0 min-w-[80px]">
122
+ <span ref={timeDisplayRef}>{formatTime(0)}</span>
123
+ <span className="text-neutral-700 mx-0.5">/</span>
124
+ <span className="text-neutral-600">{formatTime(duration)}</span>
125
+ </span>
126
+
127
+ <div
128
+ ref={seekBarRef}
129
+ role="slider"
130
+ tabIndex={0}
131
+ aria-label="Seek"
132
+ aria-valuemin={0}
133
+ aria-valuemax={Math.round(duration)}
134
+ aria-valuenow={0}
135
+ className="flex-1 h-6 flex items-center cursor-pointer group"
136
+ style={{ touchAction: "manipulation" }}
137
+ onMouseDown={handleMouseDown}
138
+ onKeyDown={handleKeyDown}
139
+ >
140
+ <div className="w-full h-[3px] bg-neutral-800 rounded-full relative">
141
+ <div
142
+ ref={progressFillRef}
143
+ className="absolute inset-y-0 left-0 bg-white/80 rounded-full"
144
+ style={{ width: 0 }}
145
+ />
146
+ <div
147
+ ref={progressThumbRef}
148
+ className="absolute top-1/2 w-2 h-2 bg-white rounded-full -translate-y-1/2 -translate-x-1/2 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity"
149
+ style={{ left: 0 }}
150
+ />
151
+ </div>
152
+ </div>
153
+
154
+ {/* Speed control */}
155
+ <div className="relative flex-shrink-0">
156
+ <button
157
+ type="button"
158
+ onClick={() => setShowSpeedMenu((v) => !v)}
159
+ className="px-1.5 py-0.5 rounded text-[11px] font-mono tabular-nums text-neutral-500 hover:text-neutral-200 hover:bg-neutral-800 transition-colors"
160
+ >
161
+ {playbackRate === 1 ? "1x" : `${playbackRate}x`}
162
+ </button>
163
+ {showSpeedMenu && (
164
+ <div className="absolute bottom-full right-0 mb-1 py-1 bg-neutral-900 border border-neutral-700 rounded-lg shadow-xl z-50 min-w-[60px]">
165
+ {SPEED_OPTIONS.map((rate) => (
166
+ <button
167
+ key={rate}
168
+ onClick={() => { setPlaybackRate(rate); setShowSpeedMenu(false); }}
169
+ className={`block w-full px-3 py-1 text-xs text-left font-mono tabular-nums transition-colors ${
170
+ rate === playbackRate ? "text-white bg-neutral-800" : "text-neutral-400 hover:text-white hover:bg-neutral-800"
171
+ }`}
172
+ >
173
+ {rate}x
174
+ </button>
175
+ ))}
176
+ </div>
177
+ )}
178
+ </div>
179
+ </div>
180
+ );
181
+ });
@@ -0,0 +1,149 @@
1
+ import type { ReactNode, Ref } from "react";
2
+ import { Player } from "./Player";
3
+ import { PlayerControls } from "./PlayerControls";
4
+ import { Timeline } from "./Timeline";
5
+
6
+ interface RenderStatus {
7
+ state: "idle" | "rendering" | "complete" | "error";
8
+ stage?: string;
9
+ progress?: number;
10
+ error?: string;
11
+ onRender?: () => void;
12
+ }
13
+
14
+ interface PreviewPanelProps {
15
+ projectId: string | null;
16
+ hasProject: boolean;
17
+ portrait: boolean;
18
+ iframeRef: Ref<HTMLIFrameElement>;
19
+ onIframeLoad: () => void;
20
+ onTogglePlay: () => void;
21
+ onSeek: (t: number) => void;
22
+ /** Optional render status — pass to show rendering progress/state */
23
+ renderStatus?: RenderStatus;
24
+ /** Optional slot for custom content below the timeline */
25
+ children?: ReactNode;
26
+ }
27
+
28
+ export function PreviewPanel({
29
+ projectId,
30
+ hasProject,
31
+ portrait,
32
+ iframeRef,
33
+ onIframeLoad,
34
+ onTogglePlay,
35
+ onSeek,
36
+ renderStatus,
37
+ children,
38
+ }: PreviewPanelProps) {
39
+
40
+ const renderState = renderStatus?.state ?? "idle";
41
+
42
+ return (
43
+ <div
44
+ className="min-w-0 overflow-hidden"
45
+ style={{
46
+ display: "grid",
47
+ gridTemplateRows: hasProject && projectId ? "1fr auto auto auto" : "1fr",
48
+ height: "100%",
49
+ minHeight: 0,
50
+ }}
51
+ >
52
+ {hasProject && projectId ? (
53
+ <>
54
+ {/* Player — takes all remaining space, constrained for portrait */}
55
+ <div className="flex items-center justify-center p-2 overflow-hidden" style={{ minHeight: 0, minWidth: 0 }}>
56
+ <Player ref={iframeRef} projectId={projectId} onLoad={onIframeLoad} portrait={portrait} />
57
+ </div>
58
+
59
+ {/* Controls — fixed height */}
60
+ <div className="bg-neutral-950 border-t border-neutral-800 flex-shrink-0">
61
+ <PlayerControls
62
+ onTogglePlay={onTogglePlay}
63
+ onSeek={onSeek}
64
+ />
65
+ </div>
66
+
67
+ {/* Timeline — capped height, internal scroll */}
68
+ <div className="bg-neutral-950 flex-shrink-0 overflow-y-auto" style={{ maxHeight: "100px" }}>
69
+ <Timeline onSeek={onSeek} />
70
+ </div>
71
+
72
+ {/* Render status — only shown when actively rendering, complete, or error */}
73
+ {renderStatus && (renderState === "rendering" || renderState === "complete" || renderState === "error") && (
74
+ <div className="bg-neutral-950 border-t border-neutral-800 px-4 py-2 flex items-center justify-end gap-2 flex-shrink-0">
75
+ {renderState === "rendering" && (
76
+ <div className="flex-1">
77
+ <div className="flex items-center gap-2">
78
+ <div className="flex-1 h-1.5 bg-neutral-800 rounded-full overflow-hidden">
79
+ <div
80
+ className="h-full bg-blue-500 rounded-full transition-[width] duration-200"
81
+ style={{ width: `${renderStatus.progress ?? 0}%` }}
82
+ />
83
+ </div>
84
+ <span className="text-xs text-neutral-400 flex-shrink-0">
85
+ {renderStatus.stage || "Rendering..."}
86
+ </span>
87
+ </div>
88
+ </div>
89
+ )}
90
+ {renderState === "complete" && (
91
+ <div className="flex items-center gap-1.5 text-xs text-green-400">
92
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
93
+ <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
94
+ <polyline points="22 4 12 14.01 9 11.01" />
95
+ </svg>
96
+ <span>Complete</span>
97
+ </div>
98
+ )}
99
+ {renderState === "error" && (
100
+ <div className="flex items-center gap-2 text-xs text-red-400">
101
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
102
+ <circle cx="12" cy="12" r="10" />
103
+ <line x1="12" y1="8" x2="12" y2="12" />
104
+ <line x1="12" y1="16" x2="12.01" y2="16" />
105
+ </svg>
106
+ <span className="truncate">{renderStatus.error}</span>
107
+ {renderStatus.onRender && (
108
+ <button
109
+ type="button"
110
+ onClick={renderStatus.onRender}
111
+ className="flex-shrink-0 px-2 py-0.5 text-xs text-neutral-300 hover:text-white hover:bg-neutral-800 rounded transition-colors"
112
+ >
113
+ Retry
114
+ </button>
115
+ )}
116
+ </div>
117
+ )}
118
+ </div>
119
+ )}
120
+
121
+ {/* Optional custom slot */}
122
+ {children}
123
+ </>
124
+ ) : (
125
+ <div className="flex items-center justify-center w-full min-w-0">
126
+ <div className="text-center w-full">
127
+ <div className="w-16 h-16 mx-auto mb-4 rounded-card bg-neutral-900 flex items-center justify-center">
128
+ <svg
129
+ width="24"
130
+ height="24"
131
+ viewBox="0 0 24 24"
132
+ fill="none"
133
+ stroke="currentColor"
134
+ strokeWidth="1.5"
135
+ strokeLinecap="round"
136
+ strokeLinejoin="round"
137
+ className="text-neutral-600"
138
+ >
139
+ <polygon points="5 3 19 12 5 21 5 3" />
140
+ </svg>
141
+ </div>
142
+ <p className="text-sm text-neutral-600">Preview will appear here</p>
143
+ <p className="text-xs text-neutral-700 mt-1">Send a message to generate a video composition</p>
144
+ </div>
145
+ </div>
146
+ )}
147
+ </div>
148
+ );
149
+ }