@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.
- package/dist/action-dispatcher.d.ts +27 -0
- package/dist/action-dispatcher.d.ts.map +1 -0
- package/dist/action-dispatcher.js +34 -0
- package/dist/action-dispatcher.js.map +1 -0
- package/dist/camera.d.ts +51 -0
- package/dist/camera.d.ts.map +1 -0
- package/dist/camera.js +327 -0
- package/dist/camera.js.map +1 -0
- package/dist/controller.d.ts +33 -0
- package/dist/controller.d.ts.map +1 -0
- package/dist/controller.js +109 -0
- package/dist/controller.js.map +1 -0
- package/dist/cue-dispatcher.d.ts +76 -0
- package/dist/cue-dispatcher.d.ts.map +1 -0
- package/dist/cue-dispatcher.js +164 -0
- package/dist/cue-dispatcher.js.map +1 -0
- package/dist/hash.d.ts +8 -0
- package/dist/hash.d.ts.map +1 -0
- package/dist/hash.js +27 -0
- package/dist/hash.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/notes.d.ts +32 -0
- package/dist/notes.d.ts.map +1 -0
- package/dist/notes.js +132 -0
- package/dist/notes.js.map +1 -0
- package/dist/registry.d.ts +32 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +76 -0
- package/dist/registry.js.map +1 -0
- package/dist/scene-player.d.ts +40 -0
- package/dist/scene-player.d.ts.map +1 -0
- package/dist/scene-player.js +177 -0
- package/dist/scene-player.js.map +1 -0
- package/dist/spatial.d.ts +11 -0
- package/dist/spatial.d.ts.map +1 -0
- package/dist/spatial.js +28 -0
- package/dist/spatial.js.map +1 -0
- package/dist/theme-bundle.d.ts +32 -0
- package/dist/theme-bundle.d.ts.map +1 -0
- package/dist/theme-bundle.js +8 -0
- package/dist/theme-bundle.js.map +1 -0
- package/dist/timeline.d.ts +46 -0
- package/dist/timeline.d.ts.map +1 -0
- package/dist/timeline.js +217 -0
- package/dist/timeline.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -0
- package/package.json +19 -0
- package/src/action-dispatcher.ts +81 -0
- package/src/camera.ts +513 -0
- package/src/controller.ts +171 -0
- package/src/cue-dispatcher.ts +299 -0
- package/src/hash.ts +39 -0
- package/src/index.ts +12 -0
- package/src/notes.ts +194 -0
- package/src/registry.ts +194 -0
- package/src/scene-player.ts +244 -0
- package/src/spatial.ts +40 -0
- package/src/theme-bundle.ts +35 -0
- package/src/timeline.ts +323 -0
- package/tsconfig.json +10 -0
package/src/timeline.ts
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import type { Cue } from "@velumo/core";
|
|
2
|
+
|
|
3
|
+
const ERROR_PREFIX = "TimelineScheduler: ";
|
|
4
|
+
|
|
5
|
+
function validateNonNegativeFinite(value: number, field: string): void {
|
|
6
|
+
if (!Number.isFinite(value)) {
|
|
7
|
+
throw new Error(
|
|
8
|
+
`${ERROR_PREFIX}${field} must be a finite number; got ${String(value)}`,
|
|
9
|
+
);
|
|
10
|
+
}
|
|
11
|
+
if (value < 0) {
|
|
12
|
+
throw new Error(
|
|
13
|
+
`${ERROR_PREFIX}${field} must be non-negative; got ${value}`,
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function validateDuration(value: number | undefined): void {
|
|
19
|
+
if (value === undefined) {
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
validateNonNegativeFinite(value, "duration");
|
|
23
|
+
if (value === 0) {
|
|
24
|
+
throw new Error(`${ERROR_PREFIX}duration must be greater than 0; got 0`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function validateInitialTimeMs(
|
|
29
|
+
value: number | undefined,
|
|
30
|
+
duration: number | undefined,
|
|
31
|
+
): void {
|
|
32
|
+
if (value === undefined) {
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
validateNonNegativeFinite(value, "initialTimeMs");
|
|
36
|
+
if (duration !== undefined && value > duration) {
|
|
37
|
+
throw new Error(
|
|
38
|
+
`${ERROR_PREFIX}initialTimeMs (${value}) exceeds duration (${duration})`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function validateCues(
|
|
44
|
+
cues: readonly Cue[] | undefined,
|
|
45
|
+
duration: number | undefined,
|
|
46
|
+
): void {
|
|
47
|
+
if (cues === undefined) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
for (const cue of cues) {
|
|
51
|
+
validateNonNegativeFinite(cue.time, "cue.time");
|
|
52
|
+
if (duration !== undefined && cue.time > duration) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`${ERROR_PREFIX}cue.time (${cue.time}) exceeds duration (${duration}); the cue could never fire`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type TimelineEventName = "cue:fired" | "scene:end" | "state:change";
|
|
61
|
+
|
|
62
|
+
export interface TimelineCueFiredEvent {
|
|
63
|
+
readonly type: "cue:fired";
|
|
64
|
+
readonly timeMs: number;
|
|
65
|
+
readonly cueIndex: number;
|
|
66
|
+
readonly cue: Cue;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface TimelineSceneEndEvent {
|
|
70
|
+
readonly type: "scene:end";
|
|
71
|
+
readonly timeMs: number;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface TimelineStateChangeEvent {
|
|
75
|
+
readonly type: "state:change";
|
|
76
|
+
readonly state: TimelineState;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type TimelineEvent =
|
|
80
|
+
| TimelineCueFiredEvent
|
|
81
|
+
| TimelineSceneEndEvent
|
|
82
|
+
| TimelineStateChangeEvent;
|
|
83
|
+
|
|
84
|
+
export type TimelineEventHandler<E extends TimelineEvent = TimelineEvent> = (
|
|
85
|
+
event: E,
|
|
86
|
+
) => void;
|
|
87
|
+
|
|
88
|
+
export interface TimelineState {
|
|
89
|
+
readonly timeMs: number;
|
|
90
|
+
readonly isPlaying: boolean;
|
|
91
|
+
readonly firedCueIndices: readonly number[];
|
|
92
|
+
readonly sceneEnded: boolean;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface TimelineSchedulerOptions {
|
|
96
|
+
readonly cues?: readonly Cue[];
|
|
97
|
+
readonly duration?: number;
|
|
98
|
+
readonly initialTimeMs?: number;
|
|
99
|
+
readonly initialPlaying?: boolean;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export class TimelineScheduler {
|
|
103
|
+
readonly #cues: Cue[];
|
|
104
|
+
readonly #duration: number | undefined;
|
|
105
|
+
readonly #handlers = new Map<TimelineEventName, Set<TimelineEventHandler>>();
|
|
106
|
+
#timeMs: number;
|
|
107
|
+
#isPlaying: boolean;
|
|
108
|
+
readonly #firedCueIndices: Set<number>;
|
|
109
|
+
#sceneEnded: boolean;
|
|
110
|
+
|
|
111
|
+
constructor(options: TimelineSchedulerOptions = {}) {
|
|
112
|
+
validateDuration(options.duration);
|
|
113
|
+
validateCues(options.cues, options.duration);
|
|
114
|
+
validateInitialTimeMs(options.initialTimeMs, options.duration);
|
|
115
|
+
|
|
116
|
+
this.#duration = options.duration;
|
|
117
|
+
this.#cues = options.cues === undefined ? [] : [...options.cues];
|
|
118
|
+
this.#cues.sort((a, b) => a.time - b.time);
|
|
119
|
+
|
|
120
|
+
this.#timeMs = options.initialTimeMs ?? 0;
|
|
121
|
+
this.#isPlaying = options.initialPlaying ?? false;
|
|
122
|
+
this.#firedCueIndices = new Set<number>();
|
|
123
|
+
this.#sceneEnded =
|
|
124
|
+
this.#duration !== undefined && this.#timeMs === this.#duration;
|
|
125
|
+
|
|
126
|
+
if (this.#timeMs > 0) {
|
|
127
|
+
for (let i = 0; i < this.#cues.length; i += 1) {
|
|
128
|
+
if (this.#cues[i].time <= this.#timeMs) {
|
|
129
|
+
this.#firedCueIndices.add(i);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
getCues(): readonly Cue[] {
|
|
136
|
+
return [...this.#cues];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
getState(): TimelineState {
|
|
140
|
+
return {
|
|
141
|
+
timeMs: this.#timeMs,
|
|
142
|
+
isPlaying: this.#isPlaying,
|
|
143
|
+
firedCueIndices: [...this.#firedCueIndices].sort((a, b) => a - b),
|
|
144
|
+
sceneEnded: this.#sceneEnded,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
play(): void {
|
|
149
|
+
if (this.#isPlaying) {
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
this.#isPlaying = true;
|
|
153
|
+
this.#emitStateChange();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
pause(): void {
|
|
157
|
+
if (!this.#isPlaying) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
this.#isPlaying = false;
|
|
161
|
+
this.#emitStateChange();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
seek(timeMs: number): void {
|
|
165
|
+
if (!Number.isFinite(timeMs)) {
|
|
166
|
+
throw new Error(
|
|
167
|
+
`${ERROR_PREFIX}seek(timeMs): timeMs must be a finite number; got ${String(timeMs)}`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
if (timeMs < 0) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`${ERROR_PREFIX}seek(timeMs): timeMs must be non-negative; got ${timeMs}`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const clamped =
|
|
177
|
+
this.#duration !== undefined ? Math.min(timeMs, this.#duration) : timeMs;
|
|
178
|
+
this.#timeMs = clamped;
|
|
179
|
+
this.#firedCueIndices.clear();
|
|
180
|
+
|
|
181
|
+
// clamped > 0 leaves cue-at-time-0 unfired so the "cue at time === 0
|
|
182
|
+
// fires on first forward advance from 0" boundary rule still applies after seek(0).
|
|
183
|
+
if (clamped > 0) {
|
|
184
|
+
for (let i = 0; i < this.#cues.length; i += 1) {
|
|
185
|
+
if (this.#cues[i].time <= clamped) {
|
|
186
|
+
this.#firedCueIndices.add(i);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
this.#sceneEnded =
|
|
192
|
+
this.#duration !== undefined && clamped === this.#duration;
|
|
193
|
+
|
|
194
|
+
this.#emitStateChange();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
advance(deltaMs: number): void {
|
|
198
|
+
if (!Number.isFinite(deltaMs)) {
|
|
199
|
+
throw new Error(
|
|
200
|
+
`${ERROR_PREFIX}advance(deltaMs): deltaMs must be a finite number; got ${String(deltaMs)}`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
if (deltaMs < 0) {
|
|
204
|
+
throw new Error(
|
|
205
|
+
`${ERROR_PREFIX}advance(deltaMs): deltaMs must be non-negative; got ${deltaMs}`,
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (deltaMs === 0) {
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (!this.#isPlaying) {
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
if (this.#sceneEnded) {
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const prevTime = this.#timeMs;
|
|
220
|
+
let newTime = prevTime + deltaMs;
|
|
221
|
+
let willEnd = false;
|
|
222
|
+
|
|
223
|
+
if (this.#duration !== undefined && newTime >= this.#duration) {
|
|
224
|
+
newTime = this.#duration;
|
|
225
|
+
willEnd = true;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const cuesToFire: number[] = [];
|
|
229
|
+
for (let i = 0; i < this.#cues.length; i += 1) {
|
|
230
|
+
const cue = this.#cues[i];
|
|
231
|
+
if (this.#firedCueIndices.has(i)) {
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
const inRange =
|
|
235
|
+
cue.time <= newTime &&
|
|
236
|
+
(cue.time > prevTime || (prevTime === 0 && cue.time === 0));
|
|
237
|
+
if (inRange) {
|
|
238
|
+
cuesToFire.push(i);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
this.#timeMs = newTime;
|
|
243
|
+
if (willEnd) {
|
|
244
|
+
this.#sceneEnded = true;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
for (const cueIndex of cuesToFire) {
|
|
248
|
+
this.#firedCueIndices.add(cueIndex);
|
|
249
|
+
this.#emit("cue:fired", {
|
|
250
|
+
type: "cue:fired",
|
|
251
|
+
timeMs: newTime,
|
|
252
|
+
cueIndex,
|
|
253
|
+
cue: this.#cues[cueIndex],
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (willEnd) {
|
|
258
|
+
this.#emit("scene:end", {
|
|
259
|
+
type: "scene:end",
|
|
260
|
+
timeMs: newTime,
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
this.#emitStateChange();
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
on(eventName: TimelineEventName, handler: TimelineEventHandler): () => void {
|
|
268
|
+
let handlers = this.#handlers.get(eventName);
|
|
269
|
+
if (handlers === undefined) {
|
|
270
|
+
handlers = new Set();
|
|
271
|
+
this.#handlers.set(eventName, handlers);
|
|
272
|
+
}
|
|
273
|
+
handlers.add(handler);
|
|
274
|
+
|
|
275
|
+
let disposed = false;
|
|
276
|
+
return () => {
|
|
277
|
+
if (disposed) {
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
disposed = true;
|
|
281
|
+
handlers.delete(handler);
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
#emit(eventName: TimelineEventName, event: TimelineEvent): void {
|
|
286
|
+
const handlers = this.#handlers.get(eventName);
|
|
287
|
+
if (handlers === undefined) {
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
for (const handler of [...handlers]) {
|
|
291
|
+
handler(event);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
#emitStateChange(): void {
|
|
296
|
+
this.#emit("state:change", {
|
|
297
|
+
type: "state:change",
|
|
298
|
+
state: this.getState(),
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export function createTimelineScheduler(
|
|
304
|
+
options?: TimelineSchedulerOptions,
|
|
305
|
+
): TimelineScheduler {
|
|
306
|
+
return new TimelineScheduler(options);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export type EasingFunction = (t: number) => number;
|
|
310
|
+
|
|
311
|
+
const _timelineEasingTypeCheck: EasingFunction = (t) => t;
|
|
312
|
+
void _timelineEasingTypeCheck;
|
|
313
|
+
|
|
314
|
+
export function lerp(start: number, end: number, t: number): number {
|
|
315
|
+
return start + (end - start) * t;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function easeInOutCubic(t: number): number {
|
|
319
|
+
if (t < 0.5) {
|
|
320
|
+
return 4 * t ** 3;
|
|
321
|
+
}
|
|
322
|
+
return 1 - Math.pow(-2 * t + 2, 3) / 2;
|
|
323
|
+
}
|