@lumencast/runtime 0.8.0 → 0.9.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 (64) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/animate/keyframes.js +8 -1
  3. package/dist/animate/keyframes.js.map +1 -1
  4. package/dist/app.d.ts +4 -1
  5. package/dist/app.d.ts.map +1 -1
  6. package/dist/app.js +2 -1
  7. package/dist/app.js.map +1 -1
  8. package/dist/{broadcast-Gcd-dmC7.js → broadcast-ryjLRD5q.js} +3 -3
  9. package/dist/{broadcast-Gcd-dmC7.js.map → broadcast-ryjLRD5q.js.map} +1 -1
  10. package/dist/{control-C5TfClga.js → control-AgxbXOVS.js} +4 -4
  11. package/dist/{control-C5TfClga.js.map → control-AgxbXOVS.js.map} +1 -1
  12. package/dist/{index-N-VqrIxN.js → index-DrXsLYhe.js} +144 -126
  13. package/dist/index-DrXsLYhe.js.map +1 -0
  14. package/dist/index.d.ts +1 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.html +1 -1
  17. package/dist/index.js.map +1 -1
  18. package/dist/lumencast.js +4 -4
  19. package/dist/mount.d.ts.map +1 -1
  20. package/dist/mount.js +5 -0
  21. package/dist/mount.js.map +1 -1
  22. package/dist/overlay/runtime-context.d.ts +11 -0
  23. package/dist/overlay/runtime-context.d.ts.map +1 -1
  24. package/dist/overlay/runtime-context.js +8 -0
  25. package/dist/overlay/runtime-context.js.map +1 -1
  26. package/dist/render/bundle.d.ts +1 -1
  27. package/dist/render/bundle.d.ts.map +1 -1
  28. package/dist/render/bundle.js +4 -0
  29. package/dist/render/bundle.js.map +1 -1
  30. package/dist/render/keyframe-player.d.ts.map +1 -1
  31. package/dist/render/keyframe-player.js +15 -1
  32. package/dist/render/keyframe-player.js.map +1 -1
  33. package/dist/render/primitives/capture.d.ts +40 -0
  34. package/dist/render/primitives/capture.d.ts.map +1 -0
  35. package/dist/render/primitives/capture.js +171 -0
  36. package/dist/render/primitives/capture.js.map +1 -0
  37. package/dist/render/primitives/index.d.ts.map +1 -1
  38. package/dist/render/primitives/index.js +3 -0
  39. package/dist/render/primitives/index.js.map +1 -1
  40. package/dist/render/prop-allowlist.d.ts.map +1 -1
  41. package/dist/render/prop-allowlist.js +5 -0
  42. package/dist/render/prop-allowlist.js.map +1 -1
  43. package/dist/{status-pill-BaLQoIDl.js → status-pill-BxCdj-KZ.js} +2 -2
  44. package/dist/{status-pill-BaLQoIDl.js.map → status-pill-BxCdj-KZ.js.map} +1 -1
  45. package/dist/{test-CA30C2By.js → test-CaRHj_J6.js} +4 -4
  46. package/dist/{test-CA30C2By.js.map → test-CaRHj_J6.js.map} +1 -1
  47. package/dist/{tree-1coZ32nd.js → tree-BLIxJbD3.js} +502 -419
  48. package/dist/tree-BLIxJbD3.js.map +1 -0
  49. package/dist/types.d.ts +10 -0
  50. package/dist/types.d.ts.map +1 -1
  51. package/package.json +4 -4
  52. package/src/animate/keyframes.ts +8 -1
  53. package/src/app.tsx +5 -0
  54. package/src/index.ts +5 -0
  55. package/src/mount.ts +5 -0
  56. package/src/overlay/runtime-context.tsx +14 -0
  57. package/src/render/bundle.ts +9 -1
  58. package/src/render/keyframe-player.tsx +14 -1
  59. package/src/render/primitives/capture.tsx +210 -0
  60. package/src/render/primitives/index.ts +3 -0
  61. package/src/render/prop-allowlist.ts +5 -0
  62. package/src/types.ts +10 -0
  63. package/dist/index-N-VqrIxN.js.map +0 -1
  64. package/dist/tree-1coZ32nd.js.map +0 -1
