@velumo/runtime 0.1.0-beta.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 (63) hide show
  1. package/dist/action-dispatcher.d.ts +27 -0
  2. package/dist/action-dispatcher.d.ts.map +1 -0
  3. package/dist/action-dispatcher.js +34 -0
  4. package/dist/action-dispatcher.js.map +1 -0
  5. package/dist/camera.d.ts +51 -0
  6. package/dist/camera.d.ts.map +1 -0
  7. package/dist/camera.js +327 -0
  8. package/dist/camera.js.map +1 -0
  9. package/dist/controller.d.ts +33 -0
  10. package/dist/controller.d.ts.map +1 -0
  11. package/dist/controller.js +109 -0
  12. package/dist/controller.js.map +1 -0
  13. package/dist/cue-dispatcher.d.ts +76 -0
  14. package/dist/cue-dispatcher.d.ts.map +1 -0
  15. package/dist/cue-dispatcher.js +164 -0
  16. package/dist/cue-dispatcher.js.map +1 -0
  17. package/dist/hash.d.ts +8 -0
  18. package/dist/hash.d.ts.map +1 -0
  19. package/dist/hash.js +27 -0
  20. package/dist/hash.js.map +1 -0
  21. package/dist/index.d.ts +13 -0
  22. package/dist/index.d.ts.map +1 -0
  23. package/dist/index.js +13 -0
  24. package/dist/index.js.map +1 -0
  25. package/dist/notes.d.ts +32 -0
  26. package/dist/notes.d.ts.map +1 -0
  27. package/dist/notes.js +132 -0
  28. package/dist/notes.js.map +1 -0
  29. package/dist/registry.d.ts +32 -0
  30. package/dist/registry.d.ts.map +1 -0
  31. package/dist/registry.js +76 -0
  32. package/dist/registry.js.map +1 -0
  33. package/dist/scene-player.d.ts +40 -0
  34. package/dist/scene-player.d.ts.map +1 -0
  35. package/dist/scene-player.js +177 -0
  36. package/dist/scene-player.js.map +1 -0
  37. package/dist/spatial.d.ts +11 -0
  38. package/dist/spatial.d.ts.map +1 -0
  39. package/dist/spatial.js +28 -0
  40. package/dist/spatial.js.map +1 -0
  41. package/dist/theme-bundle.d.ts +32 -0
  42. package/dist/theme-bundle.d.ts.map +1 -0
  43. package/dist/theme-bundle.js +8 -0
  44. package/dist/theme-bundle.js.map +1 -0
  45. package/dist/timeline.d.ts +46 -0
  46. package/dist/timeline.d.ts.map +1 -0
  47. package/dist/timeline.js +217 -0
  48. package/dist/timeline.js.map +1 -0
  49. package/dist/tsconfig.tsbuildinfo +1 -0
  50. package/package.json +19 -0
  51. package/src/action-dispatcher.ts +81 -0
  52. package/src/camera.ts +513 -0
  53. package/src/controller.ts +171 -0
  54. package/src/cue-dispatcher.ts +299 -0
  55. package/src/hash.ts +39 -0
  56. package/src/index.ts +12 -0
  57. package/src/notes.ts +194 -0
  58. package/src/registry.ts +194 -0
  59. package/src/scene-player.ts +244 -0
  60. package/src/spatial.ts +40 -0
  61. package/src/theme-bundle.ts +35 -0
  62. package/src/timeline.ts +323 -0
  63. package/tsconfig.json +10 -0
