@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.
- package/dist/.tsbuildinfo +1 -1
- package/dist/animate/keyframes.js +8 -1
- package/dist/animate/keyframes.js.map +1 -1
- package/dist/app.d.ts +4 -1
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +2 -1
- package/dist/app.js.map +1 -1
- package/dist/{broadcast-Gcd-dmC7.js → broadcast-ryjLRD5q.js} +3 -3
- package/dist/{broadcast-Gcd-dmC7.js.map → broadcast-ryjLRD5q.js.map} +1 -1
- package/dist/{control-C5TfClga.js → control-AgxbXOVS.js} +4 -4
- package/dist/{control-C5TfClga.js.map → control-AgxbXOVS.js.map} +1 -1
- package/dist/{index-N-VqrIxN.js → index-DrXsLYhe.js} +144 -126
- package/dist/index-DrXsLYhe.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.html +1 -1
- package/dist/index.js.map +1 -1
- package/dist/lumencast.js +4 -4
- package/dist/mount.d.ts.map +1 -1
- package/dist/mount.js +5 -0
- package/dist/mount.js.map +1 -1
- package/dist/overlay/runtime-context.d.ts +11 -0
- package/dist/overlay/runtime-context.d.ts.map +1 -1
- package/dist/overlay/runtime-context.js +8 -0
- package/dist/overlay/runtime-context.js.map +1 -1
- package/dist/render/bundle.d.ts +1 -1
- package/dist/render/bundle.d.ts.map +1 -1
- package/dist/render/bundle.js +4 -0
- package/dist/render/bundle.js.map +1 -1
- package/dist/render/keyframe-player.d.ts.map +1 -1
- package/dist/render/keyframe-player.js +15 -1
- package/dist/render/keyframe-player.js.map +1 -1
- package/dist/render/primitives/capture.d.ts +40 -0
- package/dist/render/primitives/capture.d.ts.map +1 -0
- package/dist/render/primitives/capture.js +171 -0
- package/dist/render/primitives/capture.js.map +1 -0
- package/dist/render/primitives/index.d.ts.map +1 -1
- package/dist/render/primitives/index.js +3 -0
- package/dist/render/primitives/index.js.map +1 -1
- package/dist/render/prop-allowlist.d.ts.map +1 -1
- package/dist/render/prop-allowlist.js +5 -0
- package/dist/render/prop-allowlist.js.map +1 -1
- package/dist/{status-pill-BaLQoIDl.js → status-pill-BxCdj-KZ.js} +2 -2
- package/dist/{status-pill-BaLQoIDl.js.map → status-pill-BxCdj-KZ.js.map} +1 -1
- package/dist/{test-CA30C2By.js → test-CaRHj_J6.js} +4 -4
- package/dist/{test-CA30C2By.js.map → test-CaRHj_J6.js.map} +1 -1
- package/dist/{tree-1coZ32nd.js → tree-BLIxJbD3.js} +502 -419
- package/dist/tree-BLIxJbD3.js.map +1 -0
- package/dist/types.d.ts +10 -0
- package/dist/types.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/animate/keyframes.ts +8 -1
- package/src/app.tsx +5 -0
- package/src/index.ts +5 -0
- package/src/mount.ts +5 -0
- package/src/overlay/runtime-context.tsx +14 -0
- package/src/render/bundle.ts +9 -1
- package/src/render/keyframe-player.tsx +14 -1
- package/src/render/primitives/capture.tsx +210 -0
- package/src/render/primitives/index.ts +3 -0
- package/src/render/prop-allowlist.ts +5 -0
- package/src/types.ts +10 -0
- package/dist/index-N-VqrIxN.js.map +0 -1
- 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. */
|
package/dist/types.d.ts.map
CHANGED
|
@@ -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;
|
|
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.
|
|
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.
|
|
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/
|
|
54
|
-
"@lumencast/server": "0.
|
|
53
|
+
"@lumencast/server": "0.9.0",
|
|
54
|
+
"@lumencast/dev-server": "0.9.0"
|
|
55
55
|
},
|
|
56
56
|
"scripts": {
|
|
57
57
|
"dev": "vite",
|
package/src/animate/keyframes.ts
CHANGED
|
@@ -151,7 +151,14 @@ function pullTransform(
|
|
|
151
151
|
if (prop === "rotate") {
|
|
152
152
|
out.rotate = values.map((n) => `${n}deg`);
|
|
153
153
|
} else {
|
|
154
|
-
|
|
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
|
+
}
|
package/src/render/bundle.ts
CHANGED
|
@@ -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
|
-
|
|
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 {
|