package/dist/types.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { ErrorCode } from "@lumencast/protocol";
2
+ import type { ResolveCaptureDevice } from "./render/primitives/capture";
2
3
  export type LumencastMode = "broadcast" | "control" | "test";
3
4
  export type LumencastStatus = "disconnected" | "connecting" | "live";
4
5
  export interface LumencastTokenProvider {
@@ -53,6 +54,15 @@ export interface MountOptions {
53
54
  * logs — `broadcast` builds stay console-silent. When omitted, the
54
55
  * runtime falls back to a DEV-only console.warn. */
55
56
  onDiagnostic?: (diagnostic: LumencastDiagnostic) => void;
57
+ /** ADR 004 §A1.3 — host resolver for the `x-zab.capture` primitive's ACQUIRE
58
+ * mode. Given the LOGICAL `(deviceRef, sourceKind)` from the bundle, return
59
+ * `{ deviceId }` to pin a physical device, or `null` for the host's default
60
+ * device. The runtime passes `deviceId` only as a live `getUserMedia`
61
+ * constraint — it NEVER enters the bundle or the content hash. Omit it and
62
+ * ACQUIRE uses the default device ("the cam traverses"), never throwing.
63
+ * Only consulted on a capture-capable host (e.g. the Electron preview
64
+ * webview) ; ignored on-air (CEF/Pulsar render the placeholder). */
65
+ resolveCaptureDevice?: ResolveCaptureDevice;
56
66
  }
57
67
  export interface LumencastHandle {
58
68
  /** Tear down the WS, unmount the React tree, release timers. Idempotent. */
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AAErD,MAAM,MAAM,aAAa,GAAG,WAAW,GAAG,SAAS,GAAG,MAAM,CAAC;AAE7D,MAAM,MAAM,eAAe,GAAG,cAAc,GAAG,YAAY,GAAG,MAAM,CAAC;AAErE,MAAM,WAAW,sBAAsB;IACrC,KAAK,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC;CAC9B;AAED,MAAM,MAAM,cAAc,GAAG,MAAM,GAAG,sBAAsB,CAAC;AAE7D,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,SAAS,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,OAAO,CAAC;CACtB;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EACA,gBAAgB,GAChB,eAAe,GACf,eAAe,GACf,WAAW,GACX,mBAAmB,GACnB,eAAe,CAAC;IACpB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED;;+BAE+B;AAC/B,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,WAAW,CAAC;IACpB,oEAAoE;IACpE,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,cAAc,CAAC;IACtB,IAAI,EAAE,aAAa,CAAC;IACpB,qCAAqC;IACrC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,qCAAqC;IACrC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;;;;;;;;OAWG;IACH,gBAAgB,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,KAAK,MAAM,CAAC;IACrE,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,eAAe,KAAK,IAAI,CAAC;IAC7C,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,cAAc,KAAK,IAAI,CAAC;IACxC,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,eAAe,KAAK,IAAI,CAAC;IAC7C;;;yDAGqD;IACrD,YAAY,CAAC,EAAE,CAAC,UAAU,EAAE,mBAAmB,KAAK,IAAI,CAAC;CAC1D;AAED,MAAM,WAAW,eAAe;IAC9B,4EAA4E;IAC5E,UAAU,EAAE,MAAM,IAAI,CAAC;IACvB,6DAA6D;IAC7D,QAAQ,EAAE,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,CAAC;CAC3C;AAED,YAAY,EAAE,SAAS,EAAE,CAAC"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,qBAAqB,CAAC;AACrD,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,6BAA6B,CAAC;AAExE,MAAM,MAAM,aAAa,GAAG,WAAW,GAAG,SAAS,GAAG,MAAM,CAAC;AAE7D,MAAM,MAAM,eAAe,GAAG,cAAc,GAAG,YAAY,GAAG,MAAM,CAAC;AAErE,MAAM,WAAW,sBAAsB;IACrC,KAAK,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,CAAC;CAC9B;AAED,MAAM,MAAM,cAAc,GAAG,MAAM,GAAG,sBAAsB,CAAC;AAE7D,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,SAAS,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,WAAW,EAAE,OAAO,CAAC;CACtB;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EACA,gBAAgB,GAChB,eAAe,GACf,eAAe,GACf,WAAW,GACX,mBAAmB,GACnB,eAAe,CAAC;IACpB,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED;;+BAE+B;AAC/B,MAAM,WAAW,mBAAmB;IAClC,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AAED,MAAM,WAAW,YAAY;IAC3B,MAAM,EAAE,WAAW,CAAC;IACpB,oEAAoE;IACpE,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,cAAc,CAAC;IACtB,IAAI,EAAE,aAAa,CAAC;IACpB,qCAAqC;IACrC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,qCAAqC;IACrC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf;;;;;;;;;;;OAWG;IACH,gBAAgB,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,KAAK,MAAM,CAAC;IACrE,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,eAAe,KAAK,IAAI,CAAC;IAC7C,OAAO,CAAC,EAAE,CAAC,GAAG,EAAE,cAAc,KAAK,IAAI,CAAC;IACxC,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,eAAe,KAAK,IAAI,CAAC;IAC7C;;;yDAGqD;IACrD,YAAY,CAAC,EAAE,CAAC,UAAU,EAAE,mBAAmB,KAAK,IAAI,CAAC;IACzD;;;;;;;yEAOqE;IACrE,oBAAoB,CAAC,EAAE,oBAAoB,CAAC;CAC7C;AAED,MAAM,WAAW,eAAe;IAC9B,4EAA4E;IAC5E,UAAU,EAAE,MAAM,IAAI,CAAC;IACvB,6DAA6D;IAC7D,QAAQ,EAAE,CAAC,KAAK,EAAE,cAAc,KAAK,IAAI,CAAC;CAC3C;AAED,YAAY,EAAE,SAAS,EAAE,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lumencast/runtime",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
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.8.0"
39
+ "@lumencast/protocol": "0.9.0"
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.8.0",
54
- "@lumencast/server": "0.8.0"
53
+ "@lumencast/server": "0.9.0",
54
+ "@lumencast/dev-server": "0.9.0"
55
55
  },
56
56
  "scripts": {
57
57
  "dev": "vite",
@@ -151,7 +151,14 @@ function pullTransform(
151
151
  if (prop === "rotate") {
152
152
  out.rotate = values.map((n) => `${n}deg`);
153
153
  } else {
154
- out[prop] = values;
154
+ // ADR 011 I7 live-bug fix (2nd half): framer-motion animates transform
155
+ // through its shorthand motion keys `x`/`y`, NOT `translateX`/`translateY`
156
+ // — emitting the authored names verbatim left framer with unknown keys
157
+ // it silently dropped, so the box never translated at the antenna (only
158
+ // the opacity fade survived). Map the translate channels onto the framer
159
+ // keys. `scale`/`rotate` already match framer's vocabulary.
160
+ const framerKey = prop === "translateX" ? "x" : prop === "translateY" ? "y" : prop;
161
+ out[framerKey] = values;
155
162
  }
156
163
  }
157
164
  }
package/src/app.tsx CHANGED
@@ -18,6 +18,7 @@ import type { Store } from "./state/store.js";
18
18
  import type { RenderBundle } from "./render/bundle.js";
19
19
  import type { ConnectionStatus } from "./transport/ws.js";
20
20
  import { LumencastRuntimeProvider } from "./overlay/runtime-context.js";
21
+ import type { ResolveCaptureDevice } from "./render/primitives/capture.js";
21
22
  import type { LumencastMode } from "./types.js";
22
23
 
23
24
  const LazyBroadcastMode = lazy(() =>
@@ -35,6 +36,8 @@ export interface LumencastAppProps {
35
36
  statusSignal: Signal<ConnectionStatus>;
36
37
  crossfadeKeySignal: Signal<string>;
37
38
  sendInput: (patches: Patch[]) => void;
39
+ /** ADR 004 §A1.3 — host resolver for `x-zab.capture` ACQUIRE mode. */
40
+ resolveCaptureDevice?: ResolveCaptureDevice;
38
41
  }
39
42
 
40
43
  export function LumencastApp({
@@ -44,6 +47,7 @@ export function LumencastApp({
44
47
  statusSignal,
45
48
  crossfadeKeySignal,
46
49
  sendInput,
50
+ resolveCaptureDevice,
47
51
  }: LumencastAppProps) {
48
52
  useSignals();
49
53
 
@@ -72,6 +76,7 @@ export function LumencastApp({
72
76
  bundle,
73
77
  status,
74
78
  sendInput,
79
+ ...(resolveCaptureDevice !== undefined ? { resolveCaptureDevice } : {}),
75
80
  }}
76
81
  >
77
82
  <Suspense fallback={null}>
package/src/index.ts CHANGED
@@ -26,6 +26,11 @@ export {
26
26
  } from "./render/diagnostics.js";
27
27
  export { PRIMITIVE_PROP_ALLOWLIST } from "./render/prop-allowlist.js";
28
28
 
29
+ // ADR 004 §A1.3 — host resolver type for the `x-zab.capture` ACQUIRE mode,
30
+ // supplied via `MountOptions.resolveCaptureDevice`. Exported so the consuming
31
+ // app (Prism/Solar) types its injected resolver against the runtime's contract.
32
+ export type { ResolveCaptureDevice } from "./render/primitives/capture.js";
33
+
29
34
  // Bundle types are useful for hosts that want to typecheck pre-compiled scenes.
30
35
  export type {
31
36
  RenderBundle,
package/src/mount.ts CHANGED
@@ -118,6 +118,11 @@ export function mount(options: MountOptions): LumencastHandle {
118
118
  statusSignal,
119
119
  crossfadeKeySignal,
120
120
  sendInput: (patches) => ws.sendInput(patches),
121
+ // ADR 004 §A1.3 — thread the host capture resolver to the runtime context
122
+ // so the `x-zab.capture` primitive's ACQUIRE mode can pin a device.
123
+ ...(options.resolveCaptureDevice !== undefined
124
+ ? { resolveCaptureDevice: options.resolveCaptureDevice }
125
+ : {}),
121
126
  }),
122
127
  );
123
128
 
@@ -4,6 +4,7 @@ import type { Store } from "../state/store";
4
4
  import type { RenderBundle } from "../render/bundle";
5
5
  import type { ConnectionStatus } from "../transport/ws";
6
6
  import type { LumencastMode } from "../types";
7
+ import type { ResolveCaptureDevice } from "../render/primitives/capture";
7
8
 
8
9
  export interface LumencastRuntime {
9
10
  mode: LumencastMode;
@@ -12,6 +13,10 @@ export interface LumencastRuntime {
12
13
  status: ConnectionStatus;
13
14
  /** Send LSDP/1 input patches to the server. */
14
15
  sendInput: (patches: Patch[]) => void;
16
+ /** ADR 004 §A1.3 — host-provided resolver mapping a LOGICAL `deviceRef` to a
17
+ * physical `deviceId` for the `x-zab.capture` primitive's ACQUIRE mode.
18
+ * Injected from `MountOptions`, NOT the bundle. Absent → default device. */
19
+ resolveCaptureDevice?: ResolveCaptureDevice;
15
20
  }
16
21
 
17
22
  const Ctx = createContext<LumencastRuntime | null>(null);
@@ -35,3 +40,12 @@ export function useLumencastRuntime(): LumencastRuntime {
35
40
  }
36
41
  return v;
37
42
  }
43
+
44
+ /** Read the runtime context WITHOUT throwing when no provider is mounted.
45
+ * Render primitives (e.g. `x-zab.capture`) may render via `<Tree>` directly —
46
+ * embedded hosts, tooling, tests — outside `mount()`'s provider. They use this
47
+ * to pick up mount-level host config (the capture resolver) when present and
48
+ * fall back to defaults when not. */
49
+ export function useOptionalLumencastRuntime(): LumencastRuntime | null {
50
+ return useContext(Ctx);
51
+ }
@@ -25,7 +25,11 @@ export type RenderKind =
25
25
  | "shape"
26
26
  | "media"
27
27
  | "repeat"
28
- | "instance";
28
+ | "instance"
29
+ // Zab vendor primitive (RFC-0001, §17.1) — a transparent capture
30
+ // placeholder. Recognised by the Zab-plugin runtime ; reserves a box and
31
+ // renders nothing.
32
+ | "x-zab.capture";
29
33
 
30
34
  export interface RenderNode {
31
35
  kind: RenderKind;
@@ -139,6 +143,10 @@ export interface RenderBundle {
139
143
  */
140
144
  export const SUPPORTED_PROFILES: ReadonlySet<string> = new Set<string>([
141
145
  "x-lumencast.color-srgb-1.0",
146
+ // RFC-0001 / ADR 004 — this runtime ships the Zab capture plugin, so a
147
+ // bundle declaring `x-zab.capture/1` in `profiles[]` is compatible (it is
148
+ // NOT rejected as BUNDLE_INCOMPATIBLE, §17.3.1).
149
+ "x-zab.capture/1",
142
150
  ]);
143
151
 
144
152
  // LSML 1.1 §17.5.1 + ADR 001 RC#14 — authoring-profile detection.
@@ -64,7 +64,20 @@ export function KeyframePlayer({
64
64
  return (
65
65
  <motion.div
66
66
  key={replayTokenRef.current}
67
- style={{ display: "contents" }}
67
+ // A `display:contents` element generates NO box, so the browser
68
+ // never composites the animated `transform`/`opacity`/`filter` this
69
+ // player writes — they are silently dropped and the subtree renders
70
+ // dead at its child's default origin (ADR 011 I7 live bug). The
71
+ // player must be a REAL compositing box. `position:absolute; inset:0`
72
+ // overlays the parent without disturbing sibling layout, and — being
73
+ // positioned — becomes the containing block for the absolutely-
74
+ // positioned primitive nested beneath it (Frame is `position:absolute;
75
+ // left:0; top:0`), so the child's authored `x`/`y` resolve against the
76
+ // player's (0,0) exactly as they did against the grandparent under
77
+ // `display:contents`. The animated channels now composite onto a live
78
+ // box and the whole subtree (the nested target's geometry + fill)
79
+ // moves and fades with the keyframes.
80
+ style={{ position: "absolute", inset: 0 }}
68
81
  initial={firstFrame(compiled.animate)}
69
82
  animate={compiled.animate}
70
83
  transition={transition}
@@ -0,0 +1,210 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import type { PrimitiveProps } from "./index";
3
+ import { useOptionalLumencastRuntime } from "../../overlay/runtime-context";
4
+
5
+ /** `x-zab.capture` — context-aware capture primitive (Zab vendor primitive,
6
+ * RFC-0001 / ADR 004 §Amendment 1).
7
+ *
8
+ * The primitive reserves a box of the declared geometry so downstream layout
9
+ * (siblings, masks, stacks, grids) is unaffected — exactly as an `image` of
10
+ * the same geometry, in BOTH modes below. It picks a mode by **capability
11
+ * detection at mount** (feature detection, not an env flag) :
12
+ *
13
+ * - **ACQUIRE** (capable host, e.g. the Electron preview webview with
14
+ * auto-granted media permissions) : it acquires a live stream itself via
15
+ * `getUserMedia` (webcam/mic) or `getDisplayMedia` (screen/window) per
16
+ * `x-zab.sourceKind`, and renders it in a `<video>` for visual kinds
17
+ * (audio kinds stay visually empty). The physical device is resolved from
18
+ * the LOGICAL `x-zab.deviceRef` through a host-provided resolver
19
+ * (`resolveCaptureDevice`, §A1.3) — never a bundle-baked id. Any failure
20
+ * (no resolver, no device, permission denied, acquisition error) falls
21
+ * back to PLACEHOLDER WITHOUT throwing or blanking the surrounding tree.
22
+ *
23
+ * - **PLACEHOLDER** (non-capable host, e.g. CEF/Pulsar on-air) : the box is
24
+ * fully transparent, acquires nothing, reaches no device — the original
25
+ * §3.2 behaviour. The consuming app composites a native source behind it
26
+ * (ON-AIR PATH UNCHANGED).
27
+ *
28
+ * A stream-less box is a valid mode, not an error : NO diagnostic is emitted
29
+ * for PLACEHOLDER mode or for an ACQUIRE→PLACEHOLDER fallback.
30
+ *
31
+ * Geometry is the only layout input. `width`/`height` are the
32
+ * compiler-flattened `size:{w,h}` ; universal props (visible/opacity/
33
+ * position) are applied by the Tree's UniversalWrapper. An audio-only
34
+ * capture (`media.mic` / `media.app_audio`) may omit `size` → a zero-area
35
+ * box that never paints. */
36
+ export function Capture({ resolved }: PrimitiveProps) {
37
+ const width = dimOr(resolved.width, "100%");
38
+ const height = dimOr(resolved.height, "100%");
39
+ const sourceKind = typeof resolved["x-zab.sourceKind"] === "string"
40
+ ? (resolved["x-zab.sourceKind"] as string)
41
+ : "";
42
+ const deviceRef = typeof resolved["x-zab.deviceRef"] === "string"
43
+ ? (resolved["x-zab.deviceRef"] as string)
44
+ : "";
45
+
46
+ // §A1.3 — the host-provided resolver, injected at mount through the runtime
47
+ // context (NOT the bundle, NOT the LSDP wire). Absent when the tree renders
48
+ // outside a host (direct embedding, tooling, tests) → the default-device
49
+ // path applies.
50
+ const runtime = useOptionalLumencastRuntime();
51
+ const resolveCaptureDevice = runtime?.resolveCaptureDevice;
52
+
53
+ const videoRef = useRef<HTMLVideoElement | null>(null);
54
+ // `null` → PLACEHOLDER (non-capable, or ACQUIRE→PLACEHOLDER fallback).
55
+ // A `MediaStream` → ACQUIRE succeeded and a visual stream is mounted.
56
+ const [stream, setStream] = useState<MediaStream | null>(null);
57
+
58
+ useEffect(() => {
59
+ // §A1.2(2) — capability detection at mount. A non-capable host (no
60
+ // `getUserMedia`, e.g. CEF/Pulsar on-air, or jsdom without a mock) stays
61
+ // in PLACEHOLDER : acquire nothing, no diagnostic.
62
+ if (!isCaptureCapable()) return;
63
+
64
+ let cancelled = false;
65
+ let acquired: MediaStream | null = null;
66
+
67
+ void (async () => {
68
+ try {
69
+ const media = await acquireStream(sourceKind, deviceRef, resolveCaptureDevice);
70
+ if (media === null) return; // unknown/unsupported kind → PLACEHOLDER
71
+ if (cancelled) {
72
+ // Unmounted (or scene-changed) during acquisition — stop immediately
73
+ // so we never leave a camera light on (RC11).
74
+ stopStream(media);
75
+ return;
76
+ }
77
+ acquired = media;
78
+ setStream(media);
79
+ } catch {
80
+ // §A1.2(2)(a) — any acquisition failure (permission denied, no device,
81
+ // getUserMedia rejected) falls back to PLACEHOLDER, no throw, no
82
+ // diagnostic.
83
+ }
84
+ })();
85
+
86
+ return () => {
87
+ 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);
91
+ };
92
+ // Re-acquire when the logical source identity changes (a scene switch can
93
+ // reuse the node with a new sourceKind/deviceRef).
94
+ }, [sourceKind, deviceRef, resolveCaptureDevice]);
95
+
96
+ // Attach / detach the live stream to the <video> element imperatively —
97
+ // `srcObject` is not a serialisable attribute.
98
+ useEffect(() => {
99
+ const el = videoRef.current;
100
+ if (el === null) return;
101
+ el.srcObject = stream;
102
+ return () => {
103
+ if (el !== null) el.srcObject = null;
104
+ };
105
+ }, [stream]);
106
+
107
+ // ACQUIRE with a visual stream → render the <video>. Audio-only kinds keep
108
+ // the transparent box (no visible element) even when acquired.
109
+ if (stream !== null && isVisualKind(sourceKind)) {
110
+ return (
111
+ <video
112
+ ref={videoRef}
113
+ data-lumencast-capture
114
+ autoPlay
115
+ muted
116
+ playsInline
117
+ style={{ width, height, objectFit: "cover", pointerEvents: "none" }}
118
+ />
119
+ );
120
+ }
121
+
122
+ // PLACEHOLDER (or audio-only ACQUIRE) — fully transparent, inert box.
123
+ return (
124
+ <div
125
+ aria-hidden
126
+ data-lumencast-capture
127
+ style={{ width, height, opacity: 0, pointerEvents: "none" }}
128
+ />
129
+ );
130
+ }
131
+
132
+ /** Resolver injected by the consuming app (ADR 004 §A1.3). Maps the LOGICAL
133
+ * `deviceRef` to a physical `deviceId` for a live `getUserMedia` constraint.
134
+ * The result NEVER enters the bundle or the content hash. */
135
+ export type ResolveCaptureDevice = (
136
+ deviceRef: string,
137
+ sourceKind: string,
138
+ ) => { deviceId?: string } | null;
139
+
140
+ /** §A1.2(2) — capture-capable iff `navigator.mediaDevices.getUserMedia`
141
+ * exists and is callable in the current context. Feature detection only ;
142
+ * CEF/Pulsar on-air and jsdom (without a mock) report non-capable. */
143
+ function isCaptureCapable(): boolean {
144
+ return (
145
+ typeof navigator !== "undefined" &&
146
+ typeof navigator.mediaDevices?.getUserMedia === "function"
147
+ );
148
+ }
149
+
150
+ /** Visual kinds render a `<video>` ; audio kinds stay visually empty. */
151
+ function isVisualKind(sourceKind: string): boolean {
152
+ return (
153
+ sourceKind === "media.webcam" ||
154
+ sourceKind === "media.screen" ||
155
+ sourceKind === "media.window"
156
+ );
157
+ }
158
+
159
+ /** Acquire a live stream for `sourceKind`, applying a host-resolved `deviceId`
160
+ * when available. Returns `null` for an unsupported/unknown kind (→
161
+ * PLACEHOLDER) ; throws are caught by the caller (→ PLACEHOLDER fallback). */
162
+ async function acquireStream(
163
+ sourceKind: string,
164
+ deviceRef: string,
165
+ resolveCaptureDevice: ResolveCaptureDevice | undefined,
166
+ ): Promise<MediaStream | null> {
167
+ const md = navigator.mediaDevices;
168
+
169
+ // §A1.3 — a resolver maps the LOGICAL deviceRef to a physical deviceId. No
170
+ // resolver / `null` result → default constraints (no deviceId), so "the cam
171
+ // traverses" on the first pass. The deviceId is a live constraint only.
172
+ const resolved = resolveCaptureDevice?.(deviceRef, sourceKind) ?? null;
173
+ const deviceId = resolved?.deviceId;
174
+
175
+ switch (sourceKind) {
176
+ case "media.webcam":
177
+ return md.getUserMedia({ video: deviceConstraint(deviceId) });
178
+ case "media.mic":
179
+ return md.getUserMedia({ audio: deviceConstraint(deviceId) });
180
+ case "media.app_audio":
181
+ return md.getUserMedia({ audio: deviceConstraint(deviceId) });
182
+ case "media.screen":
183
+ case "media.window":
184
+ // Display capture has no deviceId constraint (the picker selects the
185
+ // surface). The resolver is consulted but its id is not applicable here.
186
+ return md.getDisplayMedia({ video: true });
187
+ default:
188
+ return null;
189
+ }
190
+ }
191
+
192
+ /** A `getUserMedia` track constraint : a specific `deviceId` when resolved,
193
+ * else `true` (the host's default device). */
194
+ function deviceConstraint(deviceId: string | undefined): MediaTrackConstraints | boolean {
195
+ return typeof deviceId === "string" && deviceId.length > 0 ? { deviceId } : true;
196
+ }
197
+
198
+ /** Stop every track of a stream (RC11 — release the camera/mic, kill the
199
+ * device light). */
200
+ function stopStream(stream: MediaStream): void {
201
+ for (const track of stream.getTracks()) track.stop();
202
+ }
203
+
204
+ /** A render dimension: a finite number → px, a non-empty string → verbatim,
205
+ * anything else → the fallback (matches the `image` primitive's helper). */
206
+ function dimOr(v: unknown, fallback: string): string {
207
+ if (typeof v === "number" && Number.isFinite(v)) return `${v}px`;
208
+ if (typeof v === "string" && v.length > 0) return v;
209
+ return fallback;
210
+ }
@@ -13,6 +13,7 @@ import { Image } from "./image";
13
13
  import { Shape } from "./shape";
14
14
  import { Media } from "./media";
15
15
  import { Instance } from "./instance";
16
+ import { Capture } from "./capture";
16
17
  // `repeat` is dispatched specially in the tree (it iterates a bound
17
18
  // array and provides a path scope to its children) ; it does not
18
19
  // appear here as a regular primitive.
@@ -48,4 +49,6 @@ export const PRIMITIVES: Partial<Record<RenderKind, ComponentType<PrimitiveProps
48
49
  shape: Shape,
49
50
  media: Media,
50
51
  instance: Instance,
52
+ // RFC-0001 / ADR 004 — Zab vendor capture placeholder (transparent, inert).
53
+ "x-zab.capture": Capture,
51
54
  };
@@ -99,6 +99,11 @@ export const PRIMITIVE_PROP_ALLOWLIST: Readonly<Record<RenderKind, ReadonlySet<s
99
99
  ]),
100
100
  media: allow(["src", "loop", "mute", "autoplay", "fit"]),
101
101
  instance: allow(["scene_id", "scene_version", "size", "position"]),
102
+ // RFC-0001 / ADR 004 — vendor capture placeholder. `width`/`height` are the
103
+ // flattened geometry (universal) ; the `x-zab.*` props are carried as
104
+ // metadata (the renderer reserves the box, ignores deviceRef). Listed so
105
+ // they are NOT flagged as silent drops by the anti-drop audit.
106
+ "x-zab.capture": allow(["x-zab.sourceKind", "x-zab.deviceRef", "width", "height"]),
102
107
  // `repeat` is dispatched specially by the tree ; its only consumed
103
108
  // binding is `items`.
104
109
  repeat: new Set(["items"]),
package/src/types.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  // Public types of @lumencast/runtime — must align with RUNTIME-API.md.
2
2
 
3
3
  import type { ErrorCode } from "@lumencast/protocol";
4
+ import type { ResolveCaptureDevice } from "./render/primitives/capture";
4
5
 
5
6
  export type LumencastMode = "broadcast" | "control" | "test";
6
7
 
@@ -69,6 +70,15 @@ export interface MountOptions {
69
70
  * logs — `broadcast` builds stay console-silent. When omitted, the
70
71
  * runtime falls back to a DEV-only console.warn. */
71
72
  onDiagnostic?: (diagnostic: LumencastDiagnostic) => void;
73
+ /** ADR 004 §A1.3 — host resolver for the `x-zab.capture` primitive's ACQUIRE
74
+ * mode. Given the LOGICAL `(deviceRef, sourceKind)` from the bundle, return
75
+ * `{ deviceId }` to pin a physical device, or `null` for the host's default
76
+ * device. The runtime passes `deviceId` only as a live `getUserMedia`
77
+ * constraint — it NEVER enters the bundle or the content hash. Omit it and
78
+ * ACQUIRE uses the default device ("the cam traverses"), never throwing.
79
+ * Only consulted on a capture-capable host (e.g. the Electron preview
80
+ * webview) ; ignored on-air (CEF/Pulsar render the placeholder). */
81
+ resolveCaptureDevice?: ResolveCaptureDevice;
72
82
  }
73
83
 
74
84
  export interface LumencastHandle {