@lumencast/runtime 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.
- package/LICENSE +201 -0
- package/README.md +79 -0
- package/dist/.tsbuildinfo +1 -0
- package/dist/animate/crossfade.d.ts +13 -0
- package/dist/animate/crossfade.d.ts.map +1 -0
- package/dist/animate/crossfade.js +10 -0
- package/dist/animate/crossfade.js.map +1 -0
- package/dist/animate/keyframes.d.ts +42 -0
- package/dist/animate/keyframes.d.ts.map +1 -0
- package/dist/animate/keyframes.js +94 -0
- package/dist/animate/keyframes.js.map +1 -0
- package/dist/animate/transitions.d.ts +38 -0
- package/dist/animate/transitions.d.ts.map +1 -0
- package/dist/animate/transitions.js +81 -0
- package/dist/animate/transitions.js.map +1 -0
- package/dist/app.d.ts +16 -0
- package/dist/app.d.ts.map +1 -0
- package/dist/app.js +35 -0
- package/dist/app.js.map +1 -0
- package/dist/broadcast-BqOhSNsY.js +11 -0
- package/dist/broadcast-BqOhSNsY.js.map +1 -0
- package/dist/control-CRFn328D.js +16 -0
- package/dist/control-CRFn328D.js.map +1 -0
- package/dist/dev-entry.d.ts +2 -0
- package/dist/dev-entry.d.ts.map +1 -0
- package/dist/dev-entry.js +31 -0
- package/dist/dev-entry.js.map +1 -0
- package/dist/index-DUhPPRvw.js +583 -0
- package/dist/index-DUhPPRvw.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.html +46 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/internal/validate-options.d.ts +5 -0
- package/dist/internal/validate-options.d.ts.map +1 -0
- package/dist/internal/validate-options.js +19 -0
- package/dist/internal/validate-options.js.map +1 -0
- package/dist/lumencast.js +5 -0
- package/dist/lumencast.js.map +1 -0
- package/dist/modes/broadcast.d.ts +3 -0
- package/dist/modes/broadcast.d.ts.map +1 -0
- package/dist/modes/broadcast.js +9 -0
- package/dist/modes/broadcast.js.map +1 -0
- package/dist/modes/control.d.ts +4 -0
- package/dist/modes/control.d.ts.map +1 -0
- package/dist/modes/control.js +12 -0
- package/dist/modes/control.js.map +1 -0
- package/dist/modes/test.d.ts +4 -0
- package/dist/modes/test.d.ts.map +1 -0
- package/dist/modes/test.js +13 -0
- package/dist/modes/test.js.map +1 -0
- package/dist/mount.d.ts +3 -0
- package/dist/mount.d.ts.map +1 -0
- package/dist/mount.js +144 -0
- package/dist/mount.js.map +1 -0
- package/dist/overlay/control.d.ts +2 -0
- package/dist/overlay/control.d.ts.map +1 -0
- package/dist/overlay/control.js +127 -0
- package/dist/overlay/control.js.map +1 -0
- package/dist/overlay/runtime-context.d.ts +20 -0
- package/dist/overlay/runtime-context.d.ts.map +1 -0
- package/dist/overlay/runtime-context.js +14 -0
- package/dist/overlay/runtime-context.js.map +1 -0
- package/dist/overlay/status-pill.d.ts +2 -0
- package/dist/overlay/status-pill.d.ts.map +1 -0
- package/dist/overlay/status-pill.js +29 -0
- package/dist/overlay/status-pill.js.map +1 -0
- package/dist/overlay/test.d.ts +5 -0
- package/dist/overlay/test.d.ts.map +1 -0
- package/dist/overlay/test.js +116 -0
- package/dist/overlay/test.js.map +1 -0
- package/dist/render/bundle.d.ts +102 -0
- package/dist/render/bundle.d.ts.map +1 -0
- package/dist/render/bundle.js +86 -0
- package/dist/render/bundle.js.map +1 -0
- package/dist/render/fill.d.ts +41 -0
- package/dist/render/fill.d.ts.map +1 -0
- package/dist/render/fill.js +95 -0
- package/dist/render/fill.js.map +1 -0
- package/dist/render/keyframe-player.d.ts +10 -0
- package/dist/render/keyframe-player.d.ts.map +1 -0
- package/dist/render/keyframe-player.js +65 -0
- package/dist/render/keyframe-player.js.map +1 -0
- package/dist/render/primitives/frame.d.ts +12 -0
- package/dist/render/primitives/frame.d.ts.map +1 -0
- package/dist/render/primitives/frame.js +65 -0
- package/dist/render/primitives/frame.js.map +1 -0
- package/dist/render/primitives/grid.d.ts +4 -0
- package/dist/render/primitives/grid.d.ts.map +1 -0
- package/dist/render/primitives/grid.js +14 -0
- package/dist/render/primitives/grid.js.map +1 -0
- package/dist/render/primitives/image.d.ts +5 -0
- package/dist/render/primitives/image.d.ts.map +1 -0
- package/dist/render/primitives/image.js +25 -0
- package/dist/render/primitives/image.js.map +1 -0
- package/dist/render/primitives/index.d.ts +10 -0
- package/dist/render/primitives/index.d.ts.map +1 -0
- package/dist/render/primitives/index.js +22 -0
- package/dist/render/primitives/index.js.map +1 -0
- package/dist/render/primitives/instance.d.ts +4 -0
- package/dist/render/primitives/instance.d.ts.map +1 -0
- package/dist/render/primitives/instance.js +35 -0
- package/dist/render/primitives/instance.js.map +1 -0
- package/dist/render/primitives/media.d.ts +6 -0
- package/dist/render/primitives/media.d.ts.map +1 -0
- package/dist/render/primitives/media.js +19 -0
- package/dist/render/primitives/media.js.map +1 -0
- package/dist/render/primitives/shape.d.ts +12 -0
- package/dist/render/primitives/shape.d.ts.map +1 -0
- package/dist/render/primitives/shape.js +66 -0
- package/dist/render/primitives/shape.js.map +1 -0
- package/dist/render/primitives/stack.d.ts +13 -0
- package/dist/render/primitives/stack.d.ts.map +1 -0
- package/dist/render/primitives/stack.js +45 -0
- package/dist/render/primitives/stack.js.map +1 -0
- package/dist/render/primitives/text.d.ts +6 -0
- package/dist/render/primitives/text.d.ts.map +1 -0
- package/dist/render/primitives/text.js +27 -0
- package/dist/render/primitives/text.js.map +1 -0
- package/dist/render/scope.d.ts +10 -0
- package/dist/render/scope.d.ts.map +1 -0
- package/dist/render/scope.js +27 -0
- package/dist/render/scope.js.map +1 -0
- package/dist/render/stagger-context.d.ts +9 -0
- package/dist/render/stagger-context.d.ts.map +1 -0
- package/dist/render/stagger-context.js +22 -0
- package/dist/render/stagger-context.js.map +1 -0
- package/dist/render/tree.d.ts +9 -0
- package/dist/render/tree.d.ts.map +1 -0
- package/dist/render/tree.js +139 -0
- package/dist/render/tree.js.map +1 -0
- package/dist/render/universal-wrapper.d.ts +16 -0
- package/dist/render/universal-wrapper.d.ts.map +1 -0
- package/dist/render/universal-wrapper.js +58 -0
- package/dist/render/universal-wrapper.js.map +1 -0
- package/dist/state/apply-delta.d.ts +11 -0
- package/dist/state/apply-delta.d.ts.map +1 -0
- package/dist/state/apply-delta.js +23 -0
- package/dist/state/apply-delta.js.map +1 -0
- package/dist/state/apply-snapshot.d.ts +6 -0
- package/dist/state/apply-snapshot.d.ts.map +1 -0
- package/dist/state/apply-snapshot.js +6 -0
- package/dist/state/apply-snapshot.js.map +1 -0
- package/dist/state/store.d.ts +28 -0
- package/dist/state/store.d.ts.map +1 -0
- package/dist/state/store.js +119 -0
- package/dist/state/store.js.map +1 -0
- package/dist/status-pill-DCHvrd_y.js +241 -0
- package/dist/status-pill-DCHvrd_y.js.map +1 -0
- package/dist/test-DBCtwx_I.js +210 -0
- package/dist/test-DBCtwx_I.js.map +1 -0
- package/dist/transport/reconnect.d.ts +22 -0
- package/dist/transport/reconnect.d.ts.map +1 -0
- package/dist/transport/reconnect.js +60 -0
- package/dist/transport/reconnect.js.map +1 -0
- package/dist/transport/ws.d.ts +66 -0
- package/dist/transport/ws.d.ts.map +1 -0
- package/dist/transport/ws.js +270 -0
- package/dist/transport/ws.js.map +1 -0
- package/dist/tree-CnhX02kd.js +494 -0
- package/dist/tree-CnhX02kd.js.map +1 -0
- package/dist/types.d.ts +38 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/package.json +64 -0
- package/src/animate/crossfade.tsx +31 -0
- package/src/animate/keyframes.ts +142 -0
- package/src/animate/transitions.ts +116 -0
- package/src/app.tsx +84 -0
- package/src/dev-entry.tsx +38 -0
- package/src/index.ts +24 -0
- package/src/internal/validate-options.ts +20 -0
- package/src/modes/broadcast.tsx +8 -0
- package/src/modes/control.tsx +17 -0
- package/src/modes/test.tsx +19 -0
- package/src/mount.ts +169 -0
- package/src/overlay/control.tsx +239 -0
- package/src/overlay/runtime-context.tsx +37 -0
- package/src/overlay/status-pill.tsx +37 -0
- package/src/overlay/test.tsx +213 -0
- package/src/render/bundle.ts +208 -0
- package/src/render/fill.tsx +163 -0
- package/src/render/keyframe-player.tsx +89 -0
- package/src/render/primitives/frame.tsx +78 -0
- package/src/render/primitives/grid.tsx +20 -0
- package/src/render/primitives/image.tsx +35 -0
- package/src/render/primitives/index.ts +35 -0
- package/src/render/primitives/instance.tsx +70 -0
- package/src/render/primitives/media.tsx +28 -0
- package/src/render/primitives/shape.tsx +135 -0
- package/src/render/primitives/stack.tsx +48 -0
- package/src/render/primitives/text.tsx +38 -0
- package/src/render/scope.tsx +27 -0
- package/src/render/stagger-context.tsx +24 -0
- package/src/render/tree.tsx +182 -0
- package/src/render/universal-wrapper.tsx +95 -0
- package/src/state/apply-delta.ts +24 -0
- package/src/state/apply-snapshot.ts +8 -0
- package/src/state/store.ts +141 -0
- package/src/transport/reconnect.ts +83 -0
- package/src/transport/ws.ts +359 -0
- package/src/types.ts +54 -0
package/src/mount.ts
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// Public mount() entry — the only surface a host (browser, CEF, OBS plugin,
|
|
2
|
+
// iframe) interacts with. Lifecycle and contract: see RUNTIME-API.md.
|
|
3
|
+
|
|
4
|
+
import { signal } from "@preact/signals-react";
|
|
5
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
6
|
+
import { createElement } from "react";
|
|
7
|
+
import { LumencastApp } from "./app.js";
|
|
8
|
+
import { applyDelta } from "./state/apply-delta.js";
|
|
9
|
+
import { applySnapshot } from "./state/apply-snapshot.js";
|
|
10
|
+
import { createStore } from "./state/store.js";
|
|
11
|
+
import { createBundleFetcher, type BundleFetcher, type RenderBundle } from "./render/bundle.js";
|
|
12
|
+
import { WsClient, type ConnectionStatus, type TransportError } from "./transport/ws.js";
|
|
13
|
+
import { validateOptions } from "./internal/validate-options.js";
|
|
14
|
+
import type { LumencastError, LumencastHandle, LumencastToken, MountOptions } from "./types.js";
|
|
15
|
+
|
|
16
|
+
export function mount(options: MountOptions): LumencastHandle {
|
|
17
|
+
validateOptions(options);
|
|
18
|
+
options.onStatus?.("disconnected");
|
|
19
|
+
|
|
20
|
+
const store = createStore();
|
|
21
|
+
const baseUrl = deriveBaseUrl(options.serverUrl);
|
|
22
|
+
const bundleFetcher = createBundleFetcher({ baseUrl });
|
|
23
|
+
|
|
24
|
+
const bundleSignal = signal<RenderBundle | null>(null);
|
|
25
|
+
const statusSignal = signal<ConnectionStatus>("disconnected");
|
|
26
|
+
const crossfadeKeySignal = signal<string>("__initial__");
|
|
27
|
+
|
|
28
|
+
const setStatus = (status: ConnectionStatus): void => {
|
|
29
|
+
statusSignal.value = status;
|
|
30
|
+
options.onStatus?.(status);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const reportError = (err: LumencastError): void => {
|
|
34
|
+
options.onError?.(err);
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
let active = true;
|
|
38
|
+
|
|
39
|
+
const ws = new WsClient({
|
|
40
|
+
url: options.serverUrl,
|
|
41
|
+
token: options.token,
|
|
42
|
+
...(options.scene !== undefined ? { scene: options.scene } : {}),
|
|
43
|
+
...(options.testSession !== undefined ? { session: options.testSession } : {}),
|
|
44
|
+
onStatus: setStatus,
|
|
45
|
+
onSnapshot: (frame) => {
|
|
46
|
+
if (!active) return;
|
|
47
|
+
void onSnapshot(
|
|
48
|
+
bundleFetcher,
|
|
49
|
+
bundleSignal,
|
|
50
|
+
crossfadeKeySignal,
|
|
51
|
+
frame.scene_id,
|
|
52
|
+
frame.scene_version,
|
|
53
|
+
() => applySnapshot(store, frame),
|
|
54
|
+
reportError,
|
|
55
|
+
);
|
|
56
|
+
options.onMetric?.({
|
|
57
|
+
name: "snapshot_received",
|
|
58
|
+
scene_id: frame.scene_id,
|
|
59
|
+
path_count: Object.keys(frame.state).length,
|
|
60
|
+
});
|
|
61
|
+
},
|
|
62
|
+
onDelta: (frame) => {
|
|
63
|
+
if (!active) return;
|
|
64
|
+
const start = performance.now();
|
|
65
|
+
applyDelta(store, frame);
|
|
66
|
+
options.onMetric?.({
|
|
67
|
+
name: "delta_applied",
|
|
68
|
+
duration_ms: performance.now() - start,
|
|
69
|
+
});
|
|
70
|
+
options.onMetric?.({ name: "delta_received", count: 1, path_count: frame.patches.length });
|
|
71
|
+
},
|
|
72
|
+
onSceneChanged: (frame) => {
|
|
73
|
+
if (!active) return;
|
|
74
|
+
// The fresh snapshot that follows is the source of truth — it carries
|
|
75
|
+
// the new scene_version, drives the bundle fetch, and flips the
|
|
76
|
+
// crossfade key. Nothing eager to do here.
|
|
77
|
+
options.onMetric?.({
|
|
78
|
+
name: "scene_changed",
|
|
79
|
+
from: bundleSignal.value?.scene_version ?? null,
|
|
80
|
+
to: frame.scene_version,
|
|
81
|
+
});
|
|
82
|
+
},
|
|
83
|
+
onServerError: (frame) => {
|
|
84
|
+
reportError({
|
|
85
|
+
code: frame.code,
|
|
86
|
+
message: frame.message,
|
|
87
|
+
recoverable: frame.recoverable,
|
|
88
|
+
});
|
|
89
|
+
},
|
|
90
|
+
onTransportError: (err) => {
|
|
91
|
+
reportError(transportToLumencastError(err));
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
ws.start();
|
|
96
|
+
|
|
97
|
+
const root: Root = createRoot(options.target);
|
|
98
|
+
root.render(
|
|
99
|
+
createElement(LumencastApp, {
|
|
100
|
+
mode: options.mode,
|
|
101
|
+
store,
|
|
102
|
+
bundleSignal,
|
|
103
|
+
statusSignal,
|
|
104
|
+
crossfadeKeySignal,
|
|
105
|
+
sendInput: (patches) => ws.sendInput(patches),
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
disconnect() {
|
|
111
|
+
if (!active) return;
|
|
112
|
+
active = false;
|
|
113
|
+
ws.close();
|
|
114
|
+
root.unmount();
|
|
115
|
+
},
|
|
116
|
+
setToken(token: LumencastToken) {
|
|
117
|
+
if (!active) return;
|
|
118
|
+
ws.setToken(token);
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// --- helpers ----------------------------------------------------------
|
|
123
|
+
|
|
124
|
+
async function onSnapshot(
|
|
125
|
+
fetcher: BundleFetcher,
|
|
126
|
+
bSignal: typeof bundleSignal,
|
|
127
|
+
cSignal: typeof crossfadeKeySignal,
|
|
128
|
+
sceneId: string,
|
|
129
|
+
sceneVersion: string,
|
|
130
|
+
applyState: () => void,
|
|
131
|
+
onErr: (err: LumencastError) => void,
|
|
132
|
+
): Promise<void> {
|
|
133
|
+
let bundle: RenderBundle;
|
|
134
|
+
try {
|
|
135
|
+
bundle = await fetcher.get(sceneId, sceneVersion);
|
|
136
|
+
} catch (err) {
|
|
137
|
+
onErr({
|
|
138
|
+
code: "BUNDLE_FETCH_FAILED",
|
|
139
|
+
message: err instanceof Error ? err.message : "render bundle fetch failed",
|
|
140
|
+
recoverable: true,
|
|
141
|
+
});
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
if (!active) return;
|
|
145
|
+
applyState();
|
|
146
|
+
bSignal.value = bundle;
|
|
147
|
+
cSignal.value = `${sceneId}::${sceneVersion}`;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function transportToLumencastError(err: TransportError): LumencastError {
|
|
152
|
+
return {
|
|
153
|
+
code: err.code,
|
|
154
|
+
message: err.message,
|
|
155
|
+
recoverable: err.recoverable,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function deriveBaseUrl(wsUrl: string): string {
|
|
160
|
+
// wss://<host>/lsdp/v1 → https://<host>
|
|
161
|
+
// ws://<host>/lsdp/v1 → http://<host>
|
|
162
|
+
try {
|
|
163
|
+
const u = new URL(wsUrl);
|
|
164
|
+
const httpScheme = u.protocol === "wss:" ? "https:" : "http:";
|
|
165
|
+
return `${httpScheme}//${u.host}`;
|
|
166
|
+
} catch {
|
|
167
|
+
return "";
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { useSignals } from "@preact/signals-react/runtime";
|
|
2
|
+
import type { OperatorInput } from "../render/bundle";
|
|
3
|
+
import { useLumencastRuntime } from "./runtime-context";
|
|
4
|
+
|
|
5
|
+
const PANEL_STYLE: React.CSSProperties = {
|
|
6
|
+
position: "fixed",
|
|
7
|
+
bottom: 12,
|
|
8
|
+
left: 12,
|
|
9
|
+
zIndex: 100_000,
|
|
10
|
+
width: 320,
|
|
11
|
+
maxHeight: "70vh",
|
|
12
|
+
overflowY: "auto",
|
|
13
|
+
padding: 12,
|
|
14
|
+
fontFamily: "system-ui, -apple-system, BlinkMacSystemFont, sans-serif",
|
|
15
|
+
fontSize: 12,
|
|
16
|
+
color: "#e5e7eb",
|
|
17
|
+
background: "rgba(17, 24, 39, 0.92)",
|
|
18
|
+
border: "1px solid rgba(75, 85, 99, 0.6)",
|
|
19
|
+
borderRadius: 10,
|
|
20
|
+
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.45)",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const ROW_STYLE: React.CSSProperties = {
|
|
24
|
+
display: "flex",
|
|
25
|
+
flexDirection: "column",
|
|
26
|
+
gap: 4,
|
|
27
|
+
padding: "6px 0",
|
|
28
|
+
borderBottom: "1px solid rgba(75, 85, 99, 0.35)",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const LABEL_STYLE: React.CSSProperties = {
|
|
32
|
+
color: "#9ca3af",
|
|
33
|
+
fontSize: 10.5,
|
|
34
|
+
letterSpacing: "0.02em",
|
|
35
|
+
textTransform: "uppercase",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const INPUT_STYLE: React.CSSProperties = {
|
|
39
|
+
background: "rgba(31, 41, 55, 0.8)",
|
|
40
|
+
border: "1px solid rgba(75, 85, 99, 0.6)",
|
|
41
|
+
borderRadius: 6,
|
|
42
|
+
color: "#f9fafb",
|
|
43
|
+
padding: "4px 6px",
|
|
44
|
+
fontSize: 12,
|
|
45
|
+
width: "100%",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export function ControlPanel() {
|
|
49
|
+
const { bundle, store, sendInput } = useLumencastRuntime();
|
|
50
|
+
useSignals();
|
|
51
|
+
|
|
52
|
+
const inputs = bundle.operator_inputs ?? [];
|
|
53
|
+
if (inputs.length === 0) return null;
|
|
54
|
+
|
|
55
|
+
// Group entries by `group` field for readability.
|
|
56
|
+
const groups = new Map<string, OperatorInput[]>();
|
|
57
|
+
for (const entry of inputs) {
|
|
58
|
+
const g = entry.group ?? "General";
|
|
59
|
+
const list = groups.get(g) ?? [];
|
|
60
|
+
list.push(entry);
|
|
61
|
+
groups.set(g, list);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div style={PANEL_STYLE} data-testid="lumencast-control-panel">
|
|
66
|
+
<div
|
|
67
|
+
style={{
|
|
68
|
+
fontWeight: 600,
|
|
69
|
+
fontSize: 11,
|
|
70
|
+
letterSpacing: "0.06em",
|
|
71
|
+
color: "#9ca3af",
|
|
72
|
+
textTransform: "uppercase",
|
|
73
|
+
marginBottom: 6,
|
|
74
|
+
}}
|
|
75
|
+
>
|
|
76
|
+
Operator inputs
|
|
77
|
+
</div>
|
|
78
|
+
{[...groups.entries()].map(([group, entries]) => (
|
|
79
|
+
<div key={group} style={{ marginBottom: 8 }}>
|
|
80
|
+
<div
|
|
81
|
+
style={{
|
|
82
|
+
color: "#6b7280",
|
|
83
|
+
fontSize: 10,
|
|
84
|
+
letterSpacing: "0.04em",
|
|
85
|
+
textTransform: "uppercase",
|
|
86
|
+
padding: "4px 0",
|
|
87
|
+
}}
|
|
88
|
+
>
|
|
89
|
+
{group}
|
|
90
|
+
</div>
|
|
91
|
+
{entries.map((entry) => (
|
|
92
|
+
<InputRow
|
|
93
|
+
key={entry.path}
|
|
94
|
+
entry={entry}
|
|
95
|
+
currentValue={store.signal(entry.path).value}
|
|
96
|
+
onCommit={(v) =>
|
|
97
|
+
// Operator-control values come from form widgets typed per
|
|
98
|
+
// OperatorInput.type; coerce to LeafValue at the boundary.
|
|
99
|
+
sendInput([
|
|
100
|
+
{
|
|
101
|
+
path: entry.path,
|
|
102
|
+
value: v as Parameters<typeof sendInput>[0][number]["value"],
|
|
103
|
+
},
|
|
104
|
+
])
|
|
105
|
+
}
|
|
106
|
+
/>
|
|
107
|
+
))}
|
|
108
|
+
</div>
|
|
109
|
+
))}
|
|
110
|
+
</div>
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function InputRow({
|
|
115
|
+
entry,
|
|
116
|
+
currentValue,
|
|
117
|
+
onCommit,
|
|
118
|
+
}: {
|
|
119
|
+
entry: OperatorInput;
|
|
120
|
+
currentValue: unknown;
|
|
121
|
+
onCommit: (value: unknown) => void;
|
|
122
|
+
}) {
|
|
123
|
+
return (
|
|
124
|
+
<div style={ROW_STYLE}>
|
|
125
|
+
<span style={LABEL_STYLE}>{entry.label}</span>
|
|
126
|
+
<Editor entry={entry} currentValue={currentValue} onCommit={onCommit} />
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function Editor({
|
|
132
|
+
entry,
|
|
133
|
+
currentValue,
|
|
134
|
+
onCommit,
|
|
135
|
+
}: {
|
|
136
|
+
entry: OperatorInput;
|
|
137
|
+
currentValue: unknown;
|
|
138
|
+
onCommit: (value: unknown) => void;
|
|
139
|
+
}) {
|
|
140
|
+
switch (entry.type) {
|
|
141
|
+
case "boolean": {
|
|
142
|
+
const checked = currentValue === true;
|
|
143
|
+
return (
|
|
144
|
+
<label style={{ display: "flex", alignItems: "center", gap: 6 }}>
|
|
145
|
+
<input type="checkbox" checked={checked} onChange={(e) => onCommit(e.target.checked)} />
|
|
146
|
+
<span style={{ fontSize: 11, color: "#d1d5db" }}>{checked ? "on" : "off"}</span>
|
|
147
|
+
</label>
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
case "number": {
|
|
151
|
+
const min = entry.min as number | undefined;
|
|
152
|
+
const max = entry.max as number | undefined;
|
|
153
|
+
const step = entry.step as number | undefined;
|
|
154
|
+
return (
|
|
155
|
+
<input
|
|
156
|
+
type="number"
|
|
157
|
+
style={INPUT_STYLE}
|
|
158
|
+
value={typeof currentValue === "number" ? currentValue : ""}
|
|
159
|
+
min={min}
|
|
160
|
+
max={max}
|
|
161
|
+
step={step}
|
|
162
|
+
onChange={(e) => {
|
|
163
|
+
const n = Number(e.target.value);
|
|
164
|
+
if (Number.isFinite(n)) onCommit(n);
|
|
165
|
+
}}
|
|
166
|
+
/>
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
case "text": {
|
|
170
|
+
const max = entry.max_length as number | undefined;
|
|
171
|
+
return (
|
|
172
|
+
<input
|
|
173
|
+
type="text"
|
|
174
|
+
style={INPUT_STYLE}
|
|
175
|
+
value={typeof currentValue === "string" ? currentValue : ""}
|
|
176
|
+
maxLength={max}
|
|
177
|
+
onChange={(e) => onCommit(e.target.value)}
|
|
178
|
+
/>
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
case "colour": {
|
|
182
|
+
return (
|
|
183
|
+
<input
|
|
184
|
+
type="color"
|
|
185
|
+
style={INPUT_STYLE}
|
|
186
|
+
value={typeof currentValue === "string" ? currentValue : "#000000"}
|
|
187
|
+
onChange={(e) => onCommit(e.target.value)}
|
|
188
|
+
/>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
case "duration": {
|
|
192
|
+
return (
|
|
193
|
+
<input
|
|
194
|
+
type="number"
|
|
195
|
+
style={INPUT_STYLE}
|
|
196
|
+
value={typeof currentValue === "number" ? currentValue : ""}
|
|
197
|
+
min={0}
|
|
198
|
+
step={100}
|
|
199
|
+
onChange={(e) => {
|
|
200
|
+
const n = Number(e.target.value);
|
|
201
|
+
if (Number.isFinite(n) && n >= 0) onCommit(n);
|
|
202
|
+
}}
|
|
203
|
+
/>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
case "select":
|
|
207
|
+
case "enum": {
|
|
208
|
+
const options =
|
|
209
|
+
(entry.enum_values as string[] | undefined) ??
|
|
210
|
+
(entry.options as string[] | undefined) ??
|
|
211
|
+
[];
|
|
212
|
+
return (
|
|
213
|
+
<select
|
|
214
|
+
style={INPUT_STYLE}
|
|
215
|
+
value={typeof currentValue === "string" ? currentValue : ""}
|
|
216
|
+
onChange={(e) => onCommit(e.target.value)}
|
|
217
|
+
>
|
|
218
|
+
{options.map((opt) => (
|
|
219
|
+
<option key={opt} value={opt}>
|
|
220
|
+
{opt}
|
|
221
|
+
</option>
|
|
222
|
+
))}
|
|
223
|
+
</select>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
case "path-ref":
|
|
227
|
+
default:
|
|
228
|
+
// FIXME (v2) — `path-ref` UX is deferred ; for now show a plain
|
|
229
|
+
// text entry so the value is still editable.
|
|
230
|
+
return (
|
|
231
|
+
<input
|
|
232
|
+
type="text"
|
|
233
|
+
style={INPUT_STYLE}
|
|
234
|
+
value={typeof currentValue === "string" ? currentValue : ""}
|
|
235
|
+
onChange={(e) => onCommit(e.target.value)}
|
|
236
|
+
/>
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { createContext, useContext, type ReactNode } from "react";
|
|
2
|
+
import type { Patch } from "@lumencast/protocol";
|
|
3
|
+
import type { Store } from "../state/store";
|
|
4
|
+
import type { RenderBundle } from "../render/bundle";
|
|
5
|
+
import type { ConnectionStatus } from "../transport/ws";
|
|
6
|
+
import type { LumencastMode } from "../types";
|
|
7
|
+
|
|
8
|
+
export interface LumencastRuntime {
|
|
9
|
+
mode: LumencastMode;
|
|
10
|
+
store: Store;
|
|
11
|
+
bundle: RenderBundle;
|
|
12
|
+
status: ConnectionStatus;
|
|
13
|
+
/** Send LSDP/1 input patches to the server. */
|
|
14
|
+
sendInput: (patches: Patch[]) => void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const Ctx = createContext<LumencastRuntime | null>(null);
|
|
18
|
+
|
|
19
|
+
export function LumencastRuntimeProvider({
|
|
20
|
+
value,
|
|
21
|
+
children,
|
|
22
|
+
}: {
|
|
23
|
+
value: LumencastRuntime;
|
|
24
|
+
children: ReactNode;
|
|
25
|
+
}) {
|
|
26
|
+
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function useLumencastRuntime(): LumencastRuntime {
|
|
30
|
+
const v = useContext(Ctx);
|
|
31
|
+
if (!v) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
"Lumencast overlay components must be rendered inside LumencastRuntimeProvider",
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
return v;
|
|
37
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { useLumencastRuntime } from "./runtime-context";
|
|
2
|
+
|
|
3
|
+
const COLOURS: Record<string, string> = {
|
|
4
|
+
live: "rgba(34, 197, 94, 0.85)",
|
|
5
|
+
connecting: "rgba(234, 179, 8, 0.85)",
|
|
6
|
+
disconnected: "rgba(239, 68, 68, 0.85)",
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
const LABELS: Record<string, string> = {
|
|
10
|
+
live: "live",
|
|
11
|
+
connecting: "reconnecting",
|
|
12
|
+
disconnected: "disconnected",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export function StatusPill() {
|
|
16
|
+
const { status } = useLumencastRuntime();
|
|
17
|
+
return (
|
|
18
|
+
<div
|
|
19
|
+
data-testid="lumencast-status-pill"
|
|
20
|
+
style={{
|
|
21
|
+
position: "fixed",
|
|
22
|
+
top: 12,
|
|
23
|
+
right: 12,
|
|
24
|
+
padding: "4px 10px",
|
|
25
|
+
fontSize: 11,
|
|
26
|
+
fontFamily: "system-ui, -apple-system, BlinkMacSystemFont, sans-serif",
|
|
27
|
+
color: "white",
|
|
28
|
+
background: COLOURS[status] ?? "#444",
|
|
29
|
+
borderRadius: 999,
|
|
30
|
+
userSelect: "none",
|
|
31
|
+
pointerEvents: "none",
|
|
32
|
+
}}
|
|
33
|
+
>
|
|
34
|
+
{LABELS[status] ?? status}
|
|
35
|
+
</div>
|
|
36
|
+
);
|
|
37
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { useSignals } from "@preact/signals-react/runtime";
|
|
2
|
+
import { useState } from "react";
|
|
3
|
+
import { useLumencastRuntime } from "./runtime-context";
|
|
4
|
+
|
|
5
|
+
const PANEL_STYLE: React.CSSProperties = {
|
|
6
|
+
position: "fixed",
|
|
7
|
+
bottom: 12,
|
|
8
|
+
right: 12,
|
|
9
|
+
zIndex: 100_001,
|
|
10
|
+
width: 360,
|
|
11
|
+
maxHeight: "70vh",
|
|
12
|
+
overflowY: "auto",
|
|
13
|
+
padding: 12,
|
|
14
|
+
fontFamily: "system-ui, -apple-system, BlinkMacSystemFont, sans-serif",
|
|
15
|
+
fontSize: 12,
|
|
16
|
+
color: "#e5e7eb",
|
|
17
|
+
background: "rgba(8, 47, 73, 0.92)",
|
|
18
|
+
border: "1px solid rgba(56, 189, 248, 0.4)",
|
|
19
|
+
borderRadius: 10,
|
|
20
|
+
boxShadow: "0 8px 32px rgba(0, 0, 0, 0.45)",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const SECTION_TITLE: React.CSSProperties = {
|
|
24
|
+
fontWeight: 600,
|
|
25
|
+
fontSize: 11,
|
|
26
|
+
letterSpacing: "0.06em",
|
|
27
|
+
color: "#7dd3fc",
|
|
28
|
+
textTransform: "uppercase",
|
|
29
|
+
marginBottom: 6,
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const BUTTON_STYLE: React.CSSProperties = {
|
|
33
|
+
background: "rgba(14, 165, 233, 0.4)",
|
|
34
|
+
border: "1px solid rgba(125, 211, 252, 0.5)",
|
|
35
|
+
borderRadius: 6,
|
|
36
|
+
color: "#f0f9ff",
|
|
37
|
+
padding: "3px 8px",
|
|
38
|
+
fontSize: 11,
|
|
39
|
+
cursor: "pointer",
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const ADAPTER_ROW: React.CSSProperties = {
|
|
43
|
+
display: "flex",
|
|
44
|
+
flexDirection: "column",
|
|
45
|
+
gap: 4,
|
|
46
|
+
padding: "6px 0",
|
|
47
|
+
borderBottom: "1px solid rgba(56, 189, 248, 0.2)",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** Test-mode overlay : adapter mocker + state inspector + time
|
|
51
|
+
* controls. Drives the server's __test.* family via the same `sendInput`
|
|
52
|
+
* channel. */
|
|
53
|
+
export function TestPanel() {
|
|
54
|
+
const { bundle, store, sendInput } = useLumencastRuntime();
|
|
55
|
+
useSignals();
|
|
56
|
+
const [filter, setFilter] = useState("");
|
|
57
|
+
|
|
58
|
+
const adapters = bundle.external_adapters ?? [];
|
|
59
|
+
const stateRecord = store.toRecord();
|
|
60
|
+
const filteredEntries = Object.entries(stateRecord).filter(
|
|
61
|
+
([k]) => filter === "" || k.includes(filter),
|
|
62
|
+
);
|
|
63
|
+
|
|
64
|
+
return (
|
|
65
|
+
<div style={PANEL_STYLE} data-testid="lumencast-test-panel">
|
|
66
|
+
{/* Time controls */}
|
|
67
|
+
<div style={SECTION_TITLE}>Time</div>
|
|
68
|
+
<div style={{ display: "flex", gap: 6, marginBottom: 8 }}>
|
|
69
|
+
<button
|
|
70
|
+
type="button"
|
|
71
|
+
style={BUTTON_STYLE}
|
|
72
|
+
onClick={() => sendInput([{ path: "__test.tick", value: 100 }])}
|
|
73
|
+
>
|
|
74
|
+
tick +100ms
|
|
75
|
+
</button>
|
|
76
|
+
<button
|
|
77
|
+
type="button"
|
|
78
|
+
style={BUTTON_STYLE}
|
|
79
|
+
onClick={() => sendInput([{ path: "__test.tick", value: 1_000 }])}
|
|
80
|
+
>
|
|
81
|
+
tick +1s
|
|
82
|
+
</button>
|
|
83
|
+
<button
|
|
84
|
+
type="button"
|
|
85
|
+
style={BUTTON_STYLE}
|
|
86
|
+
onClick={() => sendInput([{ path: "__test.reset", value: true }])}
|
|
87
|
+
>
|
|
88
|
+
reset
|
|
89
|
+
</button>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
{/* Adapter mocker */}
|
|
93
|
+
<div style={SECTION_TITLE}>External adapters</div>
|
|
94
|
+
{adapters.length === 0 && (
|
|
95
|
+
<div style={{ color: "#94a3b8", fontStyle: "italic", fontSize: 11 }}>
|
|
96
|
+
No external adapters declared in this scene.
|
|
97
|
+
</div>
|
|
98
|
+
)}
|
|
99
|
+
{adapters.map((adapter) => (
|
|
100
|
+
<AdapterRow
|
|
101
|
+
key={adapter.key}
|
|
102
|
+
adapter={adapter}
|
|
103
|
+
onMock={(payload) =>
|
|
104
|
+
// LSDP/1 patch values must be leaf — JSON-encode the structured payload.
|
|
105
|
+
sendInput([
|
|
106
|
+
{
|
|
107
|
+
path: "__test.mock_adapter",
|
|
108
|
+
value: JSON.stringify({ key: adapter.key, payload }),
|
|
109
|
+
},
|
|
110
|
+
])
|
|
111
|
+
}
|
|
112
|
+
/>
|
|
113
|
+
))}
|
|
114
|
+
|
|
115
|
+
{/* State inspector */}
|
|
116
|
+
<div style={{ ...SECTION_TITLE, marginTop: 12 }}>State</div>
|
|
117
|
+
<input
|
|
118
|
+
type="text"
|
|
119
|
+
placeholder="filter paths…"
|
|
120
|
+
value={filter}
|
|
121
|
+
onChange={(e) => setFilter(e.target.value)}
|
|
122
|
+
style={{
|
|
123
|
+
background: "rgba(8, 47, 73, 0.6)",
|
|
124
|
+
border: "1px solid rgba(125, 211, 252, 0.4)",
|
|
125
|
+
borderRadius: 6,
|
|
126
|
+
color: "#e0f2fe",
|
|
127
|
+
padding: "4px 6px",
|
|
128
|
+
fontSize: 11,
|
|
129
|
+
width: "100%",
|
|
130
|
+
marginBottom: 6,
|
|
131
|
+
}}
|
|
132
|
+
/>
|
|
133
|
+
<div style={{ fontFamily: "monospace", fontSize: 10.5 }}>
|
|
134
|
+
{filteredEntries.map(([path, value]) => (
|
|
135
|
+
<div
|
|
136
|
+
key={path}
|
|
137
|
+
style={{
|
|
138
|
+
display: "grid",
|
|
139
|
+
gridTemplateColumns: "1fr auto",
|
|
140
|
+
gap: 8,
|
|
141
|
+
padding: "2px 0",
|
|
142
|
+
borderBottom: "1px dashed rgba(125, 211, 252, 0.15)",
|
|
143
|
+
}}
|
|
144
|
+
>
|
|
145
|
+
<span style={{ color: "#bae6fd" }}>{path}</span>
|
|
146
|
+
<span style={{ color: "#fef3c7" }}>{formatValue(value)}</span>
|
|
147
|
+
</div>
|
|
148
|
+
))}
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function AdapterRow({
|
|
155
|
+
adapter,
|
|
156
|
+
onMock,
|
|
157
|
+
}: {
|
|
158
|
+
adapter: { key: string; label: string; kind: string };
|
|
159
|
+
onMock: (payload: unknown) => void;
|
|
160
|
+
}) {
|
|
161
|
+
const [draft, setDraft] = useState("{}");
|
|
162
|
+
return (
|
|
163
|
+
<div style={ADAPTER_ROW}>
|
|
164
|
+
<div
|
|
165
|
+
style={{
|
|
166
|
+
display: "flex",
|
|
167
|
+
justifyContent: "space-between",
|
|
168
|
+
alignItems: "center",
|
|
169
|
+
}}
|
|
170
|
+
>
|
|
171
|
+
<span style={{ color: "#e0f2fe" }}>{adapter.label}</span>
|
|
172
|
+
<span style={{ color: "#94a3b8", fontSize: 10 }}>{adapter.kind}</span>
|
|
173
|
+
</div>
|
|
174
|
+
<textarea
|
|
175
|
+
value={draft}
|
|
176
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
177
|
+
rows={2}
|
|
178
|
+
style={{
|
|
179
|
+
fontFamily: "monospace",
|
|
180
|
+
fontSize: 10.5,
|
|
181
|
+
background: "rgba(8, 47, 73, 0.6)",
|
|
182
|
+
color: "#e0f2fe",
|
|
183
|
+
border: "1px solid rgba(125, 211, 252, 0.3)",
|
|
184
|
+
borderRadius: 4,
|
|
185
|
+
padding: 4,
|
|
186
|
+
resize: "vertical",
|
|
187
|
+
}}
|
|
188
|
+
/>
|
|
189
|
+
<button
|
|
190
|
+
type="button"
|
|
191
|
+
style={BUTTON_STYLE}
|
|
192
|
+
onClick={() => {
|
|
193
|
+
try {
|
|
194
|
+
const parsed = JSON.parse(draft);
|
|
195
|
+
onMock(parsed);
|
|
196
|
+
} catch {
|
|
197
|
+
onMock(draft);
|
|
198
|
+
}
|
|
199
|
+
}}
|
|
200
|
+
>
|
|
201
|
+
fire
|
|
202
|
+
</button>
|
|
203
|
+
</div>
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function formatValue(value: unknown): string {
|
|
208
|
+
if (value === undefined) return "—";
|
|
209
|
+
if (value === null) return "null";
|
|
210
|
+
if (typeof value === "string") return JSON.stringify(value);
|
|
211
|
+
if (typeof value === "object") return JSON.stringify(value);
|
|
212
|
+
return String(value);
|
|
213
|
+
}
|