@@ -0,0 +1,194 @@
1
+ import type { Manifest } from "@velumo/core";
2
+
3
+ export type LayerRenderer<TContext = unknown, TResult = unknown> = (
4
+ context: TContext,
5
+ ) => TResult;
6
+
7
+ export type ActionHandler<TContext = unknown> = (context: TContext) => void;
8
+
9
+ export type ManifestTransform = (manifest: Manifest) => Manifest;
10
+
11
+ export interface PluginSetupContext<
12
+ TLayerContext = unknown,
13
+ TLayerResult = unknown,
14
+ TActionContext = unknown,
15
+ > {
16
+ readonly registry: RuntimeRegistry<
17
+ TLayerContext,
18
+ TLayerResult,
19
+ TActionContext
20
+ >;
21
+ readonly manifest: Manifest;
22
+ }
23
+
24
+ export interface VelumoPlugin<
25
+ TLayerContext = unknown,
26
+ TLayerResult = unknown,
27
+ TActionContext = unknown,
28
+ > {
29
+ readonly id: string;
30
+ setup?(
31
+ context: PluginSetupContext<TLayerContext, TLayerResult, TActionContext>,
32
+ ): void;
33
+ }
34
+
35
+ export interface InstallPluginsOptions<
36
+ TLayerContext = unknown,
37
+ TLayerResult = unknown,
38
+ TActionContext = unknown,
39
+ > {
40
+ readonly registry: RuntimeRegistry<
41
+ TLayerContext,
42
+ TLayerResult,
43
+ TActionContext
44
+ >;
45
+ readonly manifest: Manifest;
46
+ readonly plugins: readonly VelumoPlugin<
47
+ TLayerContext,
48
+ TLayerResult,
49
+ TActionContext
50
+ >[];
51
+ }
52
+
53
+ export class RuntimeRegistry<
54
+ TLayerContext = unknown,
55
+ TLayerResult = unknown,
56
+ TActionContext = unknown,
57
+ > {
58
+ readonly #layerRenderers = new Map<
59
+ string,
60
+ LayerRenderer<TLayerContext, TLayerResult>
61
+ >();
62
+ readonly #actionHandlers = new Map<string, ActionHandler<TActionContext>>();
63
+ readonly #manifestTransforms: ManifestTransform[] = [];
64
+ readonly #installedPluginIds = new Set<string>();
65
+
66
+ registerLayerRenderer(
67
+ type: string,
68
+ renderer: LayerRenderer<TLayerContext, TLayerResult>,
69
+ ): void {
70
+ if (this.#layerRenderers.has(type)) {
71
+ throw new Error(`Layer renderer already registered: ${type}`);
72
+ }
73
+
74
+ this.#layerRenderers.set(type, renderer);
75
+ }
76
+
77
+ hasLayerRenderer(type: string): boolean {
78
+ return this.#layerRenderers.has(type);
79
+ }
80
+
81
+ getLayerRenderer(
82
+ type: string,
83
+ ): LayerRenderer<TLayerContext, TLayerResult> | undefined {
84
+ return this.#layerRenderers.get(type);
85
+ }
86
+
87
+ registerActionHandler(
88
+ type: string,
89
+ handler: ActionHandler<TActionContext>,
90
+ ): void {
91
+ if (this.#actionHandlers.has(type)) {
92
+ throw new Error(`Action handler already registered: ${type}`);
93
+ }
94
+
95
+ this.#actionHandlers.set(type, handler);
96
+ }
97
+
98
+ hasActionHandler(type: string): boolean {
99
+ return this.#actionHandlers.has(type);
100
+ }
101
+
102
+ getActionHandler(type: string): ActionHandler<TActionContext> | undefined {
103
+ return this.#actionHandlers.get(type);
104
+ }
105
+
106
+ registerManifestTransform(transform: ManifestTransform): void {
107
+ this.#manifestTransforms.push(transform);
108
+ }
109
+
110
+ applyManifestTransforms(manifest: Manifest): Manifest {
111
+ return this.#manifestTransforms.reduce(
112
+ (current, transform) => transform(current),
113
+ cloneManifest(manifest),
114
+ );
115
+ }
116
+
117
+ installPlugins(
118
+ manifest: Manifest,
119
+ plugins: readonly VelumoPlugin<
120
+ TLayerContext,
121
+ TLayerResult,
122
+ TActionContext
123
+ >[],
124
+ ): void {
125
+ this.#assertPluginsCanInstall(plugins);
126
+
127
+ for (const plugin of plugins) {
128
+ plugin.setup?.({ registry: this, manifest });
129
+ this.#installedPluginIds.add(plugin.id);
130
+ }
131
+ }
132
+
133
+ #assertPluginsCanInstall(
134
+ plugins: readonly VelumoPlugin<
135
+ TLayerContext,
136
+ TLayerResult,
137
+ TActionContext
138
+ >[],
139
+ ): void {
140
+ const nextPluginIds = new Set<string>();
141
+
142
+ for (const plugin of plugins) {
143
+ if (
144
+ this.#installedPluginIds.has(plugin.id) ||
145
+ nextPluginIds.has(plugin.id)
146
+ ) {
147
+ throw new Error(`Plugin already installed: ${plugin.id}`);
148
+ }
149
+
150
+ nextPluginIds.add(plugin.id);
151
+ }
152
+ }
153
+ }
154
+
155
+ export function createRuntimeRegistry<
156
+ TLayerContext = unknown,
157
+ TLayerResult = unknown,
158
+ TActionContext = unknown,
159
+ >(): RuntimeRegistry<TLayerContext, TLayerResult, TActionContext> {
160
+ return new RuntimeRegistry<TLayerContext, TLayerResult, TActionContext>();
161
+ }
162
+
163
+ export function installPlugins<
164
+ TLayerContext = unknown,
165
+ TLayerResult = unknown,
166
+ TActionContext = unknown,
167
+ >(
168
+ options: InstallPluginsOptions<TLayerContext, TLayerResult, TActionContext>,
169
+ ): void {
170
+ options.registry.installPlugins(options.manifest, options.plugins);
171
+ }
172
+
173
+ function cloneManifest(manifest: Manifest): Manifest {
174
+ const slides =
175
+ manifest.slides === undefined ? {} : { slides: [...manifest.slides] };
176
+ const assets =
177
+ manifest.assets === undefined ? {} : { assets: [...manifest.assets] };
178
+ const plugins =
179
+ manifest.plugins === undefined ? {} : { plugins: [...manifest.plugins] };
180
+ const theme =
181
+ manifest.theme === undefined ? {} : { theme: { ...manifest.theme } };
182
+ const exportOptions =
183
+ manifest.export === undefined ? {} : { export: { ...manifest.export } };
184
+
185
+ return {
186
+ ...manifest,
187
+ deck: { ...manifest.deck },
188
+ ...slides,
189
+ ...assets,
190
+ ...plugins,
191
+ ...theme,
192
+ ...exportOptions,
193
+ };
194
+ }
@@ -0,0 +1,244 @@
1
+ import type { CameraEasing, CameraStop, CameraView, Cue } from "@velumo/core";
2
+ import { type ResolvedView, resolveView } from "./spatial.js";
3
+ import { TimelineScheduler } from "./timeline.js";
4
+
5
+ const DEFAULT_DURATION_MS = 800;
6
+ const DEFAULT_EASING: CameraEasing = "ease-in-out";
7
+ const FINALIZE_SEEK_MS = Number.MAX_SAFE_INTEGER;
8
+
9
+ export type ScenePlayerDirection = "forward" | "backward";
10
+ export type ScenePlayerMove = "moved" | "exhausted";
11
+
12
+ export type ScenePlayerEventName = "camera:move" | "cue:fired" | "stop:enter";
13
+
14
+ export interface ScenePlayerCameraMoveEvent {
15
+ readonly type: "camera:move";
16
+ readonly view: ResolvedView;
17
+ readonly durationMs: number;
18
+ readonly easing: CameraEasing;
19
+ }
20
+
21
+ export interface ScenePlayerCueFiredEvent {
22
+ readonly type: "cue:fired";
23
+ readonly cue: Cue;
24
+ readonly stopIndex: number;
25
+ }
26
+
27
+ export interface ScenePlayerStopEnterEvent {
28
+ readonly type: "stop:enter";
29
+ readonly stopIndex: number;
30
+ }
31
+
32
+ export type ScenePlayerEvent =
33
+ | ScenePlayerCameraMoveEvent
34
+ | ScenePlayerCueFiredEvent
35
+ | ScenePlayerStopEnterEvent;
36
+
37
+ export type ScenePlayerEventHandler = (event: ScenePlayerEvent) => void;
38
+
39
+ export interface ScenePlayerOptions {
40
+ readonly stops: readonly CameraStop[];
41
+ readonly reducedMotion?: boolean;
42
+ }
43
+
44
+ function isCameraView(value: unknown): value is CameraView {
45
+ if (typeof value !== "object" || value === null) {
46
+ return false;
47
+ }
48
+ const v = value as { x?: unknown; y?: unknown; zoom?: unknown };
49
+ if (!Number.isFinite(v.x) || !Number.isFinite(v.y)) {
50
+ return false;
51
+ }
52
+ if (v.zoom !== undefined && !Number.isFinite(v.zoom)) {
53
+ return false;
54
+ }
55
+ return true;
56
+ }
57
+
58
+ export class ScenePlayer {
59
+ readonly #stops: readonly CameraStop[];
60
+ readonly #reducedMotion: boolean;
61
+ readonly #handlers = new Map<
62
+ ScenePlayerEventName,
63
+ Set<ScenePlayerEventHandler>
64
+ >();
65
+ #stopIndex = -1;
66
+ #view: ResolvedView = { x: 0, y: 0, zoom: 1 };
67
+ #timeline: TimelineScheduler | undefined;
68
+ #timelineCueCount = 0;
69
+ #elapsedSinceLand = 0;
70
+
71
+ constructor(options: ScenePlayerOptions) {
72
+ this.#stops = options.stops;
73
+ this.#reducedMotion = options.reducedMotion ?? false;
74
+ }
75
+
76
+ enter(direction: ScenePlayerDirection): void {
77
+ const index = direction === "forward" ? 0 : this.#stops.length - 1;
78
+ this.#land(index);
79
+ if (direction === "backward") {
80
+ this.#finalizeTimeline();
81
+ }
82
+ }
83
+
84
+ advance(): ScenePlayerMove {
85
+ if (this.#stopIndex >= this.#stops.length - 1) {
86
+ return "exhausted";
87
+ }
88
+ this.#finalizeTimeline();
89
+ this.#land(this.#stopIndex + 1);
90
+ return "moved";
91
+ }
92
+
93
+ back(): ScenePlayerMove {
94
+ if (this.#stopIndex <= 0) {
95
+ return "exhausted";
96
+ }
97
+ this.#land(this.#stopIndex - 1);
98
+ this.#finalizeTimeline();
99
+ return "moved";
100
+ }
101
+
102
+ tick(deltaMs: number): void {
103
+ if (this.#timeline !== undefined && deltaMs > 0) {
104
+ this.#timeline.advance(deltaMs);
105
+ }
106
+ this.#elapsedSinceLand += deltaMs;
107
+ const stop = this.#stops[this.#stopIndex];
108
+ if (
109
+ stop !== undefined &&
110
+ stop.hold !== undefined &&
111
+ this.#elapsedSinceLand >= stop.hold &&
112
+ this.#stopIndex < this.#stops.length - 1
113
+ ) {
114
+ this.advance();
115
+ }
116
+ }
117
+
118
+ getView(): ResolvedView {
119
+ return this.#view;
120
+ }
121
+
122
+ getStopIndex(): number {
123
+ return this.#stopIndex;
124
+ }
125
+
126
+ on(
127
+ eventName: ScenePlayerEventName,
128
+ handler: ScenePlayerEventHandler,
129
+ ): () => void {
130
+ let handlers = this.#handlers.get(eventName);
131
+ if (handlers === undefined) {
132
+ handlers = new Set();
133
+ this.#handlers.set(eventName, handlers);
134
+ }
135
+ handlers.add(handler);
136
+ return () => {
137
+ handlers.delete(handler);
138
+ };
139
+ }
140
+
141
+ #land(index: number): void {
142
+ const stop = this.#stops[index];
143
+ if (stop === undefined) {
144
+ return;
145
+ }
146
+ this.#stopIndex = index;
147
+ this.#elapsedSinceLand = 0;
148
+ this.#view = resolveView(stop.camera);
149
+ this.#emitCameraMove(stop.transition?.duration, stop.transition?.easing);
150
+ this.#emit({ type: "stop:enter", stopIndex: index });
151
+ this.#startTimeline(stop);
152
+ if (this.#reducedMotion) {
153
+ this.#finalizeTimeline();
154
+ }
155
+ }
156
+
157
+ isIdle(): boolean {
158
+ if (this.#stopIndex === -1) {
159
+ return true;
160
+ }
161
+ // Unfired cues remain: timeline exists and has not yet fired all cues.
162
+ if (this.#timeline !== undefined) {
163
+ const fired = this.#timeline.getState().firedCueIndices.length;
164
+ if (fired < this.#timelineCueCount) {
165
+ return false;
166
+ }
167
+ }
168
+ // Hold pending: not on the last stop and elapsed has not reached the hold.
169
+ const stop = this.#stops[this.#stopIndex];
170
+ if (
171
+ stop !== undefined &&
172
+ stop.hold !== undefined &&
173
+ this.#stopIndex < this.#stops.length - 1 &&
174
+ this.#elapsedSinceLand < stop.hold
175
+ ) {
176
+ return false;
177
+ }
178
+ return true;
179
+ }
180
+
181
+ #startTimeline(stop: CameraStop): void {
182
+ const cues = stop.cues ?? [];
183
+ if (cues.length === 0) {
184
+ this.#timeline = undefined;
185
+ this.#timelineCueCount = 0;
186
+ return;
187
+ }
188
+ const timeline = new TimelineScheduler({ cues, initialPlaying: true });
189
+ timeline.on("cue:fired", (event) => {
190
+ if (event.type !== "cue:fired") {
191
+ return;
192
+ }
193
+ this.#handleCue(event.cue);
194
+ });
195
+ this.#timeline = timeline;
196
+ this.#timelineCueCount = cues.length;
197
+ }
198
+
199
+ #finalizeTimeline(): void {
200
+ if (this.#timeline === undefined) {
201
+ return;
202
+ }
203
+ // Seek far beyond any cue time: every not-yet-fired cue fires now,
204
+ // applying its end-state, so a press mid-sequence leaves state consistent.
205
+ this.#timeline.advance(FINALIZE_SEEK_MS);
206
+ this.#timeline = undefined;
207
+ this.#timelineCueCount = 0;
208
+ }
209
+
210
+ #handleCue(cue: Cue): void {
211
+ if (cue.action.type === "camera" && isCameraView(cue.action.payload)) {
212
+ this.#view = resolveView(cue.action.payload);
213
+ this.#emitCameraMove(undefined, undefined);
214
+ return;
215
+ }
216
+ this.#emit({ type: "cue:fired", cue, stopIndex: this.#stopIndex });
217
+ }
218
+
219
+ #emitCameraMove(
220
+ duration: number | undefined,
221
+ easing: CameraEasing | undefined,
222
+ ): void {
223
+ this.#emit({
224
+ type: "camera:move",
225
+ view: this.#view,
226
+ durationMs: this.#reducedMotion ? 0 : (duration ?? DEFAULT_DURATION_MS),
227
+ easing: easing ?? DEFAULT_EASING,
228
+ });
229
+ }
230
+
231
+ #emit(event: ScenePlayerEvent): void {
232
+ const handlers = this.#handlers.get(event.type);
233
+ if (handlers === undefined) {
234
+ return;
235
+ }
236
+ for (const handler of [...handlers]) {
237
+ handler(event);
238
+ }
239
+ }
240
+ }
241
+
242
+ export function createScenePlayer(options: ScenePlayerOptions): ScenePlayer {
243
+ return new ScenePlayer(options);
244
+ }
package/src/spatial.ts ADDED
@@ -0,0 +1,40 @@
1
+ import type { CameraEasing, CameraView } from "@velumo/core";
2
+ import { type EasingFunction, easeInOutCubic } from "./timeline.js";
3
+
4
+ export const CAMERA_EASINGS: readonly CameraEasing[] = [
5
+ "linear",
6
+ "ease",
7
+ "ease-in",
8
+ "ease-out",
9
+ "ease-in-out",
10
+ ];
11
+
12
+ function easeIn(t: number): number {
13
+ return t * t;
14
+ }
15
+
16
+ function easeOut(t: number): number {
17
+ return 1 - (1 - t) * (1 - t);
18
+ }
19
+
20
+ const EASING_MAP: Record<CameraEasing, EasingFunction> = {
21
+ linear: (t) => t,
22
+ ease: easeInOutCubic,
23
+ "ease-in": easeIn,
24
+ "ease-out": easeOut,
25
+ "ease-in-out": easeInOutCubic,
26
+ };
27
+
28
+ export function resolveEasing(name: CameraEasing | undefined): EasingFunction {
29
+ return name === undefined ? easeInOutCubic : EASING_MAP[name];
30
+ }
31
+
32
+ export interface ResolvedView {
33
+ readonly x: number;
34
+ readonly y: number;
35
+ readonly zoom: number;
36
+ }
37
+
38
+ export function resolveView(view: CameraView): ResolvedView {
39
+ return { x: view.x, y: view.y, zoom: view.zoom ?? 1 };
40
+ }
@@ -0,0 +1,35 @@
1
+ import type { Theme } from "@velumo/core";
2
+ import type { VelumoPlugin } from "./registry.js";
3
+
4
+ /**
5
+ * A theme bundle is a heterogeneous container: it carries plugin factories
6
+ * whose internal renderer/action context types it deliberately does not track.
7
+ * Plugins are loosely coupled at registration — the registry stores renderers
8
+ * keyed by layer type and enforces context-correctness there — so the bundle
9
+ * uses `any` for the plugin generics to stay ergonomic for any host.
10
+ */
11
+ type AnyVelumoPlugin = VelumoPlugin<any, any, any>;
12
+
13
+ /**
14
+ * A host-side convenience that pairs a theme with everything it needs to run:
15
+ * its theme tokens, its CSS, and a factory for the plugins it depends on. A
16
+ * theme package can export one `ThemeBundle` so an author wires a single import
17
+ * instead of three (theme + css + plugin).
18
+ *
19
+ * Plugins stay explicit and host-controlled: the bundle only *carries* a plugin
20
+ * factory — the host still calls it and registers the result. The runtime never
21
+ * auto-loads plugins from a manifest or config reference.
22
+ */
23
+ export interface ThemeBundle {
24
+ readonly theme: Theme;
25
+ readonly css?: string;
26
+ readonly createPlugins?: () => readonly AnyVelumoPlugin[];
27
+ }
28
+
29
+ /**
30
+ * Resolve a bundle's plugins. Returns a fresh array (empty when the bundle
31
+ * brings no plugins) so the result is safe for the host to register directly.
32
+ */
33
+ export function themePlugins(bundle: ThemeBundle): readonly AnyVelumoPlugin[] {
34
+ return bundle.createPlugins?.() ?? [];
35
+ }