@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
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// Render bundle — the runtime's flat, pre-compiled representation of a scene.
|
|
2
|
+
//
|
|
3
|
+
// The bundle is content-addressed by `scene_version` (sha256 of the
|
|
4
|
+
// canonical JSON form). Lumencast fetches it once per `scene_version` and
|
|
5
|
+
// caches forever; the server serves it with long-TTL immutable cache headers.
|
|
6
|
+
//
|
|
7
|
+
// Note on shape: this `RenderBundle` is the flat, runtime-internal form. The
|
|
8
|
+
// canonical *authoring* format (LSML 1.0, see lumencast-protocol/spec/LSML-1.md)
|
|
9
|
+
// uses inline `bind: { value: "path" }` per primitive instead of a `bindings`
|
|
10
|
+
// map. A compiler step (forthcoming `@lumencast/compiler`) will translate
|
|
11
|
+
// LSML 1.0 → RenderBundle. For now, callers who want to feed an LSML 1.0
|
|
12
|
+
// bundle pre-compile or use a hand-rolled adapter.
|
|
13
|
+
|
|
14
|
+
import type { Transition } from "../animate/transitions.js";
|
|
15
|
+
import type { Keyframes } from "../animate/keyframes.js";
|
|
16
|
+
|
|
17
|
+
// --- bundle shape ----------------------------------------------------
|
|
18
|
+
|
|
19
|
+
export type RenderKind =
|
|
20
|
+
| "stack"
|
|
21
|
+
| "grid"
|
|
22
|
+
| "frame"
|
|
23
|
+
| "text"
|
|
24
|
+
| "image"
|
|
25
|
+
| "shape"
|
|
26
|
+
| "media"
|
|
27
|
+
| "repeat"
|
|
28
|
+
| "instance";
|
|
29
|
+
|
|
30
|
+
export interface RenderNode {
|
|
31
|
+
kind: RenderKind;
|
|
32
|
+
/** Stable identifier for keyed reconciliation. */
|
|
33
|
+
id?: string;
|
|
34
|
+
/** Static props (frozen at build/compile time). */
|
|
35
|
+
props?: Record<string, unknown>;
|
|
36
|
+
/** Prop name → state path. The render layer subscribes the path's signal
|
|
37
|
+
* and applies the value to the named prop on each change. */
|
|
38
|
+
bindings?: Record<string, string>;
|
|
39
|
+
/** Default transition per bound prop. Aligns with LSML 1.0 §6 `animate`
|
|
40
|
+
* directives. The runtime applies these as CSS transitions / Framer Motion
|
|
41
|
+
* configs at render time. */
|
|
42
|
+
transitions?: Record<string, Transition>;
|
|
43
|
+
/** LSML 1.1 §6.6 — multi-step keyframe sequence played on mount or
|
|
44
|
+
* whenever `keyframes.key` (LeafPath) changes. Coexists with
|
|
45
|
+
* `transitions` ; the runtime applies whichever was last triggered
|
|
46
|
+
* (no blending — see §6.6 last paragraph). */
|
|
47
|
+
keyframes?: Keyframes;
|
|
48
|
+
/** LSML 1.1 §6.7 — only meaningful on `repeat`. Each iteration's
|
|
49
|
+
* animations start `index * stagger_ms` after iteration 0. */
|
|
50
|
+
stagger_ms?: number;
|
|
51
|
+
/** Children — already-inlined primitives only. */
|
|
52
|
+
children?: RenderNode[];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type OperatorInputType =
|
|
56
|
+
| "boolean"
|
|
57
|
+
| "number"
|
|
58
|
+
| "text"
|
|
59
|
+
| "select"
|
|
60
|
+
| "enum"
|
|
61
|
+
| "path-ref"
|
|
62
|
+
| "colour"
|
|
63
|
+
| "duration";
|
|
64
|
+
|
|
65
|
+
export interface OperatorInput {
|
|
66
|
+
path: string;
|
|
67
|
+
label: string;
|
|
68
|
+
type: OperatorInputType;
|
|
69
|
+
default?: unknown;
|
|
70
|
+
group?: string;
|
|
71
|
+
writable_by?: string[];
|
|
72
|
+
[extra: string]: unknown;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface ExternalAdapter {
|
|
76
|
+
key: string;
|
|
77
|
+
label: string;
|
|
78
|
+
kind: string;
|
|
79
|
+
target_paths: string[];
|
|
80
|
+
[extra: string]: unknown;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface Asset {
|
|
84
|
+
id: string;
|
|
85
|
+
url: string;
|
|
86
|
+
kind: string;
|
|
87
|
+
[extra: string]: unknown;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export interface RenderBundle {
|
|
91
|
+
scene_version: string;
|
|
92
|
+
root: RenderNode;
|
|
93
|
+
operator_inputs?: OperatorInput[];
|
|
94
|
+
external_adapters?: ExternalAdapter[];
|
|
95
|
+
assets?: Asset[];
|
|
96
|
+
/** LSML 1.1 §17.3 — capability profiles required for correct rendering.
|
|
97
|
+
* Each entry is a `<vendor>.<name>-<version>` string. The runtime
|
|
98
|
+
* checks every entry against its supported list ; an unrecognised
|
|
99
|
+
* profile raises BUNDLE_INCOMPATIBLE per §17.3.1. */
|
|
100
|
+
profiles?: string[];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Profiles the JS runtime advertises support for. Bundle authors who
|
|
105
|
+
* declare `profiles: [...]` get a hard `BUNDLE_INCOMPATIBLE` rejection
|
|
106
|
+
* when any entry is not in this set (LSML 1.1 §17.3.1).
|
|
107
|
+
*
|
|
108
|
+
* 1.1 ships with no standard profiles ; future minors / vendor specs
|
|
109
|
+
* register here. The `x-lumencast.color-srgb-1.0` entry is the
|
|
110
|
+
* default-color-space marker ; bundles that opt into a perceptual
|
|
111
|
+
* space (OKLCH) would request a different profile and currently
|
|
112
|
+
* reject.
|
|
113
|
+
*/
|
|
114
|
+
export const SUPPORTED_PROFILES: ReadonlySet<string> = new Set<string>([
|
|
115
|
+
"x-lumencast.color-srgb-1.0",
|
|
116
|
+
]);
|
|
117
|
+
|
|
118
|
+
export class BundleIncompatibleError extends Error {
|
|
119
|
+
public readonly code = "BUNDLE_INCOMPATIBLE" as const;
|
|
120
|
+
public readonly unsupportedProfiles: string[];
|
|
121
|
+
constructor(unsupportedProfiles: string[]) {
|
|
122
|
+
super(
|
|
123
|
+
`BUNDLE_INCOMPATIBLE: profile(s) not supported by this runtime: ${unsupportedProfiles.join(
|
|
124
|
+
", ",
|
|
125
|
+
)}`,
|
|
126
|
+
);
|
|
127
|
+
this.name = "BundleIncompatibleError";
|
|
128
|
+
this.unsupportedProfiles = unsupportedProfiles;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Validate a bundle's `profiles[]` against the runtime's supported
|
|
133
|
+
* set. Throws `BundleIncompatibleError` listing every offending entry
|
|
134
|
+
* when at least one is not supported. */
|
|
135
|
+
export function validateBundleProfiles(
|
|
136
|
+
bundle: { profiles?: string[] },
|
|
137
|
+
supported: ReadonlySet<string> = SUPPORTED_PROFILES,
|
|
138
|
+
): void {
|
|
139
|
+
const profiles = bundle.profiles;
|
|
140
|
+
if (!profiles || profiles.length === 0) return;
|
|
141
|
+
const missing = profiles.filter((p) => !supported.has(p));
|
|
142
|
+
if (missing.length > 0) {
|
|
143
|
+
throw new BundleIncompatibleError(missing);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// --- fetch + cache ---------------------------------------------------
|
|
148
|
+
|
|
149
|
+
export interface BundleFetcher {
|
|
150
|
+
/** Fetch the bundle for a scene version. Cached forever by hash. */
|
|
151
|
+
get(sceneId: string, sceneVersion: string): Promise<RenderBundle>;
|
|
152
|
+
/** Inject a bundle directly — used by tests and for the "scene already in
|
|
153
|
+
* flight" handoff path. */
|
|
154
|
+
preload(bundle: RenderBundle): void;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface BundleFetcherOptions {
|
|
158
|
+
/** Base URL of the server. The fetcher constructs
|
|
159
|
+
* `${baseUrl}/lsdp/v1/scenes/{id}/bundle?v={hash}`. */
|
|
160
|
+
baseUrl: string;
|
|
161
|
+
/** Path prefix for bundle resolution. Defaults to `/lsdp/v1/scenes`. */
|
|
162
|
+
pathPrefix?: string;
|
|
163
|
+
fetchImpl?: typeof fetch;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
class FetcherImpl implements BundleFetcher {
|
|
167
|
+
private readonly cache = new Map<string, RenderBundle>();
|
|
168
|
+
private readonly baseUrl: string;
|
|
169
|
+
private readonly pathPrefix: string;
|
|
170
|
+
private readonly fetchImpl: typeof fetch;
|
|
171
|
+
|
|
172
|
+
constructor(opts: BundleFetcherOptions) {
|
|
173
|
+
this.baseUrl = opts.baseUrl.replace(/\/$/, "");
|
|
174
|
+
this.pathPrefix = (opts.pathPrefix ?? "/lsdp/v1/scenes").replace(/\/$/, "");
|
|
175
|
+
this.fetchImpl = opts.fetchImpl ?? globalThis.fetch.bind(globalThis);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
preload(bundle: RenderBundle): void {
|
|
179
|
+
// LSML 1.1 §17.3.1 — reject early if any declared profile is
|
|
180
|
+
// unsupported by this runtime. Authors get an actionable error
|
|
181
|
+
// instead of a silent rendering glitch.
|
|
182
|
+
validateBundleProfiles(bundle);
|
|
183
|
+
this.cache.set(bundle.scene_version, bundle);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
async get(sceneId: string, sceneVersion: string): Promise<RenderBundle> {
|
|
187
|
+
const cached = this.cache.get(sceneVersion);
|
|
188
|
+
if (cached) return cached;
|
|
189
|
+
const url = `${this.baseUrl}${this.pathPrefix}/${encodeURIComponent(sceneId)}/bundle?v=${encodeURIComponent(sceneVersion)}`;
|
|
190
|
+
const response = await this.fetchImpl(url);
|
|
191
|
+
if (!response.ok) {
|
|
192
|
+
throw new Error(`bundle fetch failed: ${response.status} ${response.statusText}`);
|
|
193
|
+
}
|
|
194
|
+
const json = (await response.json()) as RenderBundle;
|
|
195
|
+
if (json.scene_version !== sceneVersion) {
|
|
196
|
+
throw new Error(
|
|
197
|
+
`bundle scene_version mismatch: expected ${sceneVersion}, got ${json.scene_version}`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
validateBundleProfiles(json);
|
|
201
|
+
this.cache.set(sceneVersion, json);
|
|
202
|
+
return json;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function createBundleFetcher(opts: BundleFetcherOptions): BundleFetcher {
|
|
207
|
+
return new FetcherImpl(opts);
|
|
208
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// Fill rendering helpers (LSML 1.1 §4.12).
|
|
2
|
+
//
|
|
3
|
+
// A Fill is a discriminated union :
|
|
4
|
+
// - solid : { kind: "solid", color, opacity? }
|
|
5
|
+
// - linear-gradient : { kind: "linear-gradient", angle_deg?, stops, opacity? }
|
|
6
|
+
// - radial-gradient : { kind: "radial-gradient", center?, radius?, stops, opacity? }
|
|
7
|
+
//
|
|
8
|
+
// shape.fills[] and frame.backgrounds[] both use this shape. Each fill
|
|
9
|
+
// renders as a separate SVG element layered top-to-bottom (first entry
|
|
10
|
+
// renders on top per §4.12).
|
|
11
|
+
|
|
12
|
+
import type { CSSProperties, ReactElement } from "react";
|
|
13
|
+
|
|
14
|
+
export interface FillStop {
|
|
15
|
+
offset: number;
|
|
16
|
+
color: string;
|
|
17
|
+
opacity?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type Fill =
|
|
21
|
+
| { kind: "solid"; color: string; opacity?: number }
|
|
22
|
+
| {
|
|
23
|
+
kind: "linear-gradient";
|
|
24
|
+
angle_deg?: number;
|
|
25
|
+
stops: FillStop[];
|
|
26
|
+
opacity?: number;
|
|
27
|
+
}
|
|
28
|
+
| {
|
|
29
|
+
kind: "radial-gradient";
|
|
30
|
+
center?: { x: number; y: number };
|
|
31
|
+
radius?: number;
|
|
32
|
+
stops: FillStop[];
|
|
33
|
+
opacity?: number;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
let gradientIdSeq = 0;
|
|
37
|
+
function nextGradientId(): string {
|
|
38
|
+
gradientIdSeq = (gradientIdSeq + 1) % 1_000_000;
|
|
39
|
+
return `lumen-grad-${gradientIdSeq.toString(36)}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface FillRenderResult {
|
|
43
|
+
/** SVG <defs> contributions (gradient definitions). */
|
|
44
|
+
defs: ReactElement[];
|
|
45
|
+
/** Reference to use as the `fill` attribute on the shape. */
|
|
46
|
+
ref: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Compile a Fill into an SVG `<defs>` entry + a `fill="url(#…)"` ref.
|
|
50
|
+
* Solid fills produce no defs and return the colour directly. */
|
|
51
|
+
export function renderFill(fill: Fill): FillRenderResult {
|
|
52
|
+
if (fill.kind === "solid") {
|
|
53
|
+
// Solid fill — no defs needed, just hand the colour to fill.
|
|
54
|
+
// SVG fill-opacity composes with element opacity multiplicatively
|
|
55
|
+
// so we apply both consistently.
|
|
56
|
+
return { defs: [], ref: fill.color };
|
|
57
|
+
}
|
|
58
|
+
const id = nextGradientId();
|
|
59
|
+
if (fill.kind === "linear-gradient") {
|
|
60
|
+
// angle_deg : 0 = bottom-to-top per §4.12 (matches CSS `linear-gradient`)
|
|
61
|
+
const angle = fill.angle_deg ?? 0;
|
|
62
|
+
// Translate angle (degrees from up) to SVG x1/y1/x2/y2 in user space.
|
|
63
|
+
const rad = ((angle - 90) * Math.PI) / 180; // 0° → x1=0,y1=1 (bottom-up)
|
|
64
|
+
const x1 = 0.5 - 0.5 * Math.cos(rad);
|
|
65
|
+
const y1 = 0.5 - 0.5 * Math.sin(rad);
|
|
66
|
+
const x2 = 0.5 + 0.5 * Math.cos(rad);
|
|
67
|
+
const y2 = 0.5 + 0.5 * Math.sin(rad);
|
|
68
|
+
const defs = [
|
|
69
|
+
<linearGradient
|
|
70
|
+
key={id}
|
|
71
|
+
id={id}
|
|
72
|
+
x1={`${x1 * 100}%`}
|
|
73
|
+
y1={`${y1 * 100}%`}
|
|
74
|
+
x2={`${x2 * 100}%`}
|
|
75
|
+
y2={`${y2 * 100}%`}
|
|
76
|
+
>
|
|
77
|
+
{fill.stops.map((s, i) => (
|
|
78
|
+
<stop
|
|
79
|
+
key={i}
|
|
80
|
+
offset={s.offset}
|
|
81
|
+
stopColor={s.color}
|
|
82
|
+
{...(s.opacity !== undefined ? { stopOpacity: s.opacity } : {})}
|
|
83
|
+
/>
|
|
84
|
+
))}
|
|
85
|
+
</linearGradient>,
|
|
86
|
+
];
|
|
87
|
+
return { defs, ref: `url(#${id})` };
|
|
88
|
+
}
|
|
89
|
+
// radial-gradient
|
|
90
|
+
const cx = fill.center?.x ?? 0.5;
|
|
91
|
+
const cy = fill.center?.y ?? 0.5;
|
|
92
|
+
const r = fill.radius ?? 0.5;
|
|
93
|
+
const defs = [
|
|
94
|
+
<radialGradient key={id} id={id} cx={`${cx * 100}%`} cy={`${cy * 100}%`} r={`${r * 100}%`}>
|
|
95
|
+
{fill.stops.map((s, i) => (
|
|
96
|
+
<stop
|
|
97
|
+
key={i}
|
|
98
|
+
offset={s.offset}
|
|
99
|
+
stopColor={s.color}
|
|
100
|
+
{...(s.opacity !== undefined ? { stopOpacity: s.opacity } : {})}
|
|
101
|
+
/>
|
|
102
|
+
))}
|
|
103
|
+
</radialGradient>,
|
|
104
|
+
];
|
|
105
|
+
return { defs, ref: `url(#${id})` };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Compile an array of Fill into a CSS `background-image` value usable
|
|
109
|
+
* on a `<div>` (frame backgrounds — non-SVG context). Returns the CSS
|
|
110
|
+
* string + opacity. Stops use percentages in CSS gradient syntax. */
|
|
111
|
+
export function backgroundsToCss(fills: Fill[]): CSSProperties {
|
|
112
|
+
// Per §4.12, fills[0] renders on top — CSS background-image stacks
|
|
113
|
+
// first → top-most. Match by passing in the same order.
|
|
114
|
+
const layers = fills.map(fillToCss).filter(Boolean) as string[];
|
|
115
|
+
if (layers.length === 0) return {};
|
|
116
|
+
return { backgroundImage: layers.join(", ") };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function fillToCss(fill: Fill): string | null {
|
|
120
|
+
if (fill.kind === "solid") {
|
|
121
|
+
// Wrap solid in linear-gradient so it can stack with other layers.
|
|
122
|
+
return `linear-gradient(${fill.color}, ${fill.color})`;
|
|
123
|
+
}
|
|
124
|
+
const stops = fill.stops
|
|
125
|
+
.map((s) => {
|
|
126
|
+
const c = s.opacity !== undefined ? cssWithOpacity(s.color, s.opacity) : s.color;
|
|
127
|
+
return `${c} ${(s.offset * 100).toFixed(2)}%`;
|
|
128
|
+
})
|
|
129
|
+
.join(", ");
|
|
130
|
+
if (fill.kind === "linear-gradient") {
|
|
131
|
+
const angle = fill.angle_deg ?? 0;
|
|
132
|
+
return `linear-gradient(${angle}deg, ${stops})`;
|
|
133
|
+
}
|
|
134
|
+
// radial-gradient
|
|
135
|
+
const cx = (fill.center?.x ?? 0.5) * 100;
|
|
136
|
+
const cy = (fill.center?.y ?? 0.5) * 100;
|
|
137
|
+
return `radial-gradient(circle at ${cx}% ${cy}%, ${stops})`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function cssWithOpacity(color: string, opacity: number): string {
|
|
141
|
+
// Best-effort wrapper — for hex/rgb we can append alpha. For
|
|
142
|
+
// unrecognised forms, fall back to color-mix.
|
|
143
|
+
const hex = color.match(/^#([0-9a-f]{6})$/i);
|
|
144
|
+
if (hex) {
|
|
145
|
+
const a = Math.round(opacity * 255)
|
|
146
|
+
.toString(16)
|
|
147
|
+
.padStart(2, "0");
|
|
148
|
+
return `#${hex[1]}${a}`;
|
|
149
|
+
}
|
|
150
|
+
return `color-mix(in srgb, ${color} ${opacity * 100}%, transparent)`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Coerce loose JSON into a Fill array. Returns [] for non-arrays. */
|
|
154
|
+
export function parseFills(value: unknown): Fill[] {
|
|
155
|
+
if (!Array.isArray(value)) return [];
|
|
156
|
+
return value.filter(isFill) as Fill[];
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isFill(v: unknown): v is Fill {
|
|
160
|
+
if (typeof v !== "object" || v === null) return false;
|
|
161
|
+
const k = (v as { kind?: unknown }).kind;
|
|
162
|
+
return k === "solid" || k === "linear-gradient" || k === "radial-gradient";
|
|
163
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
// LSML 1.1 §6.6 — keyframe sequence playback wrapper.
|
|
2
|
+
//
|
|
3
|
+
// Wraps a primitive subtree in a framer-motion `motion.div` that plays
|
|
4
|
+
// out the compiled keyframe arrays once on (re)mount, or whenever the
|
|
5
|
+
// bound `key` LeafPath changes. We trigger replay via React's `key=`
|
|
6
|
+
// reconciliation — bumping a counter when the keyframe key value flips
|
|
7
|
+
// remounts the motion subtree, restarting the animation from `at: 0`.
|
|
8
|
+
//
|
|
9
|
+
// LSML 1.1 §6.7 — when this player runs inside a `repeat` iteration, a
|
|
10
|
+
// `staggerDelay` (ms) is provided through `StaggerContext` and added to
|
|
11
|
+
// framer's transition.delay so each iteration starts `index * stagger_ms`
|
|
12
|
+
// after the previous one.
|
|
13
|
+
|
|
14
|
+
import { motion } from "framer-motion";
|
|
15
|
+
import { useContext, useEffect, useRef, type ReactNode } from "react";
|
|
16
|
+
import { useSignals } from "@preact/signals-react/runtime";
|
|
17
|
+
import type { Store } from "../state/store";
|
|
18
|
+
import { compileForFramer, type Keyframes } from "../animate/keyframes";
|
|
19
|
+
import { StaggerContext } from "./stagger-context";
|
|
20
|
+
import { scopedPath, usePathScope } from "./scope";
|
|
21
|
+
|
|
22
|
+
export interface KeyframePlayerProps {
|
|
23
|
+
keyframes: Keyframes;
|
|
24
|
+
store: Store;
|
|
25
|
+
children: ReactNode;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function KeyframePlayer({ keyframes, store, children }: KeyframePlayerProps): ReactNode {
|
|
29
|
+
useSignals();
|
|
30
|
+
const scope = usePathScope();
|
|
31
|
+
const staggerDelayMs = useContext(StaggerContext);
|
|
32
|
+
|
|
33
|
+
// Pull the latest `key` LeafPath value and remount whenever it
|
|
34
|
+
// changes. We track via a ref + counter so React's reconciliation
|
|
35
|
+
// gives us a fresh motion.div (and thus a fresh animation pass).
|
|
36
|
+
const lastKeyValue = useRef<unknown>(undefined);
|
|
37
|
+
const replayTokenRef = useRef(0);
|
|
38
|
+
if (keyframes.key !== undefined) {
|
|
39
|
+
const v = store.signal(scopedPath(scope, keyframes.key)).value;
|
|
40
|
+
if (lastKeyValue.current !== v) {
|
|
41
|
+
lastKeyValue.current = v;
|
|
42
|
+
replayTokenRef.current += 1;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const compiled = compileForFramer(keyframes);
|
|
47
|
+
if (!compiled) {
|
|
48
|
+
return <>{children}</>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const transition =
|
|
52
|
+
staggerDelayMs > 0
|
|
53
|
+
? { ...compiled.transition, delay: staggerDelayMs / 1000 }
|
|
54
|
+
: compiled.transition;
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<motion.div
|
|
58
|
+
key={replayTokenRef.current}
|
|
59
|
+
style={{ display: "contents" }}
|
|
60
|
+
initial={firstFrame(compiled.animate)}
|
|
61
|
+
animate={compiled.animate}
|
|
62
|
+
transition={transition}
|
|
63
|
+
>
|
|
64
|
+
<ReplayOnMount />
|
|
65
|
+
{children}
|
|
66
|
+
</motion.div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** No-op effect placeholder — kept for symmetry / future hooks like
|
|
71
|
+
* reporting playback completion to the renderer. */
|
|
72
|
+
function ReplayOnMount(): null {
|
|
73
|
+
useEffect(() => {
|
|
74
|
+
// intentional no-op
|
|
75
|
+
}, []);
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Pluck the `at: 0` waypoint values into a framer-motion `initial` prop
|
|
80
|
+
* so the very first frame matches the start of the keyframe path. Without
|
|
81
|
+
* this, framer interpolates from the element's current style which can
|
|
82
|
+
* produce a visible jump on mount. */
|
|
83
|
+
function firstFrame(animate: Record<string, (number | string)[]>): Record<string, number | string> {
|
|
84
|
+
const out: Record<string, number | string> = {};
|
|
85
|
+
for (const [k, arr] of Object.entries(animate)) {
|
|
86
|
+
if (arr.length > 0) out[k] = arr[0];
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { motion } from "framer-motion";
|
|
2
|
+
import type { CSSProperties } from "react";
|
|
3
|
+
import type { PrimitiveProps } from "./index";
|
|
4
|
+
import { toFramer } from "../../animate/transitions";
|
|
5
|
+
import { backgroundsToCss, parseFills } from "../fill";
|
|
6
|
+
|
|
7
|
+
/** Absolute-positioned container with size + transform + opacity.
|
|
8
|
+
* Animatable on `transform` and `opacity` only — width/height/position
|
|
9
|
+
* changes are intentionally *not* animatable to keep the broadcast
|
|
10
|
+
* off the layout path.
|
|
11
|
+
*
|
|
12
|
+
* LSML 1.1 §4.3 + §4.12 add `backgrounds[]` as an alternative to the
|
|
13
|
+
* legacy `background` (single color). The array form supports stacked
|
|
14
|
+
* fills with linear / radial gradients ; first entry renders on top.
|
|
15
|
+
*/
|
|
16
|
+
export function Frame({ resolved, transitionFor, children }: PrimitiveProps) {
|
|
17
|
+
const x = numberOr(resolved.x, 0);
|
|
18
|
+
const y = numberOr(resolved.y, 0);
|
|
19
|
+
const width = sizeProp(resolved.width);
|
|
20
|
+
const height = sizeProp(resolved.height);
|
|
21
|
+
const opacity = numberOr(resolved.opacity, 1);
|
|
22
|
+
const scale = numberOr(resolved.scale, 1);
|
|
23
|
+
const rotate = numberOr(resolved.rotate, 0);
|
|
24
|
+
|
|
25
|
+
// 1.0 single-fill prop — used as fallback when 1.1 `backgrounds[]`
|
|
26
|
+
// is empty.
|
|
27
|
+
const legacyBackground = (resolved.background as string | undefined) ?? undefined;
|
|
28
|
+
const backgrounds = parseFills(resolved.backgrounds);
|
|
29
|
+
|
|
30
|
+
// Pick the most expressive declared transition among the animated
|
|
31
|
+
// bindings (transform / opacity). If none, no animation.
|
|
32
|
+
const tx =
|
|
33
|
+
transitionFor("opacity") ??
|
|
34
|
+
transitionFor("scale") ??
|
|
35
|
+
transitionFor("rotate") ??
|
|
36
|
+
transitionFor("x") ??
|
|
37
|
+
transitionFor("y");
|
|
38
|
+
|
|
39
|
+
const style: CSSProperties = {
|
|
40
|
+
position: "absolute",
|
|
41
|
+
left: 0,
|
|
42
|
+
top: 0,
|
|
43
|
+
width,
|
|
44
|
+
height,
|
|
45
|
+
willChange: "transform, opacity",
|
|
46
|
+
};
|
|
47
|
+
if (backgrounds.length > 0) {
|
|
48
|
+
Object.assign(style, backgroundsToCss(backgrounds));
|
|
49
|
+
} else if (legacyBackground !== undefined) {
|
|
50
|
+
style.background = legacyBackground;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<motion.div
|
|
55
|
+
style={style}
|
|
56
|
+
animate={{
|
|
57
|
+
opacity,
|
|
58
|
+
x,
|
|
59
|
+
y,
|
|
60
|
+
scale,
|
|
61
|
+
rotate,
|
|
62
|
+
}}
|
|
63
|
+
transition={toFramer(tx)}
|
|
64
|
+
>
|
|
65
|
+
{children}
|
|
66
|
+
</motion.div>
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function numberOr(v: unknown, fallback: number): number {
|
|
71
|
+
return typeof v === "number" && Number.isFinite(v) ? v : fallback;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function sizeProp(v: unknown): number | string | undefined {
|
|
75
|
+
if (typeof v === "number" && Number.isFinite(v)) return v;
|
|
76
|
+
if (typeof v === "string" && v.length > 0) return v;
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { PrimitiveProps } from "./index";
|
|
2
|
+
|
|
3
|
+
/** CSS Grid container with declared rows / cols. */
|
|
4
|
+
export function Grid({ resolved, children }: PrimitiveProps) {
|
|
5
|
+
const cols = (resolved.cols as string) ?? "1fr";
|
|
6
|
+
const rows = (resolved.rows as string) ?? "auto";
|
|
7
|
+
const gap = (resolved.gap as number | string | undefined) ?? 0;
|
|
8
|
+
return (
|
|
9
|
+
<div
|
|
10
|
+
style={{
|
|
11
|
+
display: "grid",
|
|
12
|
+
gridTemplateColumns: cols,
|
|
13
|
+
gridTemplateRows: rows,
|
|
14
|
+
gap,
|
|
15
|
+
}}
|
|
16
|
+
>
|
|
17
|
+
{children}
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { motion } from "framer-motion";
|
|
2
|
+
import type { PrimitiveProps } from "./index";
|
|
3
|
+
import { toFramer } from "../../animate/transitions";
|
|
4
|
+
|
|
5
|
+
/** Image leaf. `src`, `fit` (cover/contain/fill), `position`,
|
|
6
|
+
* `opacity`. Opacity is animated when a transition is declared. */
|
|
7
|
+
export function Image({ resolved, transitionFor }: PrimitiveProps) {
|
|
8
|
+
const src = resolved.src as string | undefined;
|
|
9
|
+
if (!src) return null;
|
|
10
|
+
const fit = (resolved.fit as string | undefined) ?? "contain";
|
|
11
|
+
const position = (resolved.position as string | undefined) ?? "center";
|
|
12
|
+
const opacity = numberOr(resolved.opacity, 1);
|
|
13
|
+
|
|
14
|
+
const tx = transitionFor("opacity") ?? transitionFor("src");
|
|
15
|
+
|
|
16
|
+
return (
|
|
17
|
+
<motion.img
|
|
18
|
+
src={src}
|
|
19
|
+
style={{
|
|
20
|
+
objectFit: fit as React.CSSProperties["objectFit"],
|
|
21
|
+
objectPosition: position,
|
|
22
|
+
width: "100%",
|
|
23
|
+
height: "100%",
|
|
24
|
+
willChange: "opacity",
|
|
25
|
+
}}
|
|
26
|
+
animate={{ opacity }}
|
|
27
|
+
transition={toFramer(tx)}
|
|
28
|
+
draggable={false}
|
|
29
|
+
/>
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function numberOr(v: unknown, fallback: number): number {
|
|
34
|
+
return typeof v === "number" && Number.isFinite(v) ? v : fallback;
|
|
35
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Primitive component registry. Tree dispatch uses this map to look
|
|
2
|
+
// up the React component for each `kind` ; user components are inlined
|
|
3
|
+
// at compile time so Lumencast's runtime never sees them.
|
|
4
|
+
|
|
5
|
+
import type { ComponentType, ReactNode } from "react";
|
|
6
|
+
import type { RenderKind } from "../bundle";
|
|
7
|
+
import type { Transition } from "../../animate/transitions";
|
|
8
|
+
import { Stack } from "./stack";
|
|
9
|
+
import { Grid } from "./grid";
|
|
10
|
+
import { Frame } from "./frame";
|
|
11
|
+
import { Text } from "./text";
|
|
12
|
+
import { Image } from "./image";
|
|
13
|
+
import { Shape } from "./shape";
|
|
14
|
+
import { Media } from "./media";
|
|
15
|
+
import { Instance } from "./instance";
|
|
16
|
+
// `repeat` is dispatched specially in the tree (it iterates a bound
|
|
17
|
+
// array and provides a path scope to its children) ; it does not
|
|
18
|
+
// appear here as a regular primitive.
|
|
19
|
+
|
|
20
|
+
export interface PrimitiveProps {
|
|
21
|
+
resolved: Record<string, unknown>;
|
|
22
|
+
transitionFor: (key: string) => Transition | undefined;
|
|
23
|
+
children?: ReactNode;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export const PRIMITIVES: Partial<Record<RenderKind, ComponentType<PrimitiveProps>>> = {
|
|
27
|
+
stack: Stack,
|
|
28
|
+
grid: Grid,
|
|
29
|
+
frame: Frame,
|
|
30
|
+
text: Text,
|
|
31
|
+
image: Image,
|
|
32
|
+
shape: Shape,
|
|
33
|
+
media: Media,
|
|
34
|
+
instance: Instance,
|
|
35
|
+
};
|