@lumencast/runtime 0.12.0 → 0.12.2

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 (32) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/{broadcast-ydSpPUje.js → broadcast-DrifeSRm.js} +3 -3
  3. package/dist/{broadcast-ydSpPUje.js.map → broadcast-DrifeSRm.js.map} +1 -1
  4. package/dist/{control-zTsF-bHP.js → control-CdGT0wrz.js} +4 -4
  5. package/dist/{control-zTsF-bHP.js.map → control-CdGT0wrz.js.map} +1 -1
  6. package/dist/{index-ClWi5UzJ.js → index-BH-3p9mt.js} +47 -38
  7. package/dist/index-BH-3p9mt.js.map +1 -0
  8. package/dist/index.html +1 -1
  9. package/dist/lumencast.js +1 -1
  10. package/dist/render/bundle.d.ts.map +1 -1
  11. package/dist/render/bundle.js +14 -0
  12. package/dist/render/bundle.js.map +1 -1
  13. package/dist/render/primitives/capture-stream-cache.d.ts +26 -0
  14. package/dist/render/primitives/capture-stream-cache.d.ts.map +1 -0
  15. package/dist/render/primitives/capture-stream-cache.js +141 -0
  16. package/dist/render/primitives/capture-stream-cache.js.map +1 -0
  17. package/dist/render/primitives/capture.d.ts +1 -1
  18. package/dist/render/primitives/capture.d.ts.map +1 -1
  19. package/dist/render/primitives/capture.js +20 -87
  20. package/dist/render/primitives/capture.js.map +1 -1
  21. package/dist/{status-pill-DkHIOL5V.js → status-pill-0rJyg4p3.js} +2 -2
  22. package/dist/{status-pill-DkHIOL5V.js.map → status-pill-0rJyg4p3.js.map} +1 -1
  23. package/dist/{test-COpMkyms.js → test-CYmNprVS.js} +4 -4
  24. package/dist/{test-COpMkyms.js.map → test-CYmNprVS.js.map} +1 -1
  25. package/dist/{tree-Cubmxeqo.js → tree-CyxbJbsP.js} +589 -567
  26. package/dist/tree-CyxbJbsP.js.map +1 -0
  27. package/package.json +4 -4
  28. package/src/render/bundle.ts +14 -0
  29. package/src/render/primitives/capture-stream-cache.ts +164 -0
  30. package/src/render/primitives/capture.tsx +19 -93
  31. package/dist/index-ClWi5UzJ.js.map +0 -1
  32. package/dist/tree-Cubmxeqo.js.map +0 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumencast/runtime",
3
- "version": "0.12.0",
3
+ "version": "0.12.2",
4
4
  "description": "Browser runtime for Lumencast — mount(), LSDP/1 transport, leaf-grain store, LSML render, animations, overlays.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -36,7 +36,7 @@
36
36
  "framer-motion": "^12.0.0",
37
37
  "react": "^19.0.0",
38
38
  "react-dom": "^19.0.0",
39
- "@lumencast/protocol": "0.12.0"
39
+ "@lumencast/protocol": "0.12.2"
40
40
  },
41
41
  "devDependencies": {
42
42
  "@playwright/test": "^1.49.1",
@@ -50,8 +50,8 @@
50
50
  "vite-plugin-dts": "^4.5.0",
51
51
  "vitest": "^4.1.5",
52
52
  "ws": "^8.18.0",
53
- "@lumencast/dev-server": "0.12.0",
54
- "@lumencast/server": "0.12.0"
53
+ "@lumencast/server": "0.12.2",
54
+ "@lumencast/dev-server": "0.12.2"
55
55
  },
56
56
  "scripts": {
57
57
  "dev": "vite",
@@ -287,6 +287,7 @@ export interface BundleFetcherOptions {
287
287
 
288
288
  class FetcherImpl implements BundleFetcher {
289
289
  private readonly cache = new Map<string, RenderBundle>();
290
+ private readonly inFlight = new Map<string, Promise<RenderBundle>>();
290
291
  private readonly baseUrl: string;
291
292
  private readonly pathPrefix: string;
292
293
  private readonly resolveUrl: BundleUrlResolver | undefined;
@@ -329,6 +330,19 @@ class FetcherImpl implements BundleFetcher {
329
330
  async get(sceneId: string, sceneVersion: string): Promise<RenderBundle> {
330
331
  const cached = this.cache.get(sceneVersion);
331
332
  if (cached) return cached;
333
+ // Bundles are content-addressed by scene_version, so a concurrent miss for
334
+ // the same version can share a single fetch. The entry is cleared in
335
+ // `finally` — a rejected fetch is never cached, so a later call retries.
336
+ const existing = this.inFlight.get(sceneVersion);
337
+ if (existing) return existing;
338
+ const promise = this.fetchBundle(sceneId, sceneVersion).finally(() => {
339
+ this.inFlight.delete(sceneVersion);
340
+ });
341
+ this.inFlight.set(sceneVersion, promise);
342
+ return promise;
343
+ }
344
+
345
+ private async fetchBundle(sceneId: string, sceneVersion: string): Promise<RenderBundle> {
332
346
  const url = this.buildUrl(sceneId, sceneVersion);
333
347
  const init = await this.buildInit();
334
348
  const response = init ? await this.fetchImpl(url, init) : await this.fetchImpl(url);
@@ -0,0 +1,164 @@
1
+ // Per-device stream cache for `x-zab.capture` ACQUIRE mode.
2
+ //
3
+ // Why this exists: a scene switch remounts the WHOLE render tree
4
+ // (`AnimatePresence` keyed on the scene id in app.tsx / crossfade.tsx), so every
5
+ // `Capture` node unmounts and a fresh one mounts. Without sharing, each switch
6
+ // stops the old node's tracks (RC11) and re-acquires the same physical device in
7
+ // the new node. A real USB webcam reopens near-instantly, but a synthetic
8
+ // DirectShow filter (OBS Virtual Camera) renegotiates slowly → a visible blink
9
+ // on every switch, because — by design (ADR 007 Prism) — every scene references
10
+ // the SAME shared vcam device.
11
+ //
12
+ // The fix mirrors the editor's `use-live-source.ts`: a ref-counted cache keyed by
13
+ // the resolved PHYSICAL device. Two `Capture` nodes on the same device share one
14
+ // `getUserMedia`; tracks stop (RC11) only when the LAST consumer releases. During
15
+ // a crossfade both the exiting and entering scenes are mounted at once
16
+ // (`AnimatePresence mode="sync"`), so the ref-count never reaches 0 across a
17
+ // switch and the device is never renegotiated — no blink.
18
+ //
19
+ // Cache key stability: the key is the resolved `deviceId` / `captureSourceId`, not
20
+ // the raw logical `deviceRef`. Physical ids are salted per origin/partition (see
21
+ // capture.tsx §A1.3), but the salt is constant WITHIN one runtime origin, so the
22
+ // resolved id is stable across scene switches inside a single mount/session — the
23
+ // exact scope over which sharing must hold. Keying on the physical id also
24
+ // correctly de-dupes two distinct `deviceRef`s that resolve to the same device.
25
+
26
+ import type { ResolveCaptureDevice } from "./capture";
27
+
28
+ interface CacheEntry {
29
+ promise: Promise<MediaStream>;
30
+ stream: MediaStream | null;
31
+ refs: number;
32
+ }
33
+
34
+ const cache = new Map<string, CacheEntry>();
35
+
36
+ /** Outcome of a claim: either a shared stream promise (with the key to release
37
+ * later) or PLACEHOLDER — the unknown-kind / declared-but-unresolved-ref path
38
+ * that acquires nothing, exactly as the old inline `acquireStream` returning
39
+ * `null` did. A PLACEHOLDER claim holds NO ref (nothing to release). */
40
+ export type CaptureClaim =
41
+ | { kind: "stream"; key: string; promise: Promise<MediaStream> }
42
+ | { kind: "placeholder" };
43
+
44
+ /** Resolve the device, then claim a shared stream for it (incrementing the
45
+ * ref-count), or return PLACEHOLDER. Awaits the resolver first — physical ids
46
+ * are salted per origin/partition, so the host may re-resolve a portable key
47
+ * (label) against THIS context (capture.tsx §A1.3). A throw from the underlying
48
+ * `getUserMedia` surfaces via the returned promise (caller → PLACEHOLDER). */
49
+ export async function claimCaptureStream(
50
+ sourceKind: string,
51
+ deviceRef: string,
52
+ resolveCaptureDevice: ResolveCaptureDevice | undefined,
53
+ ): Promise<CaptureClaim> {
54
+ const md = navigator.mediaDevices;
55
+ const resolved = (await resolveCaptureDevice?.(deviceRef, sourceKind)) ?? null;
56
+ const deviceId = resolved?.deviceId;
57
+ const captureSourceId = resolved?.captureSourceId;
58
+ const declaredRef = deviceRef.length > 0;
59
+
60
+ // Compute a stable physical key + a lazy acquisition thunk, or bail to
61
+ // PLACEHOLDER for the same reasons the inline switch used to return `null`.
62
+ let physicalId: string;
63
+ let acquire: () => Promise<MediaStream>;
64
+ switch (sourceKind) {
65
+ case "media.webcam":
66
+ case "media.mic":
67
+ case "media.app_audio": {
68
+ // §A1.3 (amended) — NO default-device fallback for a DECLARED deviceRef
69
+ // that did not resolve. Acquiring the host default here is the silent
70
+ // "automatic allocation" of the WRONG camera. → PLACEHOLDER. The bare
71
+ // default constraint stays ONLY when no deviceRef is declared at all.
72
+ if (declaredRef && (typeof deviceId !== "string" || deviceId.length === 0)) {
73
+ return { kind: "placeholder" };
74
+ }
75
+ const channel = sourceKind === "media.webcam" ? "video" : "audio";
76
+ physicalId = typeof deviceId === "string" && deviceId.length > 0 ? deviceId : "default";
77
+ acquire = () => md.getUserMedia({ [channel]: deviceConstraint(deviceId) });
78
+ break;
79
+ }
80
+ case "media.screen":
81
+ case "media.window": {
82
+ // DIRECT capture of the picked desktopCapturer surface (no system picker)
83
+ // via Electron's legacy `chromeMediaSource:desktop` + resolved id.
84
+ if (typeof captureSourceId === "string" && captureSourceId.length > 0) {
85
+ physicalId = captureSourceId;
86
+ acquire = () =>
87
+ md.getUserMedia({
88
+ video: {
89
+ mandatory: {
90
+ chromeMediaSource: "desktop",
91
+ chromeMediaSourceId: captureSourceId,
92
+ },
93
+ } as unknown as MediaTrackConstraints,
94
+ });
95
+ break;
96
+ }
97
+ // A declared surface ref that didn't resolve → PLACEHOLDER, never a
98
+ // default picker. The picker stays only when no ref is declared.
99
+ if (declaredRef) return { kind: "placeholder" };
100
+ physicalId = "display";
101
+ acquire = () => md.getDisplayMedia({ video: true });
102
+ break;
103
+ }
104
+ default:
105
+ return { kind: "placeholder" };
106
+ }
107
+
108
+ const key = `${sourceKind}:${physicalId}`;
109
+ const existing = cache.get(key);
110
+ if (existing) {
111
+ existing.refs += 1;
112
+ return { kind: "stream", key, promise: existing.promise };
113
+ }
114
+ const promise = acquire();
115
+ const entry: CacheEntry = { promise, stream: null, refs: 1 };
116
+ cache.set(key, entry);
117
+ promise
118
+ .then((s) => {
119
+ entry.stream = s;
120
+ })
121
+ .catch(() => {
122
+ // Acquisition failed — evict so a later mount retries instead of sharing a
123
+ // rejected promise. Consumers of this promise all fall back to PLACEHOLDER.
124
+ cache.delete(key);
125
+ });
126
+ return { kind: "stream", key, promise };
127
+ }
128
+
129
+ /** Drop one consumer's claim. Stops every track (RC11 — kill the device light)
130
+ * ONLY when the last consumer releases. A no-op for an already-evicted key
131
+ * (e.g. an acquisition that rejected and self-evicted). */
132
+ export function releaseCaptureStream(key: string): void {
133
+ const entry = cache.get(key);
134
+ if (entry === undefined) return;
135
+ entry.refs -= 1;
136
+ if (entry.refs > 0) return;
137
+ cache.delete(key);
138
+ if (entry.stream !== null) stopStream(entry.stream);
139
+ }
140
+
141
+ /** A `getUserMedia` track constraint. A resolved deviceId is pinned with
142
+ * `exact`, NOT a bare (ideal) deviceId: an *ideal* constraint SILENTLY falls
143
+ * back to the host default camera when the requested device can't start (e.g.
144
+ * an INACTIVE virtual cam enumerated but producing no stream). `exact` yields
145
+ * the requested device (its placeholder frame if idle), or an
146
+ * OverconstrainedError the caller catches into PLACEHOLDER — never the wrong
147
+ * cam. No deviceId → `true` (host default) applies ONLY when no deviceRef was
148
+ * declared. */
149
+ function deviceConstraint(deviceId: string | undefined): MediaTrackConstraints | boolean {
150
+ return typeof deviceId === "string" && deviceId.length > 0
151
+ ? { deviceId: { exact: deviceId } }
152
+ : true;
153
+ }
154
+
155
+ /** Stop every track of a stream (RC11 — release the camera/mic, kill the light). */
156
+ function stopStream(stream: MediaStream): void {
157
+ for (const track of stream.getTracks()) track.stop();
158
+ }
159
+
160
+ /** Test-only: drop all cached entries WITHOUT stopping tracks. Lets a test file
161
+ * start from a clean ref-count without leaking state between cases. */
162
+ export function __resetCaptureStreamCache(): void {
163
+ cache.clear();
164
+ }
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useRef, useState } from "react";
2
2
  import type { PrimitiveProps } from "./index";
3
3
  import { useOptionalLumencastRuntime } from "../../overlay/runtime-context";
4
+ import { claimCaptureStream, releaseCaptureStream } from "./capture-stream-cache";
4
5
 
5
6
  /** `x-zab.capture` — context-aware capture primitive (Zab vendor primitive,
6
7
  * RFC-0001 / ADR 004 §Amendment 1).
@@ -62,32 +63,38 @@ export function Capture({ resolved }: PrimitiveProps) {
62
63
  if (!isCaptureCapable()) return;
63
64
 
64
65
  let cancelled = false;
65
- let acquired: MediaStream | null = null;
66
+ // The cache key of the claim we hold, so cleanup releases exactly one ref.
67
+ let claimedKey: string | null = null;
66
68
 
67
69
  void (async () => {
68
70
  try {
69
- const media = await acquireStream(sourceKind, deviceRef, resolveCaptureDevice);
70
- if (media === null) return; // unknown/unsupported kind PLACEHOLDER
71
+ // Claim a per-device shared stream (ref-counted): a scene switch that
72
+ // remounts a Capture on the SAME device reuses the live stream instead
73
+ // of stopping and re-acquiring it — the vcam-blink fix.
74
+ const claim = await claimCaptureStream(sourceKind, deviceRef, resolveCaptureDevice);
75
+ if (claim.kind === "placeholder") return; // unknown/unresolved → PLACEHOLDER
76
+ const media = await claim.promise;
71
77
  if (cancelled) {
72
- // Unmounted (or scene-changed) during acquisition — stop immediately
73
- // so we never leave a camera light on (RC11).
74
- stopStream(media);
78
+ // Unmounted (or scene-changed) during acquisition — drop our claim.
79
+ // The shared stream stays alive if another consumer still holds it,
80
+ // and stops (RC11) only when the last one releases.
81
+ releaseCaptureStream(claim.key);
75
82
  return;
76
83
  }
77
- acquired = media;
84
+ claimedKey = claim.key;
78
85
  setStream(media);
79
86
  } catch {
80
87
  // §A1.2(2)(a) — any acquisition failure (permission denied, no device,
81
88
  // getUserMedia rejected) falls back to PLACEHOLDER, no throw, no
82
- // diagnostic.
89
+ // diagnostic. The rejected cache entry self-evicts; our ref went with it.
83
90
  }
84
91
  })();
85
92
 
86
93
  return () => {
87
94
  cancelled = true;
88
- // RC11 — stop the tracks at unmount / scene change. Clearing state is
89
- // unnecessary (the component is gone) but stopping the device is not.
90
- if (acquired !== null) stopStream(acquired);
95
+ // RC11 — release our claim at unmount / scene change. Tracks stop only
96
+ // when this was the last consumer of the shared stream.
97
+ if (claimedKey !== null) releaseCaptureStream(claimedKey);
91
98
  };
92
99
  // Re-acquire when the logical source identity changes (a scene switch can
93
100
  // reuse the node with a new sourceKind/deviceRef).
@@ -142,7 +149,7 @@ export type ResolvedCaptureDevice = {
142
149
  * hash. MAY be async: physical ids (e.g. getUserMedia `deviceId`) are salted
143
150
  * per origin/partition, so the host often must re-resolve a portable key
144
151
  * (label) against THIS context's devices — an inherently asynchronous step
145
- * (`enumerateDevices`). `acquireStream` awaits it, so the device is bound
152
+ * (`enumerateDevices`). `claimCaptureStream` awaits it, so the device is bound
146
153
  * before acquisition rather than racing a late global mutation. */
147
154
  export type ResolveCaptureDevice = (
148
155
  deviceRef: string,
@@ -165,87 +172,6 @@ function isVisualKind(sourceKind: string): boolean {
165
172
  );
166
173
  }
167
174
 
168
- /** Acquire a live stream for `sourceKind`, applying a host-resolved `deviceId`
169
- * when available. Returns `null` for an unsupported/unknown kind (→
170
- * PLACEHOLDER) ; throws are caught by the caller (→ PLACEHOLDER fallback). */
171
- async function acquireStream(
172
- sourceKind: string,
173
- deviceRef: string,
174
- resolveCaptureDevice: ResolveCaptureDevice | undefined,
175
- ): Promise<MediaStream | null> {
176
- const md = navigator.mediaDevices;
177
-
178
- // §A1.3 (amended 2026-06-27) — AWAIT the resolver: physical ids are salted
179
- // per origin/partition, so the host may need an async re-resolution by a
180
- // portable key (label) in THIS context. Awaiting binds the device before
181
- // acquisition instead of racing a late global update (the previous sync call
182
- // let the node acquire with a stale/absent id first).
183
- const resolved = (await resolveCaptureDevice?.(deviceRef, sourceKind)) ?? null;
184
- const deviceId = resolved?.deviceId;
185
- const declaredRef = deviceRef.length > 0;
186
-
187
- switch (sourceKind) {
188
- case "media.webcam":
189
- case "media.mic":
190
- case "media.app_audio": {
191
- // §A1.3 (amended) — NO default-device fallback for a DECLARED deviceRef
192
- // that did not resolve to a real device. Acquiring the host default cam
193
- // here is the silent "automatic allocation" of the WRONG camera the
194
- // consuming app must never get. → PLACEHOLDER (return null). The bare
195
- // default constraint stays ONLY when no deviceRef is declared at all.
196
- if (declaredRef && (typeof deviceId !== "string" || deviceId.length === 0)) {
197
- return null;
198
- }
199
- const channel = sourceKind === "media.webcam" ? "video" : "audio";
200
- return md.getUserMedia({ [channel]: deviceConstraint(deviceId) });
201
- }
202
- case "media.screen":
203
- case "media.window": {
204
- // DIRECT capture of the picked desktopCapturer surface (no system picker)
205
- // via Electron's legacy `chromeMediaSource:desktop` + the resolved
206
- // `captureSourceId`.
207
- const captureSourceId = resolved?.captureSourceId;
208
- if (typeof captureSourceId === "string" && captureSourceId.length > 0) {
209
- return md.getUserMedia({
210
- video: {
211
- mandatory: {
212
- chromeMediaSource: "desktop",
213
- chromeMediaSourceId: captureSourceId,
214
- },
215
- } as unknown as MediaTrackConstraints,
216
- });
217
- }
218
- // A declared surface ref that didn't resolve → PLACEHOLDER, never a
219
- // default `getDisplayMedia` picker. The picker stays only when no ref is
220
- // declared (a bare capture node on a non-Electron host).
221
- if (declaredRef) return null;
222
- return md.getDisplayMedia({ video: true });
223
- }
224
- default:
225
- return null;
226
- }
227
- }
228
-
229
- /** A `getUserMedia` track constraint. A resolved deviceId is pinned with
230
- * `exact`, NOT a bare (ideal) deviceId: an *ideal* constraint SILENTLY falls
231
- * back to the host default camera when the requested device can't start (e.g.
232
- * an INACTIVE virtual cam that's enumerated but produces no stream) — the
233
- * "automatic allocation" of the WRONG camera. `exact` yields the requested
234
- * device (its placeholder frame if idle), or an OverconstrainedError the
235
- * caller catches into PLACEHOLDER — never the wrong cam. No deviceId → `true`
236
- * (host default) applies ONLY when no deviceRef was declared. */
237
- function deviceConstraint(deviceId: string | undefined): MediaTrackConstraints | boolean {
238
- return typeof deviceId === "string" && deviceId.length > 0
239
- ? { deviceId: { exact: deviceId } }
240
- : true;
241
- }
242
-
243
- /** Stop every track of a stream (RC11 — release the camera/mic, kill the
244
- * device light). */
245
- function stopStream(stream: MediaStream): void {
246
- for (const track of stream.getTracks()) track.stop();
247
- }
248
-
249
175
  /** A render dimension: a finite number → px, a non-empty string → verbatim,
250
176
  * anything else → the fallback (matches the `image` primitive's helper). */
251
177
  function dimOr(v: unknown, fallback: string): string {