@frame-by-frame/core 1.0.0-rc.1
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 +21 -0
- package/README.md +123 -0
- package/dist/canvas.d.ts +8 -0
- package/dist/canvas.d.ts.map +1 -0
- package/dist/canvas.js +535 -0
- package/dist/canvas.js.map +1 -0
- package/dist/index-DhdyeigP.d.ts +7 -0
- package/dist/index-DhdyeigP.d.ts.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +3 -0
- package/dist/public-controller-jGcw6iqQ.js +2825 -0
- package/dist/public-controller-jGcw6iqQ.js.map +1 -0
- package/dist/timeline-DDuRBPRJ.d.ts +17 -0
- package/dist/timeline-DDuRBPRJ.d.ts.map +1 -0
- package/dist/types-BYfMRnsj.d.ts +451 -0
- package/dist/types-BYfMRnsj.d.ts.map +1 -0
- package/dist/types.d.ts +2 -0
- package/dist/types.js +0 -0
- package/dist/video.d.ts +4 -0
- package/dist/video.js +4 -0
- package/package.json +98 -0
|
@@ -0,0 +1,2825 @@
|
|
|
1
|
+
//#region src/core/event-emitter.ts
|
|
2
|
+
/** Small typed emitter that isolates failures from individual listeners. */
|
|
3
|
+
var EventEmitter = class {
|
|
4
|
+
#listeners = /* @__PURE__ */ new Map();
|
|
5
|
+
#reportAsyncError;
|
|
6
|
+
constructor(reportAsyncError) {
|
|
7
|
+
this.#reportAsyncError = reportAsyncError;
|
|
8
|
+
}
|
|
9
|
+
on(event, listener) {
|
|
10
|
+
let listeners = this.#listeners.get(event);
|
|
11
|
+
if (listeners === void 0) {
|
|
12
|
+
listeners = /* @__PURE__ */ new Set();
|
|
13
|
+
this.#listeners.set(event, listeners);
|
|
14
|
+
}
|
|
15
|
+
const storedListener = listener;
|
|
16
|
+
listeners.add(storedListener);
|
|
17
|
+
let active = true;
|
|
18
|
+
return () => {
|
|
19
|
+
if (!active) return;
|
|
20
|
+
active = false;
|
|
21
|
+
listeners.delete(storedListener);
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
emit(event, payload) {
|
|
25
|
+
const listeners = this.#listeners.get(event);
|
|
26
|
+
if (listeners === void 0) return;
|
|
27
|
+
for (const listener of [...listeners]) try {
|
|
28
|
+
listener(payload);
|
|
29
|
+
} catch (error) {
|
|
30
|
+
this.#reportAsyncError(error);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
clear() {
|
|
34
|
+
this.#listeners.clear();
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
//#endregion
|
|
39
|
+
//#region src/core/errors.ts
|
|
40
|
+
/** An error with a stable package-specific code and structured context. */
|
|
41
|
+
var FrameByFrameError = class extends Error {
|
|
42
|
+
name = "FrameByFrameError";
|
|
43
|
+
cause;
|
|
44
|
+
code;
|
|
45
|
+
details;
|
|
46
|
+
constructor(code, message, options = {}) {
|
|
47
|
+
super(message, options.cause === void 0 ? void 0 : { cause: options.cause });
|
|
48
|
+
this.cause = options.cause;
|
|
49
|
+
this.code = code;
|
|
50
|
+
this.details = options.details === void 0 ? void 0 : Object.freeze({ ...options.details });
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
//#endregion
|
|
55
|
+
//#region src/mapping/easing.ts
|
|
56
|
+
const clampProgress$2 = (progress) => Math.min(1, Math.max(0, progress));
|
|
57
|
+
const sampleBezier = (parameter, firstControl, secondControl) => {
|
|
58
|
+
const inverse = 1 - parameter;
|
|
59
|
+
return 3 * inverse * inverse * parameter * firstControl + 3 * inverse * parameter * parameter * secondControl + parameter * parameter * parameter;
|
|
60
|
+
};
|
|
61
|
+
const createCubicBezier = (x1, y1, x2, y2) => {
|
|
62
|
+
return (progress) => {
|
|
63
|
+
const x = clampProgress$2(progress);
|
|
64
|
+
if (x === 0 || x === 1) return x;
|
|
65
|
+
let lower = 0;
|
|
66
|
+
let upper = 1;
|
|
67
|
+
let parameter = x;
|
|
68
|
+
for (let iteration = 0; iteration < 24; iteration += 1) {
|
|
69
|
+
const sampledX = sampleBezier(parameter, x1, x2);
|
|
70
|
+
if (Math.abs(sampledX - x) <= 1e-7) break;
|
|
71
|
+
if (sampledX < x) lower = parameter;
|
|
72
|
+
else upper = parameter;
|
|
73
|
+
parameter = (lower + upper) / 2;
|
|
74
|
+
}
|
|
75
|
+
return clampProgress$2(sampleBezier(parameter, y1, y2));
|
|
76
|
+
};
|
|
77
|
+
};
|
|
78
|
+
const namedEasing = {
|
|
79
|
+
linear: (progress) => progress,
|
|
80
|
+
"ease-in": createCubicBezier(.42, 0, 1, 1),
|
|
81
|
+
"ease-out": createCubicBezier(0, 0, .58, 1),
|
|
82
|
+
"ease-in-out": createCubicBezier(.42, 0, .58, 1)
|
|
83
|
+
};
|
|
84
|
+
const invalidEasingDefinition = (easing, segmentIndex) => {
|
|
85
|
+
const scope = segmentIndex === null ? "timeline" : `segment at index ${String(segmentIndex)}`;
|
|
86
|
+
throw new FrameByFrameError(segmentIndex === null ? "INVALID_TIMELINE" : "INVALID_SEGMENT", `The ${scope} has an unsupported easing definition.`, { details: {
|
|
87
|
+
easing,
|
|
88
|
+
segmentIndex
|
|
89
|
+
} });
|
|
90
|
+
};
|
|
91
|
+
function assertEasingDefinition(easing, segmentIndex) {
|
|
92
|
+
if (easing === void 0 || typeof easing === "function") return;
|
|
93
|
+
if (typeof easing !== "string" || !Object.hasOwn(namedEasing, easing)) invalidEasingDefinition(easing, segmentIndex);
|
|
94
|
+
}
|
|
95
|
+
const resolveEasing = (easing, segmentIndex) => {
|
|
96
|
+
if (easing === void 0) return namedEasing.linear;
|
|
97
|
+
if (typeof easing === "string") return namedEasing[easing];
|
|
98
|
+
return (progress) => {
|
|
99
|
+
let result;
|
|
100
|
+
try {
|
|
101
|
+
result = easing(clampProgress$2(progress));
|
|
102
|
+
} catch (cause) {
|
|
103
|
+
throw new FrameByFrameError("INVALID_EASING_RESULT", `The custom easing for segment at index ${String(segmentIndex)} threw an error.`, {
|
|
104
|
+
cause,
|
|
105
|
+
details: {
|
|
106
|
+
progress,
|
|
107
|
+
segmentIndex
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
if (!Number.isFinite(result)) throw new FrameByFrameError("INVALID_EASING_RESULT", `The custom easing for segment at index ${String(segmentIndex)} returned a non-finite value.`, { details: {
|
|
112
|
+
progress,
|
|
113
|
+
result,
|
|
114
|
+
segmentIndex
|
|
115
|
+
} });
|
|
116
|
+
return clampProgress$2(result);
|
|
117
|
+
};
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
//#endregion
|
|
121
|
+
//#region src/mapping/normalize-timeline.ts
|
|
122
|
+
const isRecord$4 = (value) => typeof value === "object" && value !== null;
|
|
123
|
+
const invalidTimeline = (message, details) => {
|
|
124
|
+
throw new FrameByFrameError("INVALID_TIMELINE", message, { details });
|
|
125
|
+
};
|
|
126
|
+
const invalidSegment = (index, message, details) => {
|
|
127
|
+
throw new FrameByFrameError("INVALID_SEGMENT", message, { details: {
|
|
128
|
+
...details,
|
|
129
|
+
segmentIndex: index
|
|
130
|
+
} });
|
|
131
|
+
};
|
|
132
|
+
const readSegment = (value, segmentIndex) => {
|
|
133
|
+
if (!isRecord$4(value)) return invalidSegment(segmentIndex, "Each timeline segment must be an object.", { segment: value });
|
|
134
|
+
return value;
|
|
135
|
+
};
|
|
136
|
+
const readTuple = (value, field, segmentIndex) => {
|
|
137
|
+
if (!Array.isArray(value) || value.length !== 2) return invalidSegment(segmentIndex, `Segment ${field} must contain exactly two numbers.`, {
|
|
138
|
+
field,
|
|
139
|
+
value
|
|
140
|
+
});
|
|
141
|
+
const tuple = value;
|
|
142
|
+
const start = tuple[0];
|
|
143
|
+
const end = tuple[1];
|
|
144
|
+
if (typeof start !== "number" || !Number.isFinite(start)) return invalidSegment(segmentIndex, `Segment ${field} start must be finite.`, {
|
|
145
|
+
field,
|
|
146
|
+
value: start
|
|
147
|
+
});
|
|
148
|
+
if (typeof end !== "number" || !Number.isFinite(end)) return invalidSegment(segmentIndex, `Segment ${field} end must be finite.`, {
|
|
149
|
+
field,
|
|
150
|
+
value: end
|
|
151
|
+
});
|
|
152
|
+
return [start, end];
|
|
153
|
+
};
|
|
154
|
+
const readUnit = (value, segmentIndex) => {
|
|
155
|
+
if (value === void 0) return "px";
|
|
156
|
+
if (value !== "px" && value !== "progress") return invalidSegment(segmentIndex, "Segment scrollUnit must be \"px\" or \"progress\".", { scrollUnit: value });
|
|
157
|
+
return value;
|
|
158
|
+
};
|
|
159
|
+
const readClipId = (value, segmentIndex) => {
|
|
160
|
+
if (value === void 0) return null;
|
|
161
|
+
if (typeof value !== "string" || value.trim().length === 0) return invalidSegment(segmentIndex, "Segment clip must be a non-empty string.", { clip: value });
|
|
162
|
+
return value;
|
|
163
|
+
};
|
|
164
|
+
const readFrameRate = (value) => {
|
|
165
|
+
if (value === void 0) return null;
|
|
166
|
+
if (!isRecord$4(value)) throw new FrameByFrameError("INVALID_FRAME_RATE", "Frame configuration must be an object.", { details: { frame: value } });
|
|
167
|
+
if (value["snap"] === void 0 || value["snap"] === false) {
|
|
168
|
+
if (value["fps"] !== void 0) throw new FrameByFrameError("INVALID_FRAME_RATE", "Frame rate may only be provided when frame snapping is enabled.", { details: { frame: value } });
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
if (value["snap"] !== true || typeof value["fps"] !== "number" || !Number.isFinite(value["fps"])) throw new FrameByFrameError("INVALID_FRAME_RATE", "Frame snapping requires a finite, positive fps value.", { details: { frame: value } });
|
|
172
|
+
if (value["fps"] <= 0) throw new FrameByFrameError("INVALID_FRAME_RATE", "Frame snapping requires a finite, positive fps value.", { details: { frame: value } });
|
|
173
|
+
return value["fps"];
|
|
174
|
+
};
|
|
175
|
+
const normalizeTimeline = (options) => {
|
|
176
|
+
if (!isRecord$4(options)) return invalidTimeline("Timeline options must be an object.", { options });
|
|
177
|
+
const segmentsValue = options.segments;
|
|
178
|
+
if (!Array.isArray(segmentsValue) || segmentsValue.length === 0) return invalidTimeline("A timeline requires at least one segment.", { segments: segmentsValue });
|
|
179
|
+
assertEasingDefinition(options.easing, null);
|
|
180
|
+
const defaultEasing = options.easing;
|
|
181
|
+
let timelineUnit;
|
|
182
|
+
const normalizedSegments = [];
|
|
183
|
+
const segmentValues = segmentsValue;
|
|
184
|
+
for (const [sourceIndex, segmentValue] of segmentValues.entries()) {
|
|
185
|
+
const segment = readSegment(segmentValue, sourceIndex);
|
|
186
|
+
const scroll = readTuple(segment.scroll, "scroll", sourceIndex);
|
|
187
|
+
const media = readTuple(segment.media, "media", sourceIndex);
|
|
188
|
+
const unit = readUnit(segment.scrollUnit, sourceIndex);
|
|
189
|
+
if (timelineUnit === void 0) timelineUnit = unit;
|
|
190
|
+
else if (timelineUnit !== unit) return invalidTimeline("All segments in a timeline must use the same scroll unit.", {
|
|
191
|
+
expectedUnit: timelineUnit,
|
|
192
|
+
receivedUnit: unit,
|
|
193
|
+
segmentIndex: sourceIndex
|
|
194
|
+
});
|
|
195
|
+
if (scroll[1] <= scroll[0]) invalidSegment(sourceIndex, "Segment scroll intervals must be strictly increasing.", { scroll });
|
|
196
|
+
if (unit === "progress" && (scroll[0] < 0 || scroll[1] > 1)) invalidSegment(sourceIndex, "Progress segment boundaries must stay between 0 and 1.", { scroll });
|
|
197
|
+
if (media[0] < 0 || media[1] < 0) invalidSegment(sourceIndex, "Segment media times cannot be negative.", { media });
|
|
198
|
+
const segmentEasing = segment.easing;
|
|
199
|
+
assertEasingDefinition(segmentEasing, sourceIndex);
|
|
200
|
+
const easingDefinition = segmentEasing ?? defaultEasing;
|
|
201
|
+
normalizedSegments.push({
|
|
202
|
+
sourceIndex,
|
|
203
|
+
scrollStart: scroll[0],
|
|
204
|
+
scrollEnd: scroll[1],
|
|
205
|
+
mediaStart: media[0],
|
|
206
|
+
mediaEnd: media[1],
|
|
207
|
+
clipId: readClipId(segment.clip, sourceIndex),
|
|
208
|
+
easing: resolveEasing(easingDefinition, sourceIndex)
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
normalizedSegments.sort((left, right) => left.scrollStart - right.scrollStart || left.scrollEnd - right.scrollEnd || left.sourceIndex - right.sourceIndex);
|
|
212
|
+
for (let index = 1; index < normalizedSegments.length; index += 1) {
|
|
213
|
+
const previous = normalizedSegments[index - 1];
|
|
214
|
+
const current = normalizedSegments[index];
|
|
215
|
+
if (previous !== void 0 && current !== void 0 && current.scrollStart < previous.scrollEnd) throw new FrameByFrameError("OVERLAPPING_SEGMENTS", `Segments at indexes ${String(previous.sourceIndex)} and ${String(current.sourceIndex)} overlap.`, { details: {
|
|
216
|
+
currentSegmentIndex: current.sourceIndex,
|
|
217
|
+
previousSegmentIndex: previous.sourceIndex
|
|
218
|
+
} });
|
|
219
|
+
}
|
|
220
|
+
return {
|
|
221
|
+
unit: timelineUnit ?? "px",
|
|
222
|
+
segments: normalizedSegments,
|
|
223
|
+
frameRate: readFrameRate(options.frame)
|
|
224
|
+
};
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
//#endregion
|
|
228
|
+
//#region src/mapping/timeline.ts
|
|
229
|
+
const clampProgress$1 = (progress) => Math.min(1, Math.max(0, progress));
|
|
230
|
+
const snapTime = (time, frameRate) => frameRate === null ? time : Math.round(time * frameRate) / frameRate;
|
|
231
|
+
const createHeldResolution = (phase, segment, requestedTime, frameRate) => ({
|
|
232
|
+
phase,
|
|
233
|
+
segmentIndex: null,
|
|
234
|
+
clipId: segment.clipId,
|
|
235
|
+
rawProgress: null,
|
|
236
|
+
easedProgress: null,
|
|
237
|
+
requestedTime,
|
|
238
|
+
targetTime: snapTime(requestedTime, frameRate)
|
|
239
|
+
});
|
|
240
|
+
const createActiveResolution = (segment, position, frameRate) => {
|
|
241
|
+
const rawProgress = clampProgress$1((position - segment.scrollStart) / (segment.scrollEnd - segment.scrollStart));
|
|
242
|
+
const easedProgress = segment.easing(rawProgress);
|
|
243
|
+
const requestedTime = segment.mediaStart + easedProgress * (segment.mediaEnd - segment.mediaStart);
|
|
244
|
+
return {
|
|
245
|
+
phase: "active",
|
|
246
|
+
segmentIndex: segment.sourceIndex,
|
|
247
|
+
clipId: segment.clipId,
|
|
248
|
+
rawProgress,
|
|
249
|
+
easedProgress,
|
|
250
|
+
requestedTime,
|
|
251
|
+
targetTime: snapTime(requestedTime, frameRate)
|
|
252
|
+
};
|
|
253
|
+
};
|
|
254
|
+
const findLastStartedSegment = (segments, position) => {
|
|
255
|
+
let lower = 0;
|
|
256
|
+
let upper = segments.length;
|
|
257
|
+
while (lower < upper) {
|
|
258
|
+
const middle = Math.floor((lower + upper) / 2);
|
|
259
|
+
const segment = segments[middle];
|
|
260
|
+
if (segment !== void 0 && segment.scrollStart <= position) lower = middle + 1;
|
|
261
|
+
else upper = middle;
|
|
262
|
+
}
|
|
263
|
+
return lower - 1;
|
|
264
|
+
};
|
|
265
|
+
const resolveTimeline = (timeline, position) => {
|
|
266
|
+
if (!Number.isFinite(position)) throw new FrameByFrameError("INVALID_TIMELINE", "Timeline position must be finite.", { details: { position } });
|
|
267
|
+
const first = timeline.segments[0];
|
|
268
|
+
const last = timeline.segments[timeline.segments.length - 1];
|
|
269
|
+
if (first === void 0 || last === void 0) throw new FrameByFrameError("INVALID_TIMELINE", "A timeline requires at least one segment.");
|
|
270
|
+
if (position < first.scrollStart) return createHeldResolution("before", first, first.mediaStart, timeline.frameRate);
|
|
271
|
+
if (position > last.scrollEnd) return createHeldResolution("after", last, last.mediaEnd, timeline.frameRate);
|
|
272
|
+
const candidateIndex = findLastStartedSegment(timeline.segments, position);
|
|
273
|
+
const candidate = timeline.segments[candidateIndex];
|
|
274
|
+
if (candidate === void 0) return createHeldResolution("before", first, first.mediaStart, timeline.frameRate);
|
|
275
|
+
if (position <= candidate.scrollEnd) return createActiveResolution(candidate, position, timeline.frameRate);
|
|
276
|
+
return createHeldResolution("gap", candidate, candidate.mediaEnd, timeline.frameRate);
|
|
277
|
+
};
|
|
278
|
+
/** Creates an immutable, DOM-independent scroll-to-media timeline. */
|
|
279
|
+
const createTimeline = (options) => {
|
|
280
|
+
const normalized = normalizeTimeline(options);
|
|
281
|
+
return Object.freeze({
|
|
282
|
+
unit: normalized.unit,
|
|
283
|
+
resolve: (position) => resolveTimeline(normalized, position)
|
|
284
|
+
});
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
//#endregion
|
|
288
|
+
//#region src/core/controller-config.ts
|
|
289
|
+
const AXES = ["x", "y"];
|
|
290
|
+
const CROSS_ORIGIN_VALUES = [
|
|
291
|
+
"",
|
|
292
|
+
"anonymous",
|
|
293
|
+
"use-credentials"
|
|
294
|
+
];
|
|
295
|
+
const PRELOAD_VALUES = [
|
|
296
|
+
"none",
|
|
297
|
+
"metadata",
|
|
298
|
+
"auto",
|
|
299
|
+
"full"
|
|
300
|
+
];
|
|
301
|
+
const LOADING_MODES = ["immediate", "on-demand"];
|
|
302
|
+
const LOADING_TRIGGERS = [
|
|
303
|
+
"manual",
|
|
304
|
+
"target-near-viewport",
|
|
305
|
+
"first-use"
|
|
306
|
+
];
|
|
307
|
+
const REQUEST_CREDENTIALS = [
|
|
308
|
+
"omit",
|
|
309
|
+
"same-origin",
|
|
310
|
+
"include"
|
|
311
|
+
];
|
|
312
|
+
const REQUEST_CACHE = [
|
|
313
|
+
"default",
|
|
314
|
+
"no-store",
|
|
315
|
+
"reload",
|
|
316
|
+
"no-cache",
|
|
317
|
+
"force-cache",
|
|
318
|
+
"only-if-cached"
|
|
319
|
+
];
|
|
320
|
+
const REDUCED_MOTION_BEHAVIORS = [
|
|
321
|
+
"first-frame",
|
|
322
|
+
"last-frame",
|
|
323
|
+
"disable",
|
|
324
|
+
"ignore"
|
|
325
|
+
];
|
|
326
|
+
const BREAKPOINT_KEYS = [
|
|
327
|
+
"id",
|
|
328
|
+
"query",
|
|
329
|
+
"override"
|
|
330
|
+
];
|
|
331
|
+
const BREAKPOINT_OVERRIDE_KEYS = ["axes"];
|
|
332
|
+
const AXIS_OVERRIDE_KEYS = ["enabled", "bindings"];
|
|
333
|
+
const VIDEO_BINDING_OVERRIDE_KEYS = [
|
|
334
|
+
"id",
|
|
335
|
+
"segments",
|
|
336
|
+
"clips",
|
|
337
|
+
"easing",
|
|
338
|
+
"frame",
|
|
339
|
+
"loading",
|
|
340
|
+
"video",
|
|
341
|
+
"seek"
|
|
342
|
+
];
|
|
343
|
+
const CANVAS_BINDING_OVERRIDE_KEYS = [...VIDEO_BINDING_OVERRIDE_KEYS, "canvas"];
|
|
344
|
+
const CANVAS_FITS = [
|
|
345
|
+
"contain",
|
|
346
|
+
"cover",
|
|
347
|
+
"fill",
|
|
348
|
+
"none"
|
|
349
|
+
];
|
|
350
|
+
const CANVAS_OVERRIDE_KEYS = [
|
|
351
|
+
"fit",
|
|
352
|
+
"pixelRatio",
|
|
353
|
+
"imageSmoothingEnabled"
|
|
354
|
+
];
|
|
355
|
+
const isRecord$3 = (value) => typeof value === "object" && value !== null;
|
|
356
|
+
const invalidController = (message, details) => {
|
|
357
|
+
throw new FrameByFrameError("INVALID_CONTROLLER", message, { details });
|
|
358
|
+
};
|
|
359
|
+
const invalidMediaConfig = (bindingId, message, details) => {
|
|
360
|
+
throw new FrameByFrameError("INVALID_MEDIA_CONFIG", message, { details: {
|
|
361
|
+
...details,
|
|
362
|
+
bindingId
|
|
363
|
+
} });
|
|
364
|
+
};
|
|
365
|
+
const invalidBreakpoint = (message, details, cause) => {
|
|
366
|
+
throw new FrameByFrameError("INVALID_BREAKPOINT_CONFIG", message, {
|
|
367
|
+
cause,
|
|
368
|
+
details
|
|
369
|
+
});
|
|
370
|
+
};
|
|
371
|
+
const cloneTuple = (value) => Array.isArray(value) ? Object.freeze([...value]) : value;
|
|
372
|
+
const cloneSegments = (value) => {
|
|
373
|
+
if (!Array.isArray(value)) return value;
|
|
374
|
+
return Object.freeze(value.map((segment) => isRecord$3(segment) ? Object.freeze({
|
|
375
|
+
...segment,
|
|
376
|
+
scroll: cloneTuple(segment["scroll"]),
|
|
377
|
+
media: cloneTuple(segment["media"])
|
|
378
|
+
}) : segment));
|
|
379
|
+
};
|
|
380
|
+
const cloneClips = (value) => {
|
|
381
|
+
if (!Array.isArray(value)) return value;
|
|
382
|
+
return Object.freeze(value.map((clip) => isRecord$3(clip) ? Object.freeze({
|
|
383
|
+
...clip,
|
|
384
|
+
sources: Array.isArray(clip["sources"]) ? Object.freeze(clip["sources"].map((source) => isRecord$3(source) ? Object.freeze({ ...source }) : source)) : clip["sources"]
|
|
385
|
+
}) : clip));
|
|
386
|
+
};
|
|
387
|
+
const cloneOption = (value) => isRecord$3(value) ? Object.freeze({ ...value }) : value;
|
|
388
|
+
const createBindingDefinition = (value, axis) => Object.freeze({
|
|
389
|
+
id: value["id"],
|
|
390
|
+
axis,
|
|
391
|
+
...value["renderer"] === void 0 ? {} : { renderer: value["renderer"] },
|
|
392
|
+
...value["target"] === void 0 ? {} : { target: value["target"] },
|
|
393
|
+
...value["mountTo"] === void 0 ? {} : { mountTo: value["mountTo"] },
|
|
394
|
+
segments: cloneSegments(value["segments"]),
|
|
395
|
+
clips: cloneClips(value["clips"]),
|
|
396
|
+
...value["easing"] === void 0 ? {} : { easing: value["easing"] },
|
|
397
|
+
...value["frame"] === void 0 ? {} : { frame: cloneOption(value["frame"]) },
|
|
398
|
+
...value["loading"] === void 0 ? {} : { loading: cloneOption(value["loading"]) },
|
|
399
|
+
...value["video"] === void 0 ? {} : { video: cloneOption(value["video"]) },
|
|
400
|
+
...value["canvas"] === void 0 ? {} : { canvas: cloneOption(value["canvas"]) },
|
|
401
|
+
...value["seek"] === void 0 ? {} : { seek: cloneOption(value["seek"]) }
|
|
402
|
+
});
|
|
403
|
+
const readAxis = (value, axis) => {
|
|
404
|
+
if (!isRecord$3(value)) return invalidController(`Axis "${axis}" must be an object or false.`, {
|
|
405
|
+
axis,
|
|
406
|
+
value
|
|
407
|
+
});
|
|
408
|
+
if (value["enabled"] !== void 0 && typeof value["enabled"] !== "boolean") return invalidController(`Axis "${axis}" enabled must be a boolean.`, {
|
|
409
|
+
axis,
|
|
410
|
+
enabled: value["enabled"]
|
|
411
|
+
});
|
|
412
|
+
if (!Array.isArray(value["bindings"]) || value["bindings"].length === 0) return invalidController(`Axis "${axis}" requires at least one binding.`, {
|
|
413
|
+
axis,
|
|
414
|
+
bindings: value["bindings"]
|
|
415
|
+
});
|
|
416
|
+
return value;
|
|
417
|
+
};
|
|
418
|
+
const isElementReference = (value) => typeof value === "function" || typeof value === "string" && value.trim().length > 0 || isRecord$3(value);
|
|
419
|
+
const compileSource = (value, bindingId, clipId, sourceIndex) => {
|
|
420
|
+
if (!isRecord$3(value)) return invalidMediaConfig(bindingId, "Each video source must be an object.", {
|
|
421
|
+
clipId,
|
|
422
|
+
source: value,
|
|
423
|
+
sourceIndex
|
|
424
|
+
});
|
|
425
|
+
const src = value["src"];
|
|
426
|
+
const type = value["type"];
|
|
427
|
+
if (typeof src !== "string" || src.trim().length === 0) return invalidMediaConfig(bindingId, "Video source src must be a non-empty string.", {
|
|
428
|
+
clipId,
|
|
429
|
+
sourceIndex,
|
|
430
|
+
src
|
|
431
|
+
});
|
|
432
|
+
if (type !== void 0 && (typeof type !== "string" || type.trim().length === 0)) return invalidMediaConfig(bindingId, "Video source type must be a non-empty string.", {
|
|
433
|
+
clipId,
|
|
434
|
+
sourceIndex,
|
|
435
|
+
type
|
|
436
|
+
});
|
|
437
|
+
return Object.freeze({
|
|
438
|
+
src,
|
|
439
|
+
type: type ?? null
|
|
440
|
+
});
|
|
441
|
+
};
|
|
442
|
+
const compileClip = (value, bindingId, clipIndex) => {
|
|
443
|
+
if (!isRecord$3(value)) return invalidMediaConfig(bindingId, "Each video clip must be an object.", {
|
|
444
|
+
clip: value,
|
|
445
|
+
clipIndex
|
|
446
|
+
});
|
|
447
|
+
const id = value["id"];
|
|
448
|
+
const sources = value["sources"];
|
|
449
|
+
const poster = value["poster"];
|
|
450
|
+
const crossOrigin = value["crossOrigin"];
|
|
451
|
+
const preload = value["preload"];
|
|
452
|
+
if (typeof id !== "string" || id.trim().length === 0) return invalidMediaConfig(bindingId, "Each video clip requires a non-empty string ID.", {
|
|
453
|
+
clipId: id,
|
|
454
|
+
clipIndex
|
|
455
|
+
});
|
|
456
|
+
if (!Array.isArray(sources) || sources.length === 0) return invalidMediaConfig(bindingId, `Video clip "${id}" requires at least one source.`, {
|
|
457
|
+
clipId: id,
|
|
458
|
+
sources
|
|
459
|
+
});
|
|
460
|
+
if (poster !== void 0 && (typeof poster !== "string" || poster.trim().length === 0)) return invalidMediaConfig(bindingId, "Video clip poster must be a non-empty string.", {
|
|
461
|
+
clipId: id,
|
|
462
|
+
poster
|
|
463
|
+
});
|
|
464
|
+
if (crossOrigin !== void 0 && !CROSS_ORIGIN_VALUES.includes(crossOrigin)) return invalidMediaConfig(bindingId, "Video clip crossOrigin is invalid.", {
|
|
465
|
+
clipId: id,
|
|
466
|
+
crossOrigin
|
|
467
|
+
});
|
|
468
|
+
if (preload !== void 0 && !PRELOAD_VALUES.includes(preload)) return invalidMediaConfig(bindingId, "Video clip preload is invalid.", {
|
|
469
|
+
clipId: id,
|
|
470
|
+
preload
|
|
471
|
+
});
|
|
472
|
+
return Object.freeze({
|
|
473
|
+
id,
|
|
474
|
+
sources: Object.freeze(sources.map((source, sourceIndex) => compileSource(source, bindingId, id, sourceIndex))),
|
|
475
|
+
poster: poster ?? null,
|
|
476
|
+
crossOrigin: crossOrigin ?? null,
|
|
477
|
+
preload: preload ?? "metadata"
|
|
478
|
+
});
|
|
479
|
+
};
|
|
480
|
+
const compileClips = (value, bindingId) => {
|
|
481
|
+
if (!Array.isArray(value) || value.length === 0) return invalidMediaConfig(bindingId, "Each binding requires at least one video clip.", { clips: value });
|
|
482
|
+
const ids = /* @__PURE__ */ new Set();
|
|
483
|
+
const clips = value.map((clip, clipIndex) => compileClip(clip, bindingId, clipIndex));
|
|
484
|
+
for (const clip of clips) {
|
|
485
|
+
if (ids.has(clip.id)) return invalidMediaConfig(bindingId, `Video clip ID "${clip.id}" is duplicated.`, { clipId: clip.id });
|
|
486
|
+
ids.add(clip.id);
|
|
487
|
+
}
|
|
488
|
+
return Object.freeze(clips);
|
|
489
|
+
};
|
|
490
|
+
const compileVideoOptions = (value, bindingId) => {
|
|
491
|
+
if (value !== void 0 && !isRecord$3(value)) return invalidMediaConfig(bindingId, "Video options must be an object.", { video: value });
|
|
492
|
+
const options = value ?? {};
|
|
493
|
+
for (const field of [
|
|
494
|
+
"muted",
|
|
495
|
+
"playsInline",
|
|
496
|
+
"controls",
|
|
497
|
+
"loop"
|
|
498
|
+
]) if (options[field] !== void 0 && typeof options[field] !== "boolean") return invalidMediaConfig(bindingId, `Video option "${field}" must be a boolean.`, {
|
|
499
|
+
field,
|
|
500
|
+
value: options[field]
|
|
501
|
+
});
|
|
502
|
+
return Object.freeze({
|
|
503
|
+
muted: options["muted"],
|
|
504
|
+
playsInline: options["playsInline"],
|
|
505
|
+
controls: options["controls"],
|
|
506
|
+
loop: options["loop"]
|
|
507
|
+
});
|
|
508
|
+
};
|
|
509
|
+
const compileCanvasOptions = (value, bindingId, renderer) => {
|
|
510
|
+
if (renderer === "video") {
|
|
511
|
+
if (value !== void 0) return invalidMediaConfig(bindingId, "Canvas options require renderer \"canvas\".", { canvas: value });
|
|
512
|
+
return null;
|
|
513
|
+
}
|
|
514
|
+
if (value !== void 0 && !isRecord$3(value)) return invalidMediaConfig(bindingId, "Canvas options must be an object.", { canvas: value });
|
|
515
|
+
const options = value ?? {};
|
|
516
|
+
const fit = options["fit"] ?? "contain";
|
|
517
|
+
const pixelRatio = options["pixelRatio"] ?? "device";
|
|
518
|
+
const imageSmoothingEnabled = options["imageSmoothingEnabled"] ?? true;
|
|
519
|
+
const decoderTarget = options["decoderTarget"];
|
|
520
|
+
if (!CANVAS_FITS.includes(fit)) return invalidMediaConfig(bindingId, "Canvas fit is invalid.", { fit });
|
|
521
|
+
if (pixelRatio !== "device" && (typeof pixelRatio !== "number" || !Number.isFinite(pixelRatio) || pixelRatio <= 0)) return invalidMediaConfig(bindingId, "Canvas pixelRatio must be \"device\" or finite and positive.", { pixelRatio });
|
|
522
|
+
if (typeof imageSmoothingEnabled !== "boolean") return invalidMediaConfig(bindingId, "Canvas imageSmoothingEnabled must be a boolean.", { imageSmoothingEnabled });
|
|
523
|
+
if (decoderTarget !== void 0 && !isElementReference(decoderTarget)) return invalidMediaConfig(bindingId, "Canvas decoderTarget must be an element, selector, or resolver.", { decoderTarget });
|
|
524
|
+
return Object.freeze({
|
|
525
|
+
fit,
|
|
526
|
+
pixelRatio,
|
|
527
|
+
imageSmoothingEnabled,
|
|
528
|
+
decoderTarget
|
|
529
|
+
});
|
|
530
|
+
};
|
|
531
|
+
const compileLoading = (value, bindingId, clips) => {
|
|
532
|
+
if (value !== void 0 && !isRecord$3(value)) return invalidMediaConfig(bindingId, "Loading options must be an object.", { loading: value });
|
|
533
|
+
const options = value ?? {};
|
|
534
|
+
const mode = options["mode"] ?? "immediate";
|
|
535
|
+
const trigger = options["trigger"];
|
|
536
|
+
const rootMargin = options["rootMargin"];
|
|
537
|
+
const credentials = options["credentials"] ?? "same-origin";
|
|
538
|
+
const cache = options["cache"] ?? "default";
|
|
539
|
+
if (!LOADING_MODES.includes(mode)) return invalidMediaConfig(bindingId, "Loading mode is invalid.", { mode });
|
|
540
|
+
if (mode === "on-demand") {
|
|
541
|
+
if (!LOADING_TRIGGERS.includes(trigger)) return invalidMediaConfig(bindingId, "On-demand loading requires an explicit trigger.", { trigger });
|
|
542
|
+
} else if (trigger !== void 0) return invalidMediaConfig(bindingId, "Immediate loading cannot declare a trigger.", { trigger });
|
|
543
|
+
if (rootMargin !== void 0) {
|
|
544
|
+
if (mode !== "on-demand" || trigger !== "target-near-viewport" || typeof rootMargin !== "string" || rootMargin.trim().length === 0) return invalidMediaConfig(bindingId, "rootMargin is only valid for target-near-viewport loading.", {
|
|
545
|
+
rootMargin,
|
|
546
|
+
trigger
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
if (!REQUEST_CREDENTIALS.includes(credentials)) return invalidMediaConfig(bindingId, "Full preload credentials are invalid.", { credentials });
|
|
550
|
+
if (!REQUEST_CACHE.includes(cache)) return invalidMediaConfig(bindingId, "Full preload cache mode is invalid.", { cache });
|
|
551
|
+
if (!clips.some((clip) => clip.preload === "full") && (options["credentials"] !== void 0 || options["cache"] !== void 0)) return invalidMediaConfig(bindingId, "Fetch credentials and cache options require at least one full-preload clip.", {
|
|
552
|
+
cache: options["cache"],
|
|
553
|
+
credentials: options["credentials"]
|
|
554
|
+
});
|
|
555
|
+
return Object.freeze({
|
|
556
|
+
mode,
|
|
557
|
+
trigger: mode === "on-demand" ? trigger : null,
|
|
558
|
+
rootMargin: mode === "on-demand" && trigger === "target-near-viewport" ? rootMargin ?? "0px" : null,
|
|
559
|
+
credentials,
|
|
560
|
+
cache
|
|
561
|
+
});
|
|
562
|
+
};
|
|
563
|
+
const compileTimeEpsilon = (value, bindingId) => {
|
|
564
|
+
if (value === void 0) return .001;
|
|
565
|
+
if (!isRecord$3(value)) return invalidMediaConfig(bindingId, "Seek options must be an object.", { seek: value });
|
|
566
|
+
const epsilon = value["timeEpsilon"];
|
|
567
|
+
if (epsilon !== void 0 && (typeof epsilon !== "number" || !Number.isFinite(epsilon) || epsilon < 0)) return invalidMediaConfig(bindingId, "Seek timeEpsilon must be finite and non-negative.", { timeEpsilon: epsilon });
|
|
568
|
+
return epsilon ?? .001;
|
|
569
|
+
};
|
|
570
|
+
const validateSegmentClips = (segments, clips, bindingId) => {
|
|
571
|
+
if (!Array.isArray(segments)) return;
|
|
572
|
+
const clipIds = new Set(clips.map((clip) => clip.id));
|
|
573
|
+
for (const [segmentIndex, segment] of segments.entries()) {
|
|
574
|
+
if (!isRecord$3(segment)) continue;
|
|
575
|
+
const clipId = segment["clip"];
|
|
576
|
+
if (clips.length > 1 && clipId === void 0) invalidMediaConfig(bindingId, "Every segment requires a clip when a binding has multiple clips.", { segmentIndex });
|
|
577
|
+
if (clipId !== void 0 && (typeof clipId !== "string" || !clipIds.has(clipId))) invalidMediaConfig(bindingId, "Timeline segment references an unknown video clip.", {
|
|
578
|
+
clipId,
|
|
579
|
+
segmentIndex
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
};
|
|
583
|
+
const compileBinding = (value, axis, supportedRenderers) => {
|
|
584
|
+
if (!isRecord$3(value)) return invalidController("Each controller binding must be an object.", {
|
|
585
|
+
axis,
|
|
586
|
+
binding: value
|
|
587
|
+
});
|
|
588
|
+
const id = value["id"];
|
|
589
|
+
if (typeof id !== "string" || id.trim().length === 0) return invalidController("Each controller binding requires a non-empty string ID.", {
|
|
590
|
+
axis,
|
|
591
|
+
id
|
|
592
|
+
});
|
|
593
|
+
const rendererValue = value["renderer"] ?? "video";
|
|
594
|
+
if (rendererValue !== "video" && rendererValue !== "canvas") return invalidMediaConfig(id, "The configured renderer is not supported.", { renderer: value["renderer"] });
|
|
595
|
+
const renderer = rendererValue;
|
|
596
|
+
if (!supportedRenderers.has(renderer)) return invalidMediaConfig(id, renderer === "canvas" ? "Canvas rendering requires the @frame-by-frame/core/canvas entry point." : "The configured renderer is not supported.", { renderer: value["renderer"] });
|
|
597
|
+
const hasTarget = value["target"] !== void 0;
|
|
598
|
+
const hasMountTo = value["mountTo"] !== void 0;
|
|
599
|
+
if (hasTarget === hasMountTo) return invalidMediaConfig(id, "A binding requires exactly one of target or mountTo.", {
|
|
600
|
+
mountTo: value["mountTo"],
|
|
601
|
+
target: value["target"]
|
|
602
|
+
});
|
|
603
|
+
const reference = hasTarget ? value["target"] : value["mountTo"];
|
|
604
|
+
if (!isElementReference(reference)) return invalidMediaConfig(id, "Media target references must be elements, selectors, or resolvers.", { reference });
|
|
605
|
+
const timeline = createTimeline({
|
|
606
|
+
segments: value["segments"],
|
|
607
|
+
...value["easing"] === void 0 ? {} : { easing: value["easing"] },
|
|
608
|
+
...value["frame"] === void 0 ? {} : { frame: value["frame"] }
|
|
609
|
+
});
|
|
610
|
+
const clips = compileClips(value["clips"], id);
|
|
611
|
+
validateSegmentClips(value["segments"], clips, id);
|
|
612
|
+
const loading = compileLoading(value["loading"], id, clips);
|
|
613
|
+
const video = compileVideoOptions(value["video"], id);
|
|
614
|
+
const canvas = compileCanvasOptions(value["canvas"], id, renderer);
|
|
615
|
+
const timeEpsilon = compileTimeEpsilon(value["seek"], id);
|
|
616
|
+
const segments = value["segments"];
|
|
617
|
+
const startPosition = Math.min(...segments.map((segment) => segment.scroll[0]));
|
|
618
|
+
const endPosition = Math.max(...segments.map((segment) => segment.scroll[1]));
|
|
619
|
+
const definition = createBindingDefinition(value, axis);
|
|
620
|
+
const decoderSignature = JSON.stringify({
|
|
621
|
+
clips,
|
|
622
|
+
loading,
|
|
623
|
+
video,
|
|
624
|
+
timeEpsilon
|
|
625
|
+
});
|
|
626
|
+
return Object.freeze({
|
|
627
|
+
id,
|
|
628
|
+
axis,
|
|
629
|
+
renderer,
|
|
630
|
+
timeline,
|
|
631
|
+
startPosition,
|
|
632
|
+
endPosition,
|
|
633
|
+
target: hasTarget ? value["target"] : void 0,
|
|
634
|
+
mountTo: hasMountTo ? value["mountTo"] : void 0,
|
|
635
|
+
clips,
|
|
636
|
+
loading,
|
|
637
|
+
video,
|
|
638
|
+
canvas,
|
|
639
|
+
timeEpsilon,
|
|
640
|
+
decoderSignature,
|
|
641
|
+
mediaSignature: JSON.stringify({
|
|
642
|
+
decoderSignature,
|
|
643
|
+
canvas: canvas === null ? null : {
|
|
644
|
+
fit: canvas.fit,
|
|
645
|
+
pixelRatio: canvas.pixelRatio,
|
|
646
|
+
imageSmoothingEnabled: canvas.imageSmoothingEnabled
|
|
647
|
+
}
|
|
648
|
+
}),
|
|
649
|
+
definition
|
|
650
|
+
});
|
|
651
|
+
};
|
|
652
|
+
/** Validates controller shape and compiles every timeline and media binding at factory time. */
|
|
653
|
+
const compileControllerConfig = (options, supportedRenderers = /* @__PURE__ */ new Set(["video"])) => {
|
|
654
|
+
if (!isRecord$3(options)) return invalidController("Controller options must be an object.", { options });
|
|
655
|
+
const axesValue = options.axes;
|
|
656
|
+
if (!isRecord$3(axesValue)) return invalidController("Controller axes must be an object.", { axes: axesValue });
|
|
657
|
+
const axes = {};
|
|
658
|
+
const bindings = [];
|
|
659
|
+
const ids = /* @__PURE__ */ new Set();
|
|
660
|
+
for (const axisName of AXES) {
|
|
661
|
+
const axisValue = axesValue[axisName];
|
|
662
|
+
if (axisValue === void 0 || axisValue === false) continue;
|
|
663
|
+
const axis = readAxis(axisValue, axisName);
|
|
664
|
+
const axisBindings = axis.bindings.map((binding) => compileBinding(binding, axisName, supportedRenderers));
|
|
665
|
+
for (const binding of axisBindings) {
|
|
666
|
+
if (ids.has(binding.id)) throw new FrameByFrameError("DUPLICATE_BINDING_ID", `Binding ID "${binding.id}" is used more than once.`, { details: { bindingId: binding.id } });
|
|
667
|
+
ids.add(binding.id);
|
|
668
|
+
bindings.push(binding);
|
|
669
|
+
}
|
|
670
|
+
axes[axisName] = Object.freeze({
|
|
671
|
+
enabled: axis.enabled ?? true,
|
|
672
|
+
bindings: Object.freeze(axisBindings)
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
if (bindings.length === 0) return invalidController("At least one configured axis with one binding is required.", { axes: axesValue });
|
|
676
|
+
return Object.freeze({
|
|
677
|
+
source: options.source,
|
|
678
|
+
axes: Object.freeze(axes),
|
|
679
|
+
bindings: Object.freeze(bindings)
|
|
680
|
+
});
|
|
681
|
+
};
|
|
682
|
+
const hasOwn = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
|
|
683
|
+
const assertKnownKeys = (value, allowed, breakpointId, scope) => {
|
|
684
|
+
for (const key of Object.keys(value)) if (!allowed.includes(key)) invalidBreakpoint(`Breakpoint "${breakpointId}" cannot override "${scope}.${key}".`, {
|
|
685
|
+
breakpointId,
|
|
686
|
+
key,
|
|
687
|
+
scope
|
|
688
|
+
});
|
|
689
|
+
};
|
|
690
|
+
const cloneBindingOverride = (value, breakpointId, axis, bindingIds, canvasEnabled) => {
|
|
691
|
+
if (!isRecord$3(value)) return invalidBreakpoint("Each breakpoint binding override must be an object.", {
|
|
692
|
+
axis,
|
|
693
|
+
breakpointId,
|
|
694
|
+
value
|
|
695
|
+
});
|
|
696
|
+
assertKnownKeys(value, canvasEnabled ? CANVAS_BINDING_OVERRIDE_KEYS : VIDEO_BINDING_OVERRIDE_KEYS, breakpointId, `axes.${axis}.bindings`);
|
|
697
|
+
const id = value["id"];
|
|
698
|
+
if (typeof id !== "string" || !bindingIds.has(id)) return invalidBreakpoint(`Breakpoint "${breakpointId}" references an unknown binding on axis "${axis}".`, {
|
|
699
|
+
axis,
|
|
700
|
+
bindingId: id,
|
|
701
|
+
breakpointId
|
|
702
|
+
});
|
|
703
|
+
for (const field of ["segments", "clips"]) if (hasOwn(value, field) && !Array.isArray(value[field])) invalidBreakpoint(`Breakpoint binding override "${field}" must be an array.`, {
|
|
704
|
+
axis,
|
|
705
|
+
bindingId: id,
|
|
706
|
+
breakpointId,
|
|
707
|
+
field
|
|
708
|
+
});
|
|
709
|
+
for (const field of [
|
|
710
|
+
"frame",
|
|
711
|
+
"loading",
|
|
712
|
+
"video",
|
|
713
|
+
"seek",
|
|
714
|
+
"canvas"
|
|
715
|
+
]) if (hasOwn(value, field) && !isRecord$3(value[field])) invalidBreakpoint(`Breakpoint binding override "${field}" must be an object.`, {
|
|
716
|
+
axis,
|
|
717
|
+
bindingId: id,
|
|
718
|
+
breakpointId,
|
|
719
|
+
field
|
|
720
|
+
});
|
|
721
|
+
if (hasOwn(value, "canvas")) {
|
|
722
|
+
const canvas = value["canvas"];
|
|
723
|
+
assertKnownKeys(canvas, CANVAS_OVERRIDE_KEYS, breakpointId, `axes.${axis}.bindings.canvas`);
|
|
724
|
+
}
|
|
725
|
+
return Object.freeze({
|
|
726
|
+
id,
|
|
727
|
+
...hasOwn(value, "segments") ? { segments: cloneSegments(value["segments"]) } : {},
|
|
728
|
+
...hasOwn(value, "clips") ? { clips: cloneClips(value["clips"]) } : {},
|
|
729
|
+
...hasOwn(value, "easing") ? { easing: value["easing"] } : {},
|
|
730
|
+
...hasOwn(value, "frame") ? { frame: cloneOption(value["frame"]) } : {},
|
|
731
|
+
...hasOwn(value, "loading") ? { loading: cloneOption(value["loading"]) } : {},
|
|
732
|
+
...hasOwn(value, "video") ? { video: cloneOption(value["video"]) } : {},
|
|
733
|
+
...hasOwn(value, "canvas") ? { canvas: cloneOption(value["canvas"]) } : {},
|
|
734
|
+
...hasOwn(value, "seek") ? { seek: cloneOption(value["seek"]) } : {}
|
|
735
|
+
});
|
|
736
|
+
};
|
|
737
|
+
const cloneBreakpointOverride = (value, breakpointId, base, canvasEnabled) => {
|
|
738
|
+
if (!isRecord$3(value)) return invalidBreakpoint(`Breakpoint "${breakpointId}" override must be an object.`, {
|
|
739
|
+
breakpointId,
|
|
740
|
+
override: value
|
|
741
|
+
});
|
|
742
|
+
assertKnownKeys(value, BREAKPOINT_OVERRIDE_KEYS, breakpointId, "override");
|
|
743
|
+
const axesValue = value["axes"];
|
|
744
|
+
if (!isRecord$3(axesValue) || Object.keys(axesValue).length === 0) return invalidBreakpoint(`Breakpoint "${breakpointId}" requires at least one axis override.`, {
|
|
745
|
+
axes: axesValue,
|
|
746
|
+
breakpointId
|
|
747
|
+
});
|
|
748
|
+
assertKnownKeys(axesValue, AXES, breakpointId, "axes");
|
|
749
|
+
const axes = {};
|
|
750
|
+
for (const axis of AXES) {
|
|
751
|
+
if (!hasOwn(axesValue, axis)) continue;
|
|
752
|
+
const axisValue = axesValue[axis];
|
|
753
|
+
const baseAxis = base.axes[axis];
|
|
754
|
+
if (baseAxis === void 0) return invalidBreakpoint(`Breakpoint "${breakpointId}" references an unconfigured axis.`, {
|
|
755
|
+
axis,
|
|
756
|
+
breakpointId
|
|
757
|
+
});
|
|
758
|
+
if (axisValue === false) {
|
|
759
|
+
axes[axis] = false;
|
|
760
|
+
continue;
|
|
761
|
+
}
|
|
762
|
+
if (!isRecord$3(axisValue)) return invalidBreakpoint(`Breakpoint axis "${axis}" must be an object or false.`, {
|
|
763
|
+
axis,
|
|
764
|
+
breakpointId,
|
|
765
|
+
value: axisValue
|
|
766
|
+
});
|
|
767
|
+
assertKnownKeys(axisValue, AXIS_OVERRIDE_KEYS, breakpointId, `axes.${axis}`);
|
|
768
|
+
if (hasOwn(axisValue, "enabled") && typeof axisValue["enabled"] !== "boolean") return invalidBreakpoint(`Breakpoint axis "${axis}" enabled must be a boolean.`, {
|
|
769
|
+
axis,
|
|
770
|
+
breakpointId,
|
|
771
|
+
enabled: axisValue["enabled"]
|
|
772
|
+
});
|
|
773
|
+
const bindingIds = new Set(baseAxis.bindings.map((binding) => binding.id));
|
|
774
|
+
const bindingsValue = axisValue["bindings"];
|
|
775
|
+
if (hasOwn(axisValue, "bindings") && !Array.isArray(bindingsValue)) return invalidBreakpoint(`Breakpoint axis "${axis}" bindings must be an array.`, {
|
|
776
|
+
axis,
|
|
777
|
+
bindings: bindingsValue,
|
|
778
|
+
breakpointId
|
|
779
|
+
});
|
|
780
|
+
const seen = /* @__PURE__ */ new Set();
|
|
781
|
+
const bindings = Array.isArray(bindingsValue) ? bindingsValue.map((binding) => {
|
|
782
|
+
const cloned = cloneBindingOverride(binding, breakpointId, axis, bindingIds, canvasEnabled);
|
|
783
|
+
const bindingId = cloned["id"];
|
|
784
|
+
if (seen.has(bindingId)) invalidBreakpoint(`Breakpoint "${breakpointId}" overrides binding "${bindingId}" more than once.`, {
|
|
785
|
+
axis,
|
|
786
|
+
bindingId,
|
|
787
|
+
breakpointId
|
|
788
|
+
});
|
|
789
|
+
seen.add(bindingId);
|
|
790
|
+
return cloned;
|
|
791
|
+
}) : void 0;
|
|
792
|
+
axes[axis] = Object.freeze({
|
|
793
|
+
...hasOwn(axisValue, "enabled") ? { enabled: axisValue["enabled"] } : {},
|
|
794
|
+
...bindings === void 0 ? {} : { bindings: Object.freeze(bindings) }
|
|
795
|
+
});
|
|
796
|
+
}
|
|
797
|
+
return Object.freeze({ axes: Object.freeze(axes) });
|
|
798
|
+
};
|
|
799
|
+
const compileBreakpoints = (value, base, canvasEnabled) => {
|
|
800
|
+
if (value === void 0) return Object.freeze([]);
|
|
801
|
+
if (!Array.isArray(value)) return invalidBreakpoint("Controller breakpoints must be an array.", { breakpoints: value });
|
|
802
|
+
const ids = /* @__PURE__ */ new Set();
|
|
803
|
+
const breakpoints = value.map((candidate, index) => {
|
|
804
|
+
if (!isRecord$3(candidate)) return invalidBreakpoint("Each breakpoint must be an object.", {
|
|
805
|
+
breakpoint: candidate,
|
|
806
|
+
index
|
|
807
|
+
});
|
|
808
|
+
const provisionalId = typeof candidate["id"] === "string" && candidate["id"].trim().length > 0 ? candidate["id"] : `#${String(index)}`;
|
|
809
|
+
assertKnownKeys(candidate, BREAKPOINT_KEYS, provisionalId, "breakpoint");
|
|
810
|
+
const id = candidate["id"];
|
|
811
|
+
const query = candidate["query"];
|
|
812
|
+
if (typeof id !== "string" || id.trim().length === 0) return invalidBreakpoint("Each breakpoint requires a non-empty string ID.", {
|
|
813
|
+
id,
|
|
814
|
+
index
|
|
815
|
+
});
|
|
816
|
+
if (ids.has(id)) return invalidBreakpoint(`Breakpoint ID "${id}" is duplicated.`, { breakpointId: id });
|
|
817
|
+
if (typeof query !== "string" || query.trim().length === 0) return invalidBreakpoint(`Breakpoint "${id}" requires a non-empty media query.`, {
|
|
818
|
+
breakpointId: id,
|
|
819
|
+
query
|
|
820
|
+
});
|
|
821
|
+
ids.add(id);
|
|
822
|
+
return Object.freeze({
|
|
823
|
+
id,
|
|
824
|
+
query,
|
|
825
|
+
override: cloneBreakpointOverride(candidate["override"], id, base, canvasEnabled)
|
|
826
|
+
});
|
|
827
|
+
});
|
|
828
|
+
return Object.freeze(breakpoints);
|
|
829
|
+
};
|
|
830
|
+
const mergeOption = (current, override) => isRecord$3(current) && isRecord$3(override) ? Object.freeze({
|
|
831
|
+
...current,
|
|
832
|
+
...override
|
|
833
|
+
}) : cloneOption(override);
|
|
834
|
+
const mergeBindingDefinition = (current, override) => {
|
|
835
|
+
const next = { ...current };
|
|
836
|
+
for (const field of [
|
|
837
|
+
"segments",
|
|
838
|
+
"clips",
|
|
839
|
+
"easing"
|
|
840
|
+
]) if (hasOwn(override, field)) next[field] = override[field];
|
|
841
|
+
for (const field of [
|
|
842
|
+
"frame",
|
|
843
|
+
"loading",
|
|
844
|
+
"video",
|
|
845
|
+
"seek",
|
|
846
|
+
"canvas"
|
|
847
|
+
]) if (hasOwn(override, field)) next[field] = mergeOption(current[field], override[field]);
|
|
848
|
+
const frame = next["frame"];
|
|
849
|
+
if (isRecord$3(frame) && frame["snap"] === false) {
|
|
850
|
+
const withoutFps = { ...frame };
|
|
851
|
+
delete withoutFps["fps"];
|
|
852
|
+
next["frame"] = Object.freeze(withoutFps);
|
|
853
|
+
}
|
|
854
|
+
const loading = next["loading"];
|
|
855
|
+
if (isRecord$3(loading)) {
|
|
856
|
+
const normalized = { ...loading };
|
|
857
|
+
if (normalized["mode"] === "immediate") {
|
|
858
|
+
delete normalized["trigger"];
|
|
859
|
+
delete normalized["rootMargin"];
|
|
860
|
+
} else if (normalized["trigger"] !== "target-near-viewport") delete normalized["rootMargin"];
|
|
861
|
+
next["loading"] = Object.freeze(normalized);
|
|
862
|
+
}
|
|
863
|
+
return Object.freeze(next);
|
|
864
|
+
};
|
|
865
|
+
/** Resolves and revalidates one ordered set of matching breakpoint IDs. */
|
|
866
|
+
const resolveControllerConfig = (program, activeBreakpointIds) => {
|
|
867
|
+
const active = new Set(activeBreakpointIds);
|
|
868
|
+
const axes = /* @__PURE__ */ new Map();
|
|
869
|
+
for (const axis of AXES) {
|
|
870
|
+
const baseAxis = program.base.axes[axis];
|
|
871
|
+
if (baseAxis !== void 0) axes.set(axis, {
|
|
872
|
+
enabled: baseAxis.enabled,
|
|
873
|
+
bindings: new Map(baseAxis.bindings.map((binding) => [binding.id, binding.definition]))
|
|
874
|
+
});
|
|
875
|
+
}
|
|
876
|
+
for (const breakpoint of program.breakpoints) {
|
|
877
|
+
if (!active.has(breakpoint.id)) continue;
|
|
878
|
+
const breakpointAxes = breakpoint.override["axes"];
|
|
879
|
+
for (const axis of AXES) {
|
|
880
|
+
if (!hasOwn(breakpointAxes, axis)) continue;
|
|
881
|
+
const target = axes.get(axis);
|
|
882
|
+
const override = breakpointAxes[axis];
|
|
883
|
+
if (target === void 0) continue;
|
|
884
|
+
if (override === false) {
|
|
885
|
+
target.enabled = false;
|
|
886
|
+
continue;
|
|
887
|
+
}
|
|
888
|
+
if (!isRecord$3(override)) continue;
|
|
889
|
+
if (hasOwn(override, "enabled")) target.enabled = override["enabled"];
|
|
890
|
+
const bindingOverrides = override["bindings"];
|
|
891
|
+
if (Array.isArray(bindingOverrides)) for (const bindingOverride of bindingOverrides) {
|
|
892
|
+
const bindingId = bindingOverride["id"];
|
|
893
|
+
const current = target.bindings.get(bindingId);
|
|
894
|
+
if (current !== void 0) target.bindings.set(bindingId, mergeBindingDefinition(current, bindingOverride));
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
const resolvedAxes = {};
|
|
899
|
+
for (const [axis, value] of axes) resolvedAxes[axis] = {
|
|
900
|
+
enabled: value.enabled,
|
|
901
|
+
bindings: [...value.bindings.values()]
|
|
902
|
+
};
|
|
903
|
+
try {
|
|
904
|
+
return compileControllerConfig({
|
|
905
|
+
...program.base.source === void 0 ? {} : { source: program.base.source },
|
|
906
|
+
axes: resolvedAxes
|
|
907
|
+
}, program.supportedRenderers);
|
|
908
|
+
} catch (cause) {
|
|
909
|
+
return invalidBreakpoint("The matching breakpoint cascade does not produce a valid controller configuration.", { activeBreakpoints: Object.freeze([...activeBreakpointIds]) }, cause);
|
|
910
|
+
}
|
|
911
|
+
};
|
|
912
|
+
/** Compiles the immutable base configuration and responsive program at factory time. */
|
|
913
|
+
const compileControllerProgram = (options, supportedRenderers = /* @__PURE__ */ new Set(["video"])) => {
|
|
914
|
+
const base = compileControllerConfig(options, supportedRenderers);
|
|
915
|
+
const reducedMotion = options.reducedMotion ?? "first-frame";
|
|
916
|
+
if (!REDUCED_MOTION_BEHAVIORS.includes(reducedMotion)) return invalidController("Controller reducedMotion behavior is invalid.", { reducedMotion });
|
|
917
|
+
return Object.freeze({
|
|
918
|
+
base,
|
|
919
|
+
breakpoints: compileBreakpoints(options.breakpoints, base, supportedRenderers.has("canvas")),
|
|
920
|
+
reducedMotion,
|
|
921
|
+
supportedRenderers
|
|
922
|
+
});
|
|
923
|
+
};
|
|
924
|
+
|
|
925
|
+
//#endregion
|
|
926
|
+
//#region src/responsive/environment-observer.ts
|
|
927
|
+
const isRecord$2 = (value) => typeof value === "object" && value !== null;
|
|
928
|
+
const getDocument = (source) => {
|
|
929
|
+
if (source.nodeType === 9) return source;
|
|
930
|
+
const ownerDocument = source.ownerDocument;
|
|
931
|
+
return ownerDocument.nodeType === 9 ? ownerDocument : null;
|
|
932
|
+
};
|
|
933
|
+
const getView = (document) => {
|
|
934
|
+
const ownerView = document?.defaultView;
|
|
935
|
+
if (isRecord$2(ownerView)) return ownerView;
|
|
936
|
+
const globalWindow = globalThis.window;
|
|
937
|
+
return isRecord$2(globalWindow) ? globalWindow : null;
|
|
938
|
+
};
|
|
939
|
+
const freezeSnapshot = (activeBreakpoints, prefersReducedMotion, hidden) => Object.freeze({
|
|
940
|
+
activeBreakpoints: Object.freeze([...activeBreakpoints]),
|
|
941
|
+
prefersReducedMotion,
|
|
942
|
+
hidden
|
|
943
|
+
});
|
|
944
|
+
const arraysEqual = (left, right) => left.length === right.length && left.every((value, index) => value === right[index]);
|
|
945
|
+
const listenToMediaQuery = (query, listener) => {
|
|
946
|
+
if (query.addEventListener !== void 0 && query.removeEventListener !== void 0) {
|
|
947
|
+
query.addEventListener("change", listener);
|
|
948
|
+
return () => {
|
|
949
|
+
query.removeEventListener?.("change", listener);
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
if (query.addListener !== void 0 && query.removeListener !== void 0) {
|
|
953
|
+
query.addListener(listener);
|
|
954
|
+
return () => {
|
|
955
|
+
query.removeListener?.(listener);
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
return () => void 0;
|
|
959
|
+
};
|
|
960
|
+
/** Mount-scoped browser observers with no import-time DOM access or timers. */
|
|
961
|
+
const createControllerEnvironmentObserver = (options) => {
|
|
962
|
+
const document = getDocument(options.source);
|
|
963
|
+
const view = getView(document);
|
|
964
|
+
const mediaQueries = /* @__PURE__ */ new Map();
|
|
965
|
+
const cleanups = [];
|
|
966
|
+
let reducedMotionQuery = null;
|
|
967
|
+
let resizeObserver = null;
|
|
968
|
+
let resizeFrame = null;
|
|
969
|
+
let mediaScheduled = false;
|
|
970
|
+
let destroyed = false;
|
|
971
|
+
const readHidden = () => document?.hidden === true;
|
|
972
|
+
const readSnapshot = () => freezeSnapshot(options.breakpoints.filter((breakpoint) => mediaQueries.get(breakpoint.id)?.matches === true).map((breakpoint) => breakpoint.id), reducedMotionQuery?.matches === true, readHidden());
|
|
973
|
+
let snapshot = freezeSnapshot([], false, readHidden());
|
|
974
|
+
const publishMedia = () => {
|
|
975
|
+
mediaScheduled = false;
|
|
976
|
+
if (destroyed) return;
|
|
977
|
+
const next = readSnapshot();
|
|
978
|
+
if (next.prefersReducedMotion !== snapshot.prefersReducedMotion || !arraysEqual(next.activeBreakpoints, snapshot.activeBreakpoints)) {
|
|
979
|
+
snapshot = next;
|
|
980
|
+
options.onMediaChange(snapshot);
|
|
981
|
+
}
|
|
982
|
+
};
|
|
983
|
+
const scheduleMedia = () => {
|
|
984
|
+
if (!mediaScheduled) {
|
|
985
|
+
mediaScheduled = true;
|
|
986
|
+
globalThis.queueMicrotask(publishMedia);
|
|
987
|
+
}
|
|
988
|
+
};
|
|
989
|
+
if (view?.matchMedia !== void 0) {
|
|
990
|
+
for (const breakpoint of options.breakpoints) {
|
|
991
|
+
let mediaQuery;
|
|
992
|
+
try {
|
|
993
|
+
mediaQuery = view.matchMedia(breakpoint.query);
|
|
994
|
+
} catch (cause) {
|
|
995
|
+
for (const cleanup of cleanups.splice(0)) cleanup();
|
|
996
|
+
throw new FrameByFrameError("INVALID_BREAKPOINT_CONFIG", `The media query for breakpoint "${breakpoint.id}" could not be evaluated.`, {
|
|
997
|
+
cause,
|
|
998
|
+
details: {
|
|
999
|
+
breakpointId: breakpoint.id,
|
|
1000
|
+
query: breakpoint.query
|
|
1001
|
+
}
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
mediaQueries.set(breakpoint.id, mediaQuery);
|
|
1005
|
+
cleanups.push(listenToMediaQuery(mediaQuery, scheduleMedia));
|
|
1006
|
+
}
|
|
1007
|
+
try {
|
|
1008
|
+
reducedMotionQuery = view.matchMedia("(prefers-reduced-motion: reduce)");
|
|
1009
|
+
} catch (cause) {
|
|
1010
|
+
for (const cleanup of cleanups.splice(0)) cleanup();
|
|
1011
|
+
throw new FrameByFrameError("ENVIRONMENT_UNAVAILABLE", "The reduced-motion media query could not be evaluated.", { cause });
|
|
1012
|
+
}
|
|
1013
|
+
cleanups.push(listenToMediaQuery(reducedMotionQuery, scheduleMedia));
|
|
1014
|
+
}
|
|
1015
|
+
snapshot = readSnapshot();
|
|
1016
|
+
const scheduleResize = () => {
|
|
1017
|
+
if (resizeFrame === null && !snapshot.hidden && !destroyed) resizeFrame = options.requestFrame(() => {
|
|
1018
|
+
resizeFrame = null;
|
|
1019
|
+
options.onResize();
|
|
1020
|
+
});
|
|
1021
|
+
};
|
|
1022
|
+
if (view?.addEventListener !== void 0 && view.removeEventListener !== void 0) {
|
|
1023
|
+
view.addEventListener("resize", scheduleResize, { passive: true });
|
|
1024
|
+
cleanups.push(() => {
|
|
1025
|
+
view.removeEventListener?.("resize", scheduleResize);
|
|
1026
|
+
});
|
|
1027
|
+
}
|
|
1028
|
+
if (document !== null) {
|
|
1029
|
+
const handleVisibility = () => {
|
|
1030
|
+
const hidden = readHidden();
|
|
1031
|
+
if (hidden === snapshot.hidden) return;
|
|
1032
|
+
snapshot = freezeSnapshot(snapshot.activeBreakpoints, snapshot.prefersReducedMotion, hidden);
|
|
1033
|
+
if (hidden && resizeFrame !== null) {
|
|
1034
|
+
options.cancelFrame(resizeFrame);
|
|
1035
|
+
resizeFrame = null;
|
|
1036
|
+
}
|
|
1037
|
+
options.onVisibilityChange(hidden);
|
|
1038
|
+
};
|
|
1039
|
+
document.addEventListener("visibilitychange", handleVisibility);
|
|
1040
|
+
cleanups.push(() => {
|
|
1041
|
+
document.removeEventListener("visibilitychange", handleVisibility);
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
const ResizeObserverConstructor = view?.ResizeObserver ?? globalThis.ResizeObserver;
|
|
1045
|
+
if (typeof ResizeObserverConstructor === "function") try {
|
|
1046
|
+
resizeObserver = new ResizeObserverConstructor(scheduleResize);
|
|
1047
|
+
const sourceTarget = options.source.nodeType === 9 ? document?.documentElement : options.source;
|
|
1048
|
+
if (sourceTarget !== void 0) resizeObserver.observe(sourceTarget);
|
|
1049
|
+
} catch {
|
|
1050
|
+
resizeObserver?.disconnect();
|
|
1051
|
+
resizeObserver = null;
|
|
1052
|
+
}
|
|
1053
|
+
return {
|
|
1054
|
+
getSnapshot: () => snapshot,
|
|
1055
|
+
observeTargets: (targets) => {
|
|
1056
|
+
for (const target of targets) resizeObserver?.observe(target);
|
|
1057
|
+
},
|
|
1058
|
+
destroy: () => {
|
|
1059
|
+
if (destroyed) return;
|
|
1060
|
+
destroyed = true;
|
|
1061
|
+
if (resizeFrame !== null) {
|
|
1062
|
+
options.cancelFrame(resizeFrame);
|
|
1063
|
+
resizeFrame = null;
|
|
1064
|
+
}
|
|
1065
|
+
resizeObserver?.disconnect();
|
|
1066
|
+
resizeObserver = null;
|
|
1067
|
+
for (const cleanup of cleanups.splice(0)) cleanup();
|
|
1068
|
+
}
|
|
1069
|
+
};
|
|
1070
|
+
};
|
|
1071
|
+
|
|
1072
|
+
//#endregion
|
|
1073
|
+
//#region src/core/controller.ts
|
|
1074
|
+
const createInitialMediaState = () => ({
|
|
1075
|
+
loadState: "idle",
|
|
1076
|
+
loadProgress: Object.freeze({}),
|
|
1077
|
+
activeClipId: null,
|
|
1078
|
+
selectedSource: null,
|
|
1079
|
+
duration: null,
|
|
1080
|
+
appliedTime: null,
|
|
1081
|
+
presentedTime: null,
|
|
1082
|
+
seeking: false,
|
|
1083
|
+
error: null
|
|
1084
|
+
});
|
|
1085
|
+
const cloneResolution = (resolution) => Object.freeze({ ...resolution });
|
|
1086
|
+
const cloneError = (error) => new FrameByFrameError(error.code, error.message, {
|
|
1087
|
+
cause: error.cause,
|
|
1088
|
+
...error.details === void 0 ? {} : { details: error.details }
|
|
1089
|
+
});
|
|
1090
|
+
const asPackageError = (error, code, message) => error instanceof FrameByFrameError ? error : new FrameByFrameError(code, message, { cause: error });
|
|
1091
|
+
var Controller = class {
|
|
1092
|
+
#program;
|
|
1093
|
+
#config;
|
|
1094
|
+
#dependencies;
|
|
1095
|
+
#events;
|
|
1096
|
+
#axes = {};
|
|
1097
|
+
#bindings = /* @__PURE__ */ new Map();
|
|
1098
|
+
#handleScroll = (snapshot) => {
|
|
1099
|
+
if (this.#status !== "ready" || !this.#enabled || this.#hidden) return;
|
|
1100
|
+
try {
|
|
1101
|
+
this.#applySnapshot(snapshot, true);
|
|
1102
|
+
this.#emitUpdate("scroll");
|
|
1103
|
+
} catch (error) {
|
|
1104
|
+
this.#transitionToRuntimeError(error);
|
|
1105
|
+
}
|
|
1106
|
+
};
|
|
1107
|
+
#status = "idle";
|
|
1108
|
+
#enabled = true;
|
|
1109
|
+
#source = null;
|
|
1110
|
+
#scheduler = null;
|
|
1111
|
+
#unsubscribe = null;
|
|
1112
|
+
#mountPromise = null;
|
|
1113
|
+
#mountGeneration = 0;
|
|
1114
|
+
#lastError = null;
|
|
1115
|
+
#environment = null;
|
|
1116
|
+
#activeBreakpoints = Object.freeze([]);
|
|
1117
|
+
#prefersReducedMotion = false;
|
|
1118
|
+
#hidden = false;
|
|
1119
|
+
constructor(options, dependencies) {
|
|
1120
|
+
this.#program = compileControllerProgram(options, dependencies.supportedRenderers);
|
|
1121
|
+
this.#config = this.#program.base;
|
|
1122
|
+
this.#dependencies = dependencies;
|
|
1123
|
+
this.#events = new EventEmitter(dependencies.reportAsyncError);
|
|
1124
|
+
for (const [axisName, axisConfig] of Object.entries(this.#config.axes)) this.#axes[axisName] = {
|
|
1125
|
+
enabled: axisConfig.enabled,
|
|
1126
|
+
offset: 0,
|
|
1127
|
+
max: 0,
|
|
1128
|
+
progress: 0
|
|
1129
|
+
};
|
|
1130
|
+
for (const binding of this.#config.bindings) this.#bindings.set(binding.id, {
|
|
1131
|
+
config: binding,
|
|
1132
|
+
resolution: null,
|
|
1133
|
+
renderer: null,
|
|
1134
|
+
media: createInitialMediaState()
|
|
1135
|
+
});
|
|
1136
|
+
}
|
|
1137
|
+
mount() {
|
|
1138
|
+
this.#assertNotDestroyed();
|
|
1139
|
+
if (this.#status === "ready" || this.#status === "disabled") return Promise.resolve();
|
|
1140
|
+
if (this.#status === "mounting" && this.#mountPromise !== null) return this.#mountPromise;
|
|
1141
|
+
const generation = ++this.#mountGeneration;
|
|
1142
|
+
this.#status = "mounting";
|
|
1143
|
+
this.#lastError = null;
|
|
1144
|
+
const promise = Promise.resolve().then(() => {
|
|
1145
|
+
this.#performMount(generation);
|
|
1146
|
+
}).catch((error) => {
|
|
1147
|
+
throw this.#handleMountFailure(error, generation);
|
|
1148
|
+
}).finally(() => {
|
|
1149
|
+
if (this.#mountPromise === promise) this.#mountPromise = null;
|
|
1150
|
+
});
|
|
1151
|
+
this.#mountPromise = promise;
|
|
1152
|
+
return promise;
|
|
1153
|
+
}
|
|
1154
|
+
refresh() {
|
|
1155
|
+
this.#assertNotDestroyed();
|
|
1156
|
+
if (this.#status !== "ready" && this.#status !== "disabled" || this.#scheduler === null) throw new FrameByFrameError("INVALID_LIFECYCLE_OPERATION", "refresh() requires a successfully mounted controller.", { details: { status: this.#status } });
|
|
1157
|
+
try {
|
|
1158
|
+
for (const binding of this.#bindings.values()) binding.renderer?.resize();
|
|
1159
|
+
this.#applySnapshot(this.#scheduler.refresh(), this.#enabled);
|
|
1160
|
+
this.#emitUpdate("refresh");
|
|
1161
|
+
} catch (error) {
|
|
1162
|
+
throw this.#transitionToRuntimeError(error);
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
enable() {
|
|
1166
|
+
this.#assertNotDestroyed();
|
|
1167
|
+
if (this.#enabled) return;
|
|
1168
|
+
this.#enabled = true;
|
|
1169
|
+
if (this.#status === "disabled" && this.#scheduler !== null) try {
|
|
1170
|
+
this.#applySnapshot(this.#scheduler.getSnapshot(), true);
|
|
1171
|
+
this.#status = "ready";
|
|
1172
|
+
this.#subscribe();
|
|
1173
|
+
} catch (error) {
|
|
1174
|
+
throw this.#transitionToRuntimeError(error);
|
|
1175
|
+
}
|
|
1176
|
+
this.#emitUpdate("enable");
|
|
1177
|
+
}
|
|
1178
|
+
disable() {
|
|
1179
|
+
this.#assertNotDestroyed();
|
|
1180
|
+
if (!this.#enabled) return;
|
|
1181
|
+
this.#enabled = false;
|
|
1182
|
+
if (this.#status === "ready") {
|
|
1183
|
+
this.#unsubscribeFromSource();
|
|
1184
|
+
this.#status = "disabled";
|
|
1185
|
+
}
|
|
1186
|
+
this.#emitUpdate("disable");
|
|
1187
|
+
}
|
|
1188
|
+
load(bindingId) {
|
|
1189
|
+
this.#assertMediaLifecycle("load()");
|
|
1190
|
+
return Promise.all(this.#selectBindings(bindingId).map((binding) => this.#getMountedRenderer(binding).load())).then(() => void 0);
|
|
1191
|
+
}
|
|
1192
|
+
whenReady() {
|
|
1193
|
+
this.#assertMediaLifecycle("whenReady()");
|
|
1194
|
+
return Promise.all([...this.#bindings.values()].map((binding) => this.#getMountedRenderer(binding).whenReady())).then(() => {
|
|
1195
|
+
this.#assertNotDestroyed();
|
|
1196
|
+
return this.getState();
|
|
1197
|
+
});
|
|
1198
|
+
}
|
|
1199
|
+
unload(bindingId) {
|
|
1200
|
+
this.#assertMediaLifecycle("unload()");
|
|
1201
|
+
for (const binding of this.#selectBindings(bindingId)) this.#getMountedRenderer(binding).unload();
|
|
1202
|
+
}
|
|
1203
|
+
getTarget(bindingId) {
|
|
1204
|
+
this.#assertNotDestroyed();
|
|
1205
|
+
return this.#getBinding(bindingId).renderer?.getTarget() ?? null;
|
|
1206
|
+
}
|
|
1207
|
+
getState() {
|
|
1208
|
+
const axes = {};
|
|
1209
|
+
for (const [axisName, axis] of Object.entries(this.#axes)) axes[axisName] = Object.freeze({
|
|
1210
|
+
enabled: axis.enabled,
|
|
1211
|
+
offset: axis.offset,
|
|
1212
|
+
max: axis.max,
|
|
1213
|
+
progress: axis.progress
|
|
1214
|
+
});
|
|
1215
|
+
const bindings = {};
|
|
1216
|
+
for (const [id, binding] of this.#bindings) {
|
|
1217
|
+
const media = binding.renderer?.getState() ?? binding.media;
|
|
1218
|
+
bindings[id] = Object.freeze({
|
|
1219
|
+
id,
|
|
1220
|
+
axis: binding.config.axis,
|
|
1221
|
+
resolution: binding.resolution === null ? null : cloneResolution(binding.resolution),
|
|
1222
|
+
renderer: binding.config.renderer,
|
|
1223
|
+
loadState: media.loadState,
|
|
1224
|
+
loadProgress: Object.freeze(Object.fromEntries(Object.entries(media.loadProgress).map(([clipId, progress]) => [clipId, Object.freeze({ ...progress })]))),
|
|
1225
|
+
activeClipId: media.activeClipId,
|
|
1226
|
+
selectedSource: media.selectedSource,
|
|
1227
|
+
duration: media.duration,
|
|
1228
|
+
appliedTime: media.appliedTime,
|
|
1229
|
+
presentedTime: media.presentedTime,
|
|
1230
|
+
seeking: media.seeking,
|
|
1231
|
+
error: media.error === null ? null : cloneError(media.error)
|
|
1232
|
+
});
|
|
1233
|
+
}
|
|
1234
|
+
return Object.freeze({
|
|
1235
|
+
status: this.#status,
|
|
1236
|
+
enabled: this.#enabled,
|
|
1237
|
+
source: this.#source,
|
|
1238
|
+
activeBreakpoints: Object.freeze([...this.#activeBreakpoints]),
|
|
1239
|
+
prefersReducedMotion: this.#prefersReducedMotion,
|
|
1240
|
+
axes: Object.freeze(axes),
|
|
1241
|
+
bindings: Object.freeze(bindings),
|
|
1242
|
+
lastError: this.#lastError === null ? null : cloneError(this.#lastError)
|
|
1243
|
+
});
|
|
1244
|
+
}
|
|
1245
|
+
on(event, listener) {
|
|
1246
|
+
this.#assertNotDestroyed();
|
|
1247
|
+
if (typeof listener !== "function") throw new FrameByFrameError("INVALID_CONTROLLER", "Event listeners must be functions.", { details: {
|
|
1248
|
+
event,
|
|
1249
|
+
listener
|
|
1250
|
+
} });
|
|
1251
|
+
return this.#events.on(event, listener);
|
|
1252
|
+
}
|
|
1253
|
+
destroy() {
|
|
1254
|
+
if (this.#status === "destroyed") return;
|
|
1255
|
+
++this.#mountGeneration;
|
|
1256
|
+
this.#unsubscribeFromSource();
|
|
1257
|
+
this.#destroyEnvironment();
|
|
1258
|
+
this.#destroyRenderers();
|
|
1259
|
+
this.#scheduler = null;
|
|
1260
|
+
this.#source = null;
|
|
1261
|
+
this.#enabled = false;
|
|
1262
|
+
this.#status = "destroyed";
|
|
1263
|
+
this.#events.emit("destroy", this.getState());
|
|
1264
|
+
this.#events.clear();
|
|
1265
|
+
}
|
|
1266
|
+
#performMount(generation) {
|
|
1267
|
+
if (generation !== this.#mountGeneration) throw new FrameByFrameError("CONTROLLER_DESTROYED", "The controller was destroyed before mount completed.");
|
|
1268
|
+
const resolvedSource = this.#dependencies.resolveSource(this.#config.source);
|
|
1269
|
+
const scheduler = this.#dependencies.sourceRegistry.get(resolvedSource);
|
|
1270
|
+
const snapshot = scheduler.refresh();
|
|
1271
|
+
const environment = (this.#dependencies.createEnvironmentObserver ?? createControllerEnvironmentObserver)({
|
|
1272
|
+
source: resolvedSource.publicSource,
|
|
1273
|
+
breakpoints: this.#program.breakpoints,
|
|
1274
|
+
requestFrame: resolvedSource.requestFrame,
|
|
1275
|
+
cancelFrame: resolvedSource.cancelFrame,
|
|
1276
|
+
onMediaChange: (next) => {
|
|
1277
|
+
this.#handleMediaChange(next);
|
|
1278
|
+
},
|
|
1279
|
+
onResize: () => {
|
|
1280
|
+
this.#handleResize();
|
|
1281
|
+
},
|
|
1282
|
+
onVisibilityChange: (hidden) => {
|
|
1283
|
+
this.#handleVisibilityChange(hidden);
|
|
1284
|
+
}
|
|
1285
|
+
});
|
|
1286
|
+
const environmentSnapshot = environment.getSnapshot();
|
|
1287
|
+
let initialConfig;
|
|
1288
|
+
try {
|
|
1289
|
+
initialConfig = resolveControllerConfig(this.#program, environmentSnapshot.activeBreakpoints);
|
|
1290
|
+
} catch (error) {
|
|
1291
|
+
environment.destroy();
|
|
1292
|
+
throw error;
|
|
1293
|
+
}
|
|
1294
|
+
this.#environment = environment;
|
|
1295
|
+
this.#activeBreakpoints = environmentSnapshot.activeBreakpoints;
|
|
1296
|
+
this.#prefersReducedMotion = environmentSnapshot.prefersReducedMotion;
|
|
1297
|
+
this.#hidden = environmentSnapshot.hidden;
|
|
1298
|
+
this.#adoptConfig(initialConfig);
|
|
1299
|
+
this.#createRenderers();
|
|
1300
|
+
if (generation !== this.#mountGeneration || this.#status === "destroyed") throw new FrameByFrameError("CONTROLLER_DESTROYED", "The controller was destroyed before mount completed.");
|
|
1301
|
+
this.#source = resolvedSource.publicSource;
|
|
1302
|
+
this.#scheduler = scheduler;
|
|
1303
|
+
this.#applyRendererActivity();
|
|
1304
|
+
this.#applySnapshot(snapshot, this.#enabled);
|
|
1305
|
+
this.#status = this.#enabled ? "ready" : "disabled";
|
|
1306
|
+
this.#subscribe();
|
|
1307
|
+
environment.observeTargets([...this.#bindings.values()].map((binding) => binding.renderer?.getTarget() ?? null).filter((target) => target !== null));
|
|
1308
|
+
const state = this.getState();
|
|
1309
|
+
this.#events.emit("mount", state);
|
|
1310
|
+
this.#events.emit("update", {
|
|
1311
|
+
reason: "mount",
|
|
1312
|
+
state: this.getState()
|
|
1313
|
+
});
|
|
1314
|
+
}
|
|
1315
|
+
#handleMountFailure(error, generation) {
|
|
1316
|
+
const packageError = asPackageError(error, "SOURCE_NOT_FOUND", "The controller could not mount its scroll source.");
|
|
1317
|
+
if (this.#status === "destroyed" || generation !== this.#mountGeneration) return packageError.code === "CONTROLLER_DESTROYED" ? packageError : new FrameByFrameError("CONTROLLER_DESTROYED", "The controller was destroyed before mount completed.", { cause: packageError });
|
|
1318
|
+
this.#unsubscribeFromSource();
|
|
1319
|
+
this.#destroyEnvironment();
|
|
1320
|
+
this.#destroyRenderers();
|
|
1321
|
+
this.#scheduler = null;
|
|
1322
|
+
this.#source = null;
|
|
1323
|
+
this.#status = "error";
|
|
1324
|
+
this.#lastError = packageError;
|
|
1325
|
+
this.#events.emit("error", cloneError(packageError));
|
|
1326
|
+
return packageError;
|
|
1327
|
+
}
|
|
1328
|
+
#applySnapshot(snapshot, resolveBindings) {
|
|
1329
|
+
for (const axisName of ["x", "y"]) {
|
|
1330
|
+
const axis = this.#axes[axisName];
|
|
1331
|
+
if (axis === void 0) continue;
|
|
1332
|
+
const metrics = snapshot[axisName];
|
|
1333
|
+
axis.offset = metrics.offset;
|
|
1334
|
+
axis.max = metrics.max;
|
|
1335
|
+
axis.progress = metrics.progress;
|
|
1336
|
+
}
|
|
1337
|
+
for (const binding of this.#bindings.values()) {
|
|
1338
|
+
const axis = this.#axes[binding.config.axis];
|
|
1339
|
+
const reducedMotionBehavior = this.#program.reducedMotion;
|
|
1340
|
+
if (!resolveBindings || !axis?.enabled || this.#prefersReducedMotion && reducedMotionBehavior === "disable") {
|
|
1341
|
+
binding.resolution = null;
|
|
1342
|
+
binding.renderer?.setResolution(null);
|
|
1343
|
+
continue;
|
|
1344
|
+
}
|
|
1345
|
+
const position = this.#prefersReducedMotion && reducedMotionBehavior === "first-frame" ? binding.config.startPosition : this.#prefersReducedMotion && reducedMotionBehavior === "last-frame" ? binding.config.endPosition : binding.config.timeline.unit === "px" ? axis.offset : axis.progress;
|
|
1346
|
+
binding.resolution = binding.config.timeline.resolve(position);
|
|
1347
|
+
binding.renderer?.setResolution(binding.resolution);
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
#createRenderers() {
|
|
1351
|
+
try {
|
|
1352
|
+
for (const binding of this.#bindings.values()) {
|
|
1353
|
+
const renderer = this.#dependencies.createRenderer(binding.config, (event) => {
|
|
1354
|
+
this.#handleRendererEvent(binding.config.id, event);
|
|
1355
|
+
}, this.#getRendererActivity());
|
|
1356
|
+
binding.renderer = renderer;
|
|
1357
|
+
binding.media = renderer.getState();
|
|
1358
|
+
}
|
|
1359
|
+
} catch (error) {
|
|
1360
|
+
this.#destroyRenderers();
|
|
1361
|
+
throw error;
|
|
1362
|
+
}
|
|
1363
|
+
}
|
|
1364
|
+
#destroyRenderers() {
|
|
1365
|
+
for (const binding of this.#bindings.values()) {
|
|
1366
|
+
const renderer = binding.renderer;
|
|
1367
|
+
if (renderer === null) continue;
|
|
1368
|
+
renderer.destroy();
|
|
1369
|
+
binding.media = renderer.getState();
|
|
1370
|
+
binding.renderer = null;
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
#handleRendererEvent(bindingId, event) {
|
|
1374
|
+
const binding = this.#bindings.get(bindingId);
|
|
1375
|
+
const renderer = binding?.renderer;
|
|
1376
|
+
if (binding === void 0 || renderer === void 0 || renderer === null || this.#status === "destroyed") return;
|
|
1377
|
+
binding.media = renderer.getState();
|
|
1378
|
+
switch (event.type) {
|
|
1379
|
+
case "loadstart":
|
|
1380
|
+
case "loadready":
|
|
1381
|
+
this.#events.emit(event.type, {
|
|
1382
|
+
bindingId,
|
|
1383
|
+
clipId: event.clipId,
|
|
1384
|
+
state: this.getState()
|
|
1385
|
+
});
|
|
1386
|
+
break;
|
|
1387
|
+
case "loadprogress":
|
|
1388
|
+
this.#events.emit("loadprogress", {
|
|
1389
|
+
bindingId,
|
|
1390
|
+
clipId: event.clipId,
|
|
1391
|
+
loadedBytes: event.progress.loadedBytes,
|
|
1392
|
+
totalBytes: event.progress.totalBytes,
|
|
1393
|
+
ratio: event.progress.ratio,
|
|
1394
|
+
state: this.getState()
|
|
1395
|
+
});
|
|
1396
|
+
break;
|
|
1397
|
+
case "loadedmetadata":
|
|
1398
|
+
this.#events.emit("loadedmetadata", {
|
|
1399
|
+
bindingId,
|
|
1400
|
+
clipId: event.clipId,
|
|
1401
|
+
duration: event.duration,
|
|
1402
|
+
state: this.getState()
|
|
1403
|
+
});
|
|
1404
|
+
break;
|
|
1405
|
+
case "seekrequest":
|
|
1406
|
+
this.#events.emit("seekrequest", {
|
|
1407
|
+
bindingId,
|
|
1408
|
+
clipId: event.clipId,
|
|
1409
|
+
requestedTime: event.requestedTime,
|
|
1410
|
+
targetTime: event.targetTime,
|
|
1411
|
+
state: this.getState()
|
|
1412
|
+
});
|
|
1413
|
+
break;
|
|
1414
|
+
case "frame":
|
|
1415
|
+
this.#events.emit("frame", {
|
|
1416
|
+
bindingId,
|
|
1417
|
+
clipId: event.clipId,
|
|
1418
|
+
presentedTime: event.presentedTime,
|
|
1419
|
+
expectedDisplayTime: event.expectedDisplayTime,
|
|
1420
|
+
width: event.width,
|
|
1421
|
+
height: event.height,
|
|
1422
|
+
state: this.getState()
|
|
1423
|
+
});
|
|
1424
|
+
break;
|
|
1425
|
+
case "error":
|
|
1426
|
+
this.#events.emit("error", cloneError(event.error));
|
|
1427
|
+
break;
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
#adoptConfig(config) {
|
|
1431
|
+
this.#config = config;
|
|
1432
|
+
for (const axisName of ["x", "y"]) {
|
|
1433
|
+
const axis = this.#axes[axisName];
|
|
1434
|
+
const next = config.axes[axisName];
|
|
1435
|
+
if (axis !== void 0 && next !== void 0) axis.enabled = next.enabled;
|
|
1436
|
+
}
|
|
1437
|
+
for (const next of config.bindings) {
|
|
1438
|
+
const binding = this.#bindings.get(next.id);
|
|
1439
|
+
if (binding !== void 0) binding.config = next;
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
#applyResponsiveConfig(config) {
|
|
1443
|
+
const transactions = [];
|
|
1444
|
+
try {
|
|
1445
|
+
for (const next of config.bindings) {
|
|
1446
|
+
const binding = this.#bindings.get(next.id);
|
|
1447
|
+
if (binding?.renderer !== null && binding?.renderer !== void 0 && binding.config.mediaSignature !== next.mediaSignature) transactions.push(binding.renderer.prepareConfig(next));
|
|
1448
|
+
}
|
|
1449
|
+
} catch (cause) {
|
|
1450
|
+
for (const transaction of transactions) transaction.cancel();
|
|
1451
|
+
throw asPackageError(cause, "INVALID_BREAKPOINT_CONFIG", "The responsive media configuration could not be prepared atomically.");
|
|
1452
|
+
}
|
|
1453
|
+
for (const transaction of transactions) transaction.commit();
|
|
1454
|
+
this.#adoptConfig(config);
|
|
1455
|
+
}
|
|
1456
|
+
#handleMediaChange(snapshot) {
|
|
1457
|
+
if (this.#status !== "ready" && this.#status !== "disabled") return;
|
|
1458
|
+
let breakpointChanged = snapshot.activeBreakpoints.length !== this.#activeBreakpoints.length || snapshot.activeBreakpoints.some((id, index) => id !== this.#activeBreakpoints[index]);
|
|
1459
|
+
const preferenceChanged = snapshot.prefersReducedMotion !== this.#prefersReducedMotion;
|
|
1460
|
+
const previousBreakpoints = this.#activeBreakpoints;
|
|
1461
|
+
if (breakpointChanged) try {
|
|
1462
|
+
const candidate = resolveControllerConfig(this.#program, snapshot.activeBreakpoints);
|
|
1463
|
+
this.#applyResponsiveConfig(candidate);
|
|
1464
|
+
this.#activeBreakpoints = Object.freeze([...snapshot.activeBreakpoints]);
|
|
1465
|
+
this.#lastError = null;
|
|
1466
|
+
} catch (error) {
|
|
1467
|
+
const packageError = asPackageError(error, "INVALID_BREAKPOINT_CONFIG", "The responsive configuration change was rejected.");
|
|
1468
|
+
this.#lastError = packageError;
|
|
1469
|
+
this.#events.emit("error", cloneError(packageError));
|
|
1470
|
+
breakpointChanged = false;
|
|
1471
|
+
}
|
|
1472
|
+
if (preferenceChanged) this.#prefersReducedMotion = snapshot.prefersReducedMotion;
|
|
1473
|
+
if (!breakpointChanged && !preferenceChanged) return;
|
|
1474
|
+
try {
|
|
1475
|
+
this.#applyRendererActivity();
|
|
1476
|
+
if (this.#scheduler !== null) {
|
|
1477
|
+
const scrollSnapshot = breakpointChanged ? this.#scheduler.refresh() : this.#scheduler.getSnapshot();
|
|
1478
|
+
this.#applySnapshot(scrollSnapshot, this.#enabled);
|
|
1479
|
+
}
|
|
1480
|
+
this.#syncSubscription();
|
|
1481
|
+
} catch (error) {
|
|
1482
|
+
this.#transitionToRuntimeError(error);
|
|
1483
|
+
return;
|
|
1484
|
+
}
|
|
1485
|
+
if (breakpointChanged) {
|
|
1486
|
+
const state = this.getState();
|
|
1487
|
+
this.#events.emit("breakpointchange", {
|
|
1488
|
+
previous: Object.freeze([...previousBreakpoints]),
|
|
1489
|
+
current: Object.freeze([...this.#activeBreakpoints]),
|
|
1490
|
+
state
|
|
1491
|
+
});
|
|
1492
|
+
this.#emitUpdate("breakpoint");
|
|
1493
|
+
}
|
|
1494
|
+
if (preferenceChanged) this.#emitUpdate("preference");
|
|
1495
|
+
}
|
|
1496
|
+
#handleResize() {
|
|
1497
|
+
if (this.#status !== "ready" && this.#status !== "disabled" || this.#scheduler === null || this.#hidden) return;
|
|
1498
|
+
try {
|
|
1499
|
+
for (const binding of this.#bindings.values()) binding.renderer?.resize();
|
|
1500
|
+
this.#applySnapshot(this.#scheduler.refresh(), this.#enabled);
|
|
1501
|
+
this.#emitUpdate("resize");
|
|
1502
|
+
} catch (error) {
|
|
1503
|
+
this.#transitionToRuntimeError(error);
|
|
1504
|
+
}
|
|
1505
|
+
}
|
|
1506
|
+
#handleVisibilityChange(hidden) {
|
|
1507
|
+
if (this.#status !== "ready" && this.#status !== "disabled") return;
|
|
1508
|
+
this.#hidden = hidden;
|
|
1509
|
+
try {
|
|
1510
|
+
if (hidden) this.#unsubscribeFromSource();
|
|
1511
|
+
this.#applyRendererActivity();
|
|
1512
|
+
if (!hidden && this.#scheduler !== null) {
|
|
1513
|
+
this.#applySnapshot(this.#scheduler.refresh(), this.#enabled);
|
|
1514
|
+
this.#subscribe();
|
|
1515
|
+
}
|
|
1516
|
+
this.#emitUpdate("visibility");
|
|
1517
|
+
} catch (error) {
|
|
1518
|
+
this.#transitionToRuntimeError(error);
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
#getRendererActivity() {
|
|
1522
|
+
if (this.#prefersReducedMotion && this.#program.reducedMotion === "disable") return "disabled";
|
|
1523
|
+
return this.#hidden ? "suspended" : "active";
|
|
1524
|
+
}
|
|
1525
|
+
#applyRendererActivity() {
|
|
1526
|
+
const activity = this.#getRendererActivity();
|
|
1527
|
+
for (const binding of this.#bindings.values()) binding.renderer?.setActivity(activity);
|
|
1528
|
+
}
|
|
1529
|
+
#destroyEnvironment() {
|
|
1530
|
+
this.#environment?.destroy();
|
|
1531
|
+
this.#environment = null;
|
|
1532
|
+
this.#hidden = false;
|
|
1533
|
+
}
|
|
1534
|
+
#selectBindings(bindingId) {
|
|
1535
|
+
if (bindingId === void 0) return [...this.#bindings.values()];
|
|
1536
|
+
return [this.#getBinding(bindingId)];
|
|
1537
|
+
}
|
|
1538
|
+
#getBinding(bindingId) {
|
|
1539
|
+
const binding = this.#bindings.get(bindingId);
|
|
1540
|
+
if (binding === void 0) throw new FrameByFrameError("INVALID_CONTROLLER", `Binding "${bindingId}" is not configured.`, { details: { bindingId } });
|
|
1541
|
+
return binding;
|
|
1542
|
+
}
|
|
1543
|
+
#getMountedRenderer(binding) {
|
|
1544
|
+
const renderer = binding.renderer;
|
|
1545
|
+
if (renderer === null) throw new FrameByFrameError("INVALID_LIFECYCLE_OPERATION", "Media operations require a successfully mounted controller.", { details: {
|
|
1546
|
+
bindingId: binding.config.id,
|
|
1547
|
+
status: this.#status
|
|
1548
|
+
} });
|
|
1549
|
+
return renderer;
|
|
1550
|
+
}
|
|
1551
|
+
#assertMediaLifecycle(operation) {
|
|
1552
|
+
this.#assertNotDestroyed();
|
|
1553
|
+
if (this.#status !== "ready" && this.#status !== "disabled") throw new FrameByFrameError("INVALID_LIFECYCLE_OPERATION", `${operation} requires a successfully mounted controller.`, { details: { status: this.#status } });
|
|
1554
|
+
}
|
|
1555
|
+
#subscribe() {
|
|
1556
|
+
if (this.#unsubscribe !== null || !this.#enabled || this.#hidden || this.#scheduler === null || !Object.values(this.#axes).some((axis) => axis.enabled)) return;
|
|
1557
|
+
this.#unsubscribe = this.#scheduler.subscribe(this.#handleScroll);
|
|
1558
|
+
}
|
|
1559
|
+
#syncSubscription() {
|
|
1560
|
+
if (!this.#enabled || this.#hidden || !Object.values(this.#axes).some((axis) => axis.enabled)) {
|
|
1561
|
+
this.#unsubscribeFromSource();
|
|
1562
|
+
return;
|
|
1563
|
+
}
|
|
1564
|
+
this.#subscribe();
|
|
1565
|
+
}
|
|
1566
|
+
#unsubscribeFromSource() {
|
|
1567
|
+
this.#unsubscribe?.();
|
|
1568
|
+
this.#unsubscribe = null;
|
|
1569
|
+
}
|
|
1570
|
+
#transitionToRuntimeError(error) {
|
|
1571
|
+
const packageError = asPackageError(error, "INVALID_CONTROLLER", "The controller failed while resolving a scroll update.");
|
|
1572
|
+
this.#unsubscribeFromSource();
|
|
1573
|
+
this.#destroyEnvironment();
|
|
1574
|
+
this.#destroyRenderers();
|
|
1575
|
+
this.#scheduler = null;
|
|
1576
|
+
this.#source = null;
|
|
1577
|
+
this.#status = "error";
|
|
1578
|
+
this.#lastError = packageError;
|
|
1579
|
+
this.#events.emit("error", cloneError(packageError));
|
|
1580
|
+
return packageError;
|
|
1581
|
+
}
|
|
1582
|
+
#emitUpdate(reason) {
|
|
1583
|
+
this.#events.emit("update", {
|
|
1584
|
+
reason,
|
|
1585
|
+
state: this.getState()
|
|
1586
|
+
});
|
|
1587
|
+
}
|
|
1588
|
+
#assertNotDestroyed() {
|
|
1589
|
+
if (this.#status === "destroyed") throw new FrameByFrameError("CONTROLLER_DESTROYED", "The controller has already been destroyed.");
|
|
1590
|
+
}
|
|
1591
|
+
};
|
|
1592
|
+
/** Internal factory with injectable browser dependencies for deterministic tests. */
|
|
1593
|
+
const createController = (options, dependencies) => new Controller(options, dependencies);
|
|
1594
|
+
|
|
1595
|
+
//#endregion
|
|
1596
|
+
//#region src/media/asset-cache.ts
|
|
1597
|
+
const initialProgress = () => Object.freeze({
|
|
1598
|
+
loadedBytes: 0,
|
|
1599
|
+
totalBytes: null,
|
|
1600
|
+
ratio: null
|
|
1601
|
+
});
|
|
1602
|
+
const readTotalBytes = (response) => {
|
|
1603
|
+
const header = response.headers.get("content-length");
|
|
1604
|
+
if (header === null) return null;
|
|
1605
|
+
const value = Number(header);
|
|
1606
|
+
return Number.isFinite(value) && value >= 0 ? value : null;
|
|
1607
|
+
};
|
|
1608
|
+
const calculateRatio = (loadedBytes, totalBytes) => totalBytes !== null && totalBytes > 0 ? Math.min(1, loadedBytes / totalBytes) : null;
|
|
1609
|
+
const cacheKey = (request) => JSON.stringify([
|
|
1610
|
+
request.url,
|
|
1611
|
+
request.credentials,
|
|
1612
|
+
request.cache
|
|
1613
|
+
]);
|
|
1614
|
+
/** Internal reference-counted cache used only by explicit full-file preloads. */
|
|
1615
|
+
var AssetCache = class {
|
|
1616
|
+
#dependencies;
|
|
1617
|
+
#entries = /* @__PURE__ */ new Map();
|
|
1618
|
+
constructor(dependencies) {
|
|
1619
|
+
this.#dependencies = dependencies;
|
|
1620
|
+
}
|
|
1621
|
+
acquire(request, onProgress) {
|
|
1622
|
+
const key = cacheKey(request);
|
|
1623
|
+
let entry = this.#entries.get(key);
|
|
1624
|
+
if (entry === void 0) {
|
|
1625
|
+
entry = this.#createEntry(key, request);
|
|
1626
|
+
this.#entries.set(key, entry);
|
|
1627
|
+
}
|
|
1628
|
+
entry.references += 1;
|
|
1629
|
+
entry.listeners.add(onProgress);
|
|
1630
|
+
onProgress(entry.progress);
|
|
1631
|
+
let released = false;
|
|
1632
|
+
return {
|
|
1633
|
+
result: entry.result,
|
|
1634
|
+
release: () => {
|
|
1635
|
+
if (released) return;
|
|
1636
|
+
released = true;
|
|
1637
|
+
entry.listeners.delete(onProgress);
|
|
1638
|
+
this.#release(entry);
|
|
1639
|
+
}
|
|
1640
|
+
};
|
|
1641
|
+
}
|
|
1642
|
+
#createEntry(key, request) {
|
|
1643
|
+
const entry = {
|
|
1644
|
+
key,
|
|
1645
|
+
request,
|
|
1646
|
+
abortController: new AbortController(),
|
|
1647
|
+
listeners: /* @__PURE__ */ new Set(),
|
|
1648
|
+
references: 0,
|
|
1649
|
+
progress: initialProgress(),
|
|
1650
|
+
asset: null,
|
|
1651
|
+
result: Promise.resolve(null)
|
|
1652
|
+
};
|
|
1653
|
+
entry.result = this.#load(entry).catch((error) => {
|
|
1654
|
+
if (this.#entries.get(key) === entry) this.#entries.delete(key);
|
|
1655
|
+
throw error;
|
|
1656
|
+
});
|
|
1657
|
+
return entry;
|
|
1658
|
+
}
|
|
1659
|
+
async #load(entry) {
|
|
1660
|
+
const response = await this.#dependencies.fetch(entry.request.url, {
|
|
1661
|
+
signal: entry.abortController.signal,
|
|
1662
|
+
credentials: entry.request.credentials,
|
|
1663
|
+
cache: entry.request.cache,
|
|
1664
|
+
mode: entry.request.cache === "only-if-cached" ? "same-origin" : "cors"
|
|
1665
|
+
});
|
|
1666
|
+
if (!response.ok) throw new Error(`Full preload request failed with HTTP ${String(response.status)}.`);
|
|
1667
|
+
const totalBytes = readTotalBytes(response);
|
|
1668
|
+
this.#publish(entry, 0, totalBytes);
|
|
1669
|
+
const blob = await this.#readBlob(entry, response, totalBytes);
|
|
1670
|
+
const objectUrl = this.#dependencies.createObjectURL(blob);
|
|
1671
|
+
const asset = Object.freeze({
|
|
1672
|
+
objectUrl,
|
|
1673
|
+
size: blob.size,
|
|
1674
|
+
type: blob.type
|
|
1675
|
+
});
|
|
1676
|
+
entry.asset = asset;
|
|
1677
|
+
this.#publish(entry, blob.size, totalBytes);
|
|
1678
|
+
return asset;
|
|
1679
|
+
}
|
|
1680
|
+
async #readBlob(entry, response, totalBytes) {
|
|
1681
|
+
if (response.body === null) {
|
|
1682
|
+
const blob = await response.blob();
|
|
1683
|
+
this.#publish(entry, blob.size, totalBytes);
|
|
1684
|
+
return blob;
|
|
1685
|
+
}
|
|
1686
|
+
const reader = response.body.getReader();
|
|
1687
|
+
const chunks = [];
|
|
1688
|
+
let loadedBytes = 0;
|
|
1689
|
+
try {
|
|
1690
|
+
for (;;) {
|
|
1691
|
+
const chunk = await reader.read();
|
|
1692
|
+
if (chunk.done) break;
|
|
1693
|
+
const bytes = new Uint8Array(chunk.value);
|
|
1694
|
+
chunks.push(bytes);
|
|
1695
|
+
loadedBytes += bytes.byteLength;
|
|
1696
|
+
this.#publish(entry, loadedBytes, totalBytes);
|
|
1697
|
+
}
|
|
1698
|
+
} finally {
|
|
1699
|
+
reader.releaseLock();
|
|
1700
|
+
}
|
|
1701
|
+
return new Blob(chunks, { type: response.headers.get("content-type") ?? "" });
|
|
1702
|
+
}
|
|
1703
|
+
#publish(entry, loadedBytes, totalBytes) {
|
|
1704
|
+
const progress = Object.freeze({
|
|
1705
|
+
loadedBytes,
|
|
1706
|
+
totalBytes,
|
|
1707
|
+
ratio: calculateRatio(loadedBytes, totalBytes)
|
|
1708
|
+
});
|
|
1709
|
+
entry.progress = progress;
|
|
1710
|
+
for (const listener of entry.listeners) listener(progress);
|
|
1711
|
+
}
|
|
1712
|
+
#release(entry) {
|
|
1713
|
+
entry.references -= 1;
|
|
1714
|
+
if (entry.references > 0) return;
|
|
1715
|
+
if (this.#entries.get(entry.key) === entry) this.#entries.delete(entry.key);
|
|
1716
|
+
if (entry.asset === null) {
|
|
1717
|
+
entry.abortController.abort();
|
|
1718
|
+
return;
|
|
1719
|
+
}
|
|
1720
|
+
this.#dependencies.revokeObjectURL(entry.asset.objectUrl);
|
|
1721
|
+
}
|
|
1722
|
+
};
|
|
1723
|
+
|
|
1724
|
+
//#endregion
|
|
1725
|
+
//#region src/media/video-renderer.ts
|
|
1726
|
+
const defaultDependencies = {
|
|
1727
|
+
assetCache: new AssetCache({
|
|
1728
|
+
fetch: (url, init) => globalThis.fetch(url, init),
|
|
1729
|
+
createObjectURL: (blob) => globalThis.URL.createObjectURL(blob),
|
|
1730
|
+
revokeObjectURL: (url) => {
|
|
1731
|
+
globalThis.URL.revokeObjectURL(url);
|
|
1732
|
+
}
|
|
1733
|
+
}),
|
|
1734
|
+
resolveUrl: (source, target) => {
|
|
1735
|
+
return new URL(source, target.ownerDocument.baseURI).href;
|
|
1736
|
+
},
|
|
1737
|
+
observeNearViewport: (target, rootMargin, onEnter) => {
|
|
1738
|
+
if (typeof globalThis.IntersectionObserver !== "function") throw new FrameByFrameError("ENVIRONMENT_UNAVAILABLE", "IntersectionObserver is required by target-near-viewport loading.");
|
|
1739
|
+
const observer = new globalThis.IntersectionObserver((entries) => {
|
|
1740
|
+
if (entries.some((entry) => entry.isIntersecting)) onEnter();
|
|
1741
|
+
}, {
|
|
1742
|
+
root: null,
|
|
1743
|
+
rootMargin
|
|
1744
|
+
});
|
|
1745
|
+
observer.observe(target);
|
|
1746
|
+
return () => {
|
|
1747
|
+
observer.disconnect();
|
|
1748
|
+
};
|
|
1749
|
+
}
|
|
1750
|
+
};
|
|
1751
|
+
const SNAPSHOT_ATTRIBUTES = [
|
|
1752
|
+
"src",
|
|
1753
|
+
"preload",
|
|
1754
|
+
"poster",
|
|
1755
|
+
"crossorigin",
|
|
1756
|
+
"muted",
|
|
1757
|
+
"playsinline",
|
|
1758
|
+
"controls",
|
|
1759
|
+
"loop",
|
|
1760
|
+
"autoplay"
|
|
1761
|
+
];
|
|
1762
|
+
const finiteOrNull = (value) => Number.isFinite(value) ? value : null;
|
|
1763
|
+
const readDuration = (target) => {
|
|
1764
|
+
const duration = finiteOrNull(target.duration);
|
|
1765
|
+
return duration === null ? null : Math.max(0, duration);
|
|
1766
|
+
};
|
|
1767
|
+
const snapshotTarget = (target) => {
|
|
1768
|
+
const attributes = {};
|
|
1769
|
+
for (const attribute of SNAPSHOT_ATTRIBUTES) attributes[attribute] = target.getAttribute(attribute);
|
|
1770
|
+
return {
|
|
1771
|
+
attributes: Object.freeze(attributes),
|
|
1772
|
+
srcObject: target.srcObject,
|
|
1773
|
+
muted: target.muted,
|
|
1774
|
+
defaultMuted: target.defaultMuted,
|
|
1775
|
+
playsInline: target.playsInline,
|
|
1776
|
+
controls: target.controls,
|
|
1777
|
+
loop: target.loop,
|
|
1778
|
+
autoplay: target.autoplay
|
|
1779
|
+
};
|
|
1780
|
+
};
|
|
1781
|
+
const restoreTarget = (target, snapshot) => {
|
|
1782
|
+
for (const attribute of SNAPSHOT_ATTRIBUTES) {
|
|
1783
|
+
const value = snapshot.attributes[attribute];
|
|
1784
|
+
if (value === null || value === void 0) target.removeAttribute(attribute);
|
|
1785
|
+
else target.setAttribute(attribute, value);
|
|
1786
|
+
}
|
|
1787
|
+
target.srcObject = snapshot.srcObject;
|
|
1788
|
+
target.muted = snapshot.muted;
|
|
1789
|
+
target.defaultMuted = snapshot.defaultMuted;
|
|
1790
|
+
target.playsInline = snapshot.playsInline;
|
|
1791
|
+
target.controls = snapshot.controls;
|
|
1792
|
+
target.loop = snapshot.loop;
|
|
1793
|
+
target.autoplay = snapshot.autoplay;
|
|
1794
|
+
try {
|
|
1795
|
+
target.load();
|
|
1796
|
+
} catch {}
|
|
1797
|
+
};
|
|
1798
|
+
const restoreTargetConfiguration = (target, snapshot) => {
|
|
1799
|
+
for (const attribute of SNAPSHOT_ATTRIBUTES) {
|
|
1800
|
+
if (attribute === "src") continue;
|
|
1801
|
+
const value = snapshot.attributes[attribute];
|
|
1802
|
+
if (value === null || value === void 0) target.removeAttribute(attribute);
|
|
1803
|
+
else target.setAttribute(attribute, value);
|
|
1804
|
+
}
|
|
1805
|
+
target.muted = snapshot.muted;
|
|
1806
|
+
target.defaultMuted = snapshot.defaultMuted;
|
|
1807
|
+
target.playsInline = snapshot.playsInline;
|
|
1808
|
+
target.controls = snapshot.controls;
|
|
1809
|
+
target.loop = snapshot.loop;
|
|
1810
|
+
target.autoplay = snapshot.autoplay;
|
|
1811
|
+
};
|
|
1812
|
+
const configureTarget = (target, config, owned) => {
|
|
1813
|
+
target.pause();
|
|
1814
|
+
target.autoplay = false;
|
|
1815
|
+
if (owned) {
|
|
1816
|
+
const muted = config.video.muted ?? true;
|
|
1817
|
+
target.muted = muted;
|
|
1818
|
+
target.defaultMuted = muted;
|
|
1819
|
+
target.playsInline = config.video.playsInline ?? true;
|
|
1820
|
+
target.controls = config.video.controls ?? false;
|
|
1821
|
+
target.loop = config.video.loop ?? false;
|
|
1822
|
+
return;
|
|
1823
|
+
}
|
|
1824
|
+
if (config.video.muted !== void 0) {
|
|
1825
|
+
target.muted = config.video.muted;
|
|
1826
|
+
target.defaultMuted = config.video.muted;
|
|
1827
|
+
}
|
|
1828
|
+
if (config.video.playsInline !== void 0) target.playsInline = config.video.playsInline;
|
|
1829
|
+
if (config.video.controls !== void 0) target.controls = config.video.controls;
|
|
1830
|
+
if (config.video.loop !== void 0) target.loop = config.video.loop;
|
|
1831
|
+
};
|
|
1832
|
+
const createError = (code, message, bindingId, clipId, source, cause) => new FrameByFrameError(code, message, {
|
|
1833
|
+
cause,
|
|
1834
|
+
details: {
|
|
1835
|
+
bindingId,
|
|
1836
|
+
clipId,
|
|
1837
|
+
source
|
|
1838
|
+
}
|
|
1839
|
+
});
|
|
1840
|
+
var NativeVideoRenderer = class {
|
|
1841
|
+
#config;
|
|
1842
|
+
#handle;
|
|
1843
|
+
#target;
|
|
1844
|
+
#onEvent;
|
|
1845
|
+
#snapshot;
|
|
1846
|
+
#waiters = /* @__PURE__ */ new Set();
|
|
1847
|
+
#readinessWaiters = /* @__PURE__ */ new Set();
|
|
1848
|
+
#dependencies;
|
|
1849
|
+
#viewportTarget;
|
|
1850
|
+
#fullAssets = /* @__PURE__ */ new Map();
|
|
1851
|
+
#loadProgress = /* @__PURE__ */ new Map();
|
|
1852
|
+
#loadState = "idle";
|
|
1853
|
+
#activeClipId = null;
|
|
1854
|
+
#selectedSource = null;
|
|
1855
|
+
#duration = null;
|
|
1856
|
+
#appliedTime = null;
|
|
1857
|
+
#presentedTime = null;
|
|
1858
|
+
#seeking = false;
|
|
1859
|
+
#error = null;
|
|
1860
|
+
#desired = null;
|
|
1861
|
+
#autoLoad;
|
|
1862
|
+
#destroyed = false;
|
|
1863
|
+
#generation = 0;
|
|
1864
|
+
#candidateSources = [];
|
|
1865
|
+
#candidateIndex = -1;
|
|
1866
|
+
#removeSourceListeners = null;
|
|
1867
|
+
#seekInFlight = false;
|
|
1868
|
+
#pendingSeek = null;
|
|
1869
|
+
#frameHandle = null;
|
|
1870
|
+
#frameToken = 0;
|
|
1871
|
+
#readinessGeneration = 0;
|
|
1872
|
+
#stopObserving = null;
|
|
1873
|
+
#activity = "active";
|
|
1874
|
+
#manualUnload = false;
|
|
1875
|
+
constructor(config, handle, onEvent, dependencies, activity, viewportTarget) {
|
|
1876
|
+
this.#config = config;
|
|
1877
|
+
this.#handle = handle;
|
|
1878
|
+
this.#target = handle.target;
|
|
1879
|
+
this.#onEvent = onEvent;
|
|
1880
|
+
this.#dependencies = dependencies;
|
|
1881
|
+
this.#viewportTarget = viewportTarget;
|
|
1882
|
+
this.#activity = activity;
|
|
1883
|
+
this.#snapshot = handle.owned ? null : snapshotTarget(this.#target);
|
|
1884
|
+
configureTarget(this.#target, config, handle.owned);
|
|
1885
|
+
this.#autoLoad = activity === "active" && config.loading.mode === "immediate";
|
|
1886
|
+
if (this.#autoLoad) this.#prepareAllFullClips();
|
|
1887
|
+
else if (activity === "active" && config.loading.trigger === "target-near-viewport") try {
|
|
1888
|
+
this.#stopObserving = dependencies.observeNearViewport(this.#viewportTarget, config.loading.rootMargin ?? "0px", () => {
|
|
1889
|
+
this.#activate(true, true);
|
|
1890
|
+
});
|
|
1891
|
+
} catch (cause) {
|
|
1892
|
+
throw cause instanceof FrameByFrameError ? cause : new FrameByFrameError("ENVIRONMENT_UNAVAILABLE", "The target-near-viewport observer could not be created.", {
|
|
1893
|
+
cause,
|
|
1894
|
+
details: { bindingId: config.id }
|
|
1895
|
+
});
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
prepareConfig(config) {
|
|
1899
|
+
this.#assertNotDestroyed();
|
|
1900
|
+
if (config.id !== this.#config.id || config.axis !== this.#config.axis || config.target !== this.#config.target || config.mountTo !== this.#config.mountTo) throw new FrameByFrameError("INVALID_BREAKPOINT_CONFIG", "Responsive overrides cannot change binding identity, axis, or target ownership.", { details: { bindingId: this.#config.id } });
|
|
1901
|
+
let committed = false;
|
|
1902
|
+
let enteredBeforeCommit = false;
|
|
1903
|
+
let candidateObserver = null;
|
|
1904
|
+
if (this.#activity === "active" && config.loading.mode === "on-demand" && config.loading.trigger === "target-near-viewport") candidateObserver = this.#observeNearViewport(config, () => {
|
|
1905
|
+
if (committed) this.#activate(true, true);
|
|
1906
|
+
else enteredBeforeCommit = true;
|
|
1907
|
+
});
|
|
1908
|
+
let settled = false;
|
|
1909
|
+
return {
|
|
1910
|
+
commit: () => {
|
|
1911
|
+
if (settled) return;
|
|
1912
|
+
settled = true;
|
|
1913
|
+
this.#stopObserving?.();
|
|
1914
|
+
this.#stopObserving = null;
|
|
1915
|
+
this.#rejectWaiters(createError("MEDIA_LOAD_FAILED", "Media loading was superseded by a responsive configuration change.", this.#config.id, this.#activeClipId, this.#selectedSource));
|
|
1916
|
+
this.#supersedeReadiness();
|
|
1917
|
+
this.#releaseFullAssets();
|
|
1918
|
+
this.#desired = null;
|
|
1919
|
+
this.#resetSource("idle");
|
|
1920
|
+
if (this.#snapshot !== null) restoreTargetConfiguration(this.#target, this.#snapshot);
|
|
1921
|
+
this.#config = config;
|
|
1922
|
+
configureTarget(this.#target, config, this.#handle.owned);
|
|
1923
|
+
this.#manualUnload = false;
|
|
1924
|
+
this.#autoLoad = this.#activity === "active" && config.loading.mode === "immediate";
|
|
1925
|
+
committed = true;
|
|
1926
|
+
if (this.#activity === "active") if (this.#autoLoad) {
|
|
1927
|
+
candidateObserver?.();
|
|
1928
|
+
candidateObserver = null;
|
|
1929
|
+
this.#prepareAllFullClips();
|
|
1930
|
+
} else {
|
|
1931
|
+
this.#stopObserving = candidateObserver;
|
|
1932
|
+
candidateObserver = null;
|
|
1933
|
+
if (enteredBeforeCommit) this.#activate(true, true);
|
|
1934
|
+
}
|
|
1935
|
+
else {
|
|
1936
|
+
candidateObserver?.();
|
|
1937
|
+
candidateObserver = null;
|
|
1938
|
+
}
|
|
1939
|
+
},
|
|
1940
|
+
cancel: () => {
|
|
1941
|
+
if (settled) return;
|
|
1942
|
+
settled = true;
|
|
1943
|
+
candidateObserver?.();
|
|
1944
|
+
candidateObserver = null;
|
|
1945
|
+
}
|
|
1946
|
+
};
|
|
1947
|
+
}
|
|
1948
|
+
setActivity(activity) {
|
|
1949
|
+
this.#assertNotDestroyed();
|
|
1950
|
+
if (activity === this.#activity) return;
|
|
1951
|
+
this.#activity = activity;
|
|
1952
|
+
if (activity === "suspended") {
|
|
1953
|
+
this.#cancelFrameObservation();
|
|
1954
|
+
return;
|
|
1955
|
+
}
|
|
1956
|
+
if (activity === "disabled") {
|
|
1957
|
+
this.#autoLoad = false;
|
|
1958
|
+
this.#stopObserving?.();
|
|
1959
|
+
this.#stopObserving = null;
|
|
1960
|
+
this.#rejectWaiters(createError("MEDIA_LOAD_FAILED", "Media loading was cancelled by the active reduced-motion behavior.", this.#config.id, this.#activeClipId, this.#selectedSource));
|
|
1961
|
+
this.#supersedeReadiness();
|
|
1962
|
+
this.#releaseFullAssets();
|
|
1963
|
+
this.#desired = null;
|
|
1964
|
+
this.#resetSource("unloaded");
|
|
1965
|
+
return;
|
|
1966
|
+
}
|
|
1967
|
+
if (this.#manualUnload) return;
|
|
1968
|
+
this.#startConfiguredLoadingPolicy();
|
|
1969
|
+
}
|
|
1970
|
+
setResolution(resolution) {
|
|
1971
|
+
if (this.#destroyed || resolution === null || this.#activity === "disabled") return;
|
|
1972
|
+
const clipId = resolution.clipId ?? this.#config.clips[0]?.id;
|
|
1973
|
+
if (clipId === void 0) return;
|
|
1974
|
+
const desired = {
|
|
1975
|
+
clipId,
|
|
1976
|
+
requestedTime: resolution.requestedTime,
|
|
1977
|
+
targetTime: resolution.targetTime
|
|
1978
|
+
};
|
|
1979
|
+
const clipChanged = this.#desired?.clipId !== clipId;
|
|
1980
|
+
this.#desired = desired;
|
|
1981
|
+
if (this.#activity === "suspended") return;
|
|
1982
|
+
if (!this.#autoLoad && this.#config.loading.mode === "on-demand" && this.#config.loading.trigger === "first-use" && this.#loadState !== "unloaded") this.#activate(false, false);
|
|
1983
|
+
if (clipChanged) this.#supersedeReadiness();
|
|
1984
|
+
if (!this.#autoLoad || this.#loadState === "unloaded") return;
|
|
1985
|
+
if (clipChanged || this.#activeClipId !== clipId || this.#loadState === "idle") {
|
|
1986
|
+
this.#beginDesiredClip();
|
|
1987
|
+
return;
|
|
1988
|
+
}
|
|
1989
|
+
if (this.#loadState === "metadata" || this.#loadState === "ready") this.#requestSeek(desired);
|
|
1990
|
+
}
|
|
1991
|
+
load() {
|
|
1992
|
+
this.#assertNotDestroyed();
|
|
1993
|
+
this.#manualUnload = false;
|
|
1994
|
+
if (this.#activity === "disabled") return Promise.resolve();
|
|
1995
|
+
if (this.#error?.code === "FULL_PRELOAD_FAILED" && this.#desired !== null) this.#discardFullAsset(this.#desired.clipId);
|
|
1996
|
+
this.#activate(true);
|
|
1997
|
+
if (this.#desired === null) {
|
|
1998
|
+
const soleClip = this.#config.clips[0];
|
|
1999
|
+
if (this.#config.clips.length !== 1 || soleClip === void 0) return Promise.reject(createError("MEDIA_LOAD_FAILED", "The binding has no currently resolved clip to load.", this.#config.id, null, null));
|
|
2000
|
+
this.#desired = {
|
|
2001
|
+
clipId: soleClip.id,
|
|
2002
|
+
requestedTime: 0,
|
|
2003
|
+
targetTime: 0
|
|
2004
|
+
};
|
|
2005
|
+
}
|
|
2006
|
+
if (this.#activeClipId === this.#desired.clipId && (this.#loadState === "metadata" || this.#loadState === "ready")) return Promise.resolve();
|
|
2007
|
+
const promise = new Promise((resolve, reject) => {
|
|
2008
|
+
this.#waiters.add({
|
|
2009
|
+
resolve,
|
|
2010
|
+
reject
|
|
2011
|
+
});
|
|
2012
|
+
});
|
|
2013
|
+
if (this.#activeClipId !== this.#desired.clipId || this.#loadState === "idle" || this.#loadState === "unloaded" || this.#loadState === "error") this.#beginDesiredClip();
|
|
2014
|
+
return promise;
|
|
2015
|
+
}
|
|
2016
|
+
async whenReady() {
|
|
2017
|
+
this.#assertNotDestroyed();
|
|
2018
|
+
for (;;) {
|
|
2019
|
+
const generation = this.#readinessGeneration;
|
|
2020
|
+
const tasks = this.#readinessTasks();
|
|
2021
|
+
await Promise.all(tasks);
|
|
2022
|
+
if (this.#destroyed) throw new FrameByFrameError("CONTROLLER_DESTROYED", "The controller was destroyed while media readiness was pending.", { details: { bindingId: this.#config.id } });
|
|
2023
|
+
if (generation === this.#readinessGeneration) return;
|
|
2024
|
+
}
|
|
2025
|
+
}
|
|
2026
|
+
unload() {
|
|
2027
|
+
this.#assertNotDestroyed();
|
|
2028
|
+
this.#manualUnload = true;
|
|
2029
|
+
this.#autoLoad = false;
|
|
2030
|
+
this.#stopObserving?.();
|
|
2031
|
+
this.#stopObserving = null;
|
|
2032
|
+
this.#rejectWaiters(createError("MEDIA_LOAD_FAILED", "Media loading was cancelled by unload().", this.#config.id, this.#activeClipId, this.#selectedSource));
|
|
2033
|
+
this.#supersedeReadiness();
|
|
2034
|
+
this.#releaseFullAssets();
|
|
2035
|
+
this.#resetSource("unloaded");
|
|
2036
|
+
}
|
|
2037
|
+
resize() {
|
|
2038
|
+
this.#assertNotDestroyed();
|
|
2039
|
+
}
|
|
2040
|
+
getTarget() {
|
|
2041
|
+
this.#assertNotDestroyed();
|
|
2042
|
+
return this.#target;
|
|
2043
|
+
}
|
|
2044
|
+
getState() {
|
|
2045
|
+
return {
|
|
2046
|
+
loadState: this.#loadState,
|
|
2047
|
+
loadProgress: Object.freeze(Object.fromEntries(this.#loadProgress)),
|
|
2048
|
+
activeClipId: this.#activeClipId,
|
|
2049
|
+
selectedSource: this.#selectedSource,
|
|
2050
|
+
duration: this.#duration,
|
|
2051
|
+
appliedTime: this.#appliedTime,
|
|
2052
|
+
presentedTime: this.#presentedTime,
|
|
2053
|
+
seeking: this.#seeking,
|
|
2054
|
+
error: this.#error
|
|
2055
|
+
};
|
|
2056
|
+
}
|
|
2057
|
+
destroy() {
|
|
2058
|
+
if (this.#destroyed) return;
|
|
2059
|
+
this.#destroyed = true;
|
|
2060
|
+
this.#autoLoad = false;
|
|
2061
|
+
this.#stopObserving?.();
|
|
2062
|
+
this.#stopObserving = null;
|
|
2063
|
+
this.#rejectWaiters(new FrameByFrameError("CONTROLLER_DESTROYED", "The controller was destroyed while media was loading.", { details: { bindingId: this.#config.id } }));
|
|
2064
|
+
this.#rejectReadinessWaiters(new FrameByFrameError("CONTROLLER_DESTROYED", "The controller was destroyed while media readiness was pending.", { details: { bindingId: this.#config.id } }));
|
|
2065
|
+
this.#releaseFullAssets();
|
|
2066
|
+
this.#resetSource("unloaded");
|
|
2067
|
+
try {
|
|
2068
|
+
if (this.#snapshot !== null) restoreTarget(this.#target, this.#snapshot);
|
|
2069
|
+
} finally {
|
|
2070
|
+
this.#handle.release();
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
#activate(prepareAllFullClips, beginDesired = true) {
|
|
2074
|
+
if (this.#activity !== "active") return;
|
|
2075
|
+
const changed = !this.#autoLoad;
|
|
2076
|
+
this.#autoLoad = true;
|
|
2077
|
+
this.#stopObserving?.();
|
|
2078
|
+
this.#stopObserving = null;
|
|
2079
|
+
if (changed) this.#supersedeReadiness();
|
|
2080
|
+
if (prepareAllFullClips) this.#prepareAllFullClips();
|
|
2081
|
+
if (beginDesired && this.#desired !== null && (changed || this.#activeClipId !== this.#desired.clipId || this.#loadState === "idle" || this.#loadState === "unloaded" || this.#loadState === "error")) this.#beginDesiredClip();
|
|
2082
|
+
}
|
|
2083
|
+
#observeNearViewport(config, onEnter) {
|
|
2084
|
+
try {
|
|
2085
|
+
return this.#dependencies.observeNearViewport(this.#viewportTarget, config.loading.rootMargin ?? "0px", onEnter);
|
|
2086
|
+
} catch (cause) {
|
|
2087
|
+
throw cause instanceof FrameByFrameError ? cause : new FrameByFrameError("ENVIRONMENT_UNAVAILABLE", "The target-near-viewport observer could not be created.", {
|
|
2088
|
+
cause,
|
|
2089
|
+
details: { bindingId: config.id }
|
|
2090
|
+
});
|
|
2091
|
+
}
|
|
2092
|
+
}
|
|
2093
|
+
#startConfiguredLoadingPolicy() {
|
|
2094
|
+
this.#stopObserving?.();
|
|
2095
|
+
this.#stopObserving = null;
|
|
2096
|
+
if (this.#loadState === "unloaded") this.#loadState = "idle";
|
|
2097
|
+
this.#autoLoad = this.#config.loading.mode === "immediate";
|
|
2098
|
+
if (this.#autoLoad) {
|
|
2099
|
+
this.#prepareAllFullClips();
|
|
2100
|
+
if (this.#desired !== null) if (this.#activeClipId === this.#desired.clipId && (this.#loadState === "metadata" || this.#loadState === "ready")) this.#requestSeek(this.#desired);
|
|
2101
|
+
else this.#beginDesiredClip();
|
|
2102
|
+
return;
|
|
2103
|
+
}
|
|
2104
|
+
if (this.#config.loading.trigger === "target-near-viewport") this.#stopObserving = this.#observeNearViewport(this.#config, () => {
|
|
2105
|
+
this.#activate(true, true);
|
|
2106
|
+
});
|
|
2107
|
+
if (this.#config.loading.trigger === "first-use" && this.#desired !== null) this.#activate(false, true);
|
|
2108
|
+
}
|
|
2109
|
+
#prepareAllFullClips() {
|
|
2110
|
+
for (const clip of this.#config.clips) if (clip.preload === "full") this.#ensureFullAsset(clip, 0).result.catch(() => void 0);
|
|
2111
|
+
}
|
|
2112
|
+
#ensureFullAsset(clip, startIndex) {
|
|
2113
|
+
const current = this.#fullAssets.get(clip.id);
|
|
2114
|
+
if (current !== void 0) return current;
|
|
2115
|
+
const candidates = this.#playableSources(clip);
|
|
2116
|
+
const preparation = {
|
|
2117
|
+
clipId: clip.id,
|
|
2118
|
+
consumer: null,
|
|
2119
|
+
result: Promise.resolve(null)
|
|
2120
|
+
};
|
|
2121
|
+
preparation.result = this.#loadFullAssetCandidates(preparation, clip, candidates, startIndex).catch((cause) => {
|
|
2122
|
+
const error = cause instanceof FrameByFrameError ? cause : createError("FULL_PRELOAD_FAILED", `Every full-preload source failed for video clip "${clip.id}".`, this.#config.id, clip.id, candidates.at(-1)?.src ?? null, cause);
|
|
2123
|
+
if (this.#fullAssets.get(clip.id) === preparation && !this.#destroyed) {
|
|
2124
|
+
this.#error = error;
|
|
2125
|
+
this.#emit({
|
|
2126
|
+
type: "error",
|
|
2127
|
+
error
|
|
2128
|
+
});
|
|
2129
|
+
}
|
|
2130
|
+
throw error;
|
|
2131
|
+
});
|
|
2132
|
+
this.#fullAssets.set(clip.id, preparation);
|
|
2133
|
+
return preparation;
|
|
2134
|
+
}
|
|
2135
|
+
async #loadFullAssetCandidates(preparation, clip, candidates, startIndex) {
|
|
2136
|
+
let lastCause;
|
|
2137
|
+
for (let sourceIndex = startIndex; sourceIndex < candidates.length; sourceIndex += 1) {
|
|
2138
|
+
const source = candidates[sourceIndex];
|
|
2139
|
+
if (source === void 0) continue;
|
|
2140
|
+
try {
|
|
2141
|
+
const url = this.#dependencies.resolveUrl(source.src, this.#target);
|
|
2142
|
+
const consumer = this.#dependencies.assetCache.acquire({
|
|
2143
|
+
url,
|
|
2144
|
+
credentials: this.#config.loading.credentials,
|
|
2145
|
+
cache: this.#config.loading.cache
|
|
2146
|
+
}, (progress) => {
|
|
2147
|
+
if (this.#fullAssets.get(clip.id) === preparation) this.#publishProgress(clip.id, progress);
|
|
2148
|
+
});
|
|
2149
|
+
preparation.consumer = consumer;
|
|
2150
|
+
try {
|
|
2151
|
+
const asset = await consumer.result;
|
|
2152
|
+
return Object.freeze({
|
|
2153
|
+
asset,
|
|
2154
|
+
source,
|
|
2155
|
+
sourceIndex
|
|
2156
|
+
});
|
|
2157
|
+
} catch (cause) {
|
|
2158
|
+
lastCause = cause;
|
|
2159
|
+
consumer.release();
|
|
2160
|
+
preparation.consumer = null;
|
|
2161
|
+
}
|
|
2162
|
+
} catch (cause) {
|
|
2163
|
+
lastCause = cause;
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
throw createError("FULL_PRELOAD_FAILED", `Every full-preload source failed for video clip "${clip.id}".`, this.#config.id, clip.id, candidates.at(-1)?.src ?? null, lastCause);
|
|
2167
|
+
}
|
|
2168
|
+
#publishProgress(clipId, progress) {
|
|
2169
|
+
const snapshot = Object.freeze({ ...progress });
|
|
2170
|
+
this.#loadProgress.set(clipId, snapshot);
|
|
2171
|
+
this.#emit({
|
|
2172
|
+
type: "loadprogress",
|
|
2173
|
+
clipId,
|
|
2174
|
+
progress: snapshot
|
|
2175
|
+
});
|
|
2176
|
+
}
|
|
2177
|
+
#releaseFullAssets() {
|
|
2178
|
+
for (const preparation of this.#fullAssets.values()) preparation.consumer?.release();
|
|
2179
|
+
this.#fullAssets.clear();
|
|
2180
|
+
this.#loadProgress.clear();
|
|
2181
|
+
}
|
|
2182
|
+
#discardFullAsset(clipId) {
|
|
2183
|
+
this.#fullAssets.get(clipId)?.consumer?.release();
|
|
2184
|
+
this.#fullAssets.delete(clipId);
|
|
2185
|
+
this.#loadProgress.delete(clipId);
|
|
2186
|
+
}
|
|
2187
|
+
#readinessTasks() {
|
|
2188
|
+
if (!this.#autoLoad) return [];
|
|
2189
|
+
const tasks = [...this.#fullAssets.values()].map((preparation) => preparation.result);
|
|
2190
|
+
const desired = this.#desired;
|
|
2191
|
+
if (desired === null) return tasks;
|
|
2192
|
+
const clip = this.#config.clips.find((candidate) => candidate.id === desired.clipId);
|
|
2193
|
+
if (clip === void 0 || clip.preload === "none") return tasks;
|
|
2194
|
+
if (clip.preload === "full") {
|
|
2195
|
+
const preparation = this.#ensureFullAsset(clip, 0);
|
|
2196
|
+
if (!tasks.includes(preparation.result)) tasks.push(preparation.result);
|
|
2197
|
+
}
|
|
2198
|
+
tasks.push(this.#waitForReadiness(clip.id, clip.preload === "metadata" ? "metadata" : "ready"));
|
|
2199
|
+
return tasks;
|
|
2200
|
+
}
|
|
2201
|
+
#waitForReadiness(clipId, state) {
|
|
2202
|
+
if (this.#activeClipId === clipId && this.#hasReadiness(state)) return Promise.resolve();
|
|
2203
|
+
if (this.#activeClipId === clipId && this.#loadState === "error" && this.#error !== null) return Promise.reject(this.#error);
|
|
2204
|
+
return new Promise((resolve, reject) => {
|
|
2205
|
+
this.#readinessWaiters.add({
|
|
2206
|
+
clipId,
|
|
2207
|
+
state,
|
|
2208
|
+
resolve,
|
|
2209
|
+
reject
|
|
2210
|
+
});
|
|
2211
|
+
});
|
|
2212
|
+
}
|
|
2213
|
+
#hasReadiness(state) {
|
|
2214
|
+
return state === "metadata" ? this.#loadState === "metadata" || this.#loadState === "ready" : this.#loadState === "ready";
|
|
2215
|
+
}
|
|
2216
|
+
#resolveReadinessWaiters() {
|
|
2217
|
+
for (const waiter of this.#readinessWaiters) if (waiter.clipId === this.#activeClipId && this.#hasReadiness(waiter.state)) {
|
|
2218
|
+
waiter.resolve();
|
|
2219
|
+
this.#readinessWaiters.delete(waiter);
|
|
2220
|
+
}
|
|
2221
|
+
}
|
|
2222
|
+
#supersedeReadiness() {
|
|
2223
|
+
this.#readinessGeneration += 1;
|
|
2224
|
+
for (const waiter of this.#readinessWaiters) waiter.resolve();
|
|
2225
|
+
this.#readinessWaiters.clear();
|
|
2226
|
+
}
|
|
2227
|
+
#rejectReadinessWaiters(error) {
|
|
2228
|
+
for (const waiter of this.#readinessWaiters) waiter.reject(error);
|
|
2229
|
+
this.#readinessWaiters.clear();
|
|
2230
|
+
}
|
|
2231
|
+
#playableSources(clip) {
|
|
2232
|
+
return clip.sources.filter((source) => {
|
|
2233
|
+
if (source.type === null) return true;
|
|
2234
|
+
try {
|
|
2235
|
+
return this.#target.canPlayType(source.type) !== "";
|
|
2236
|
+
} catch {
|
|
2237
|
+
return false;
|
|
2238
|
+
}
|
|
2239
|
+
});
|
|
2240
|
+
}
|
|
2241
|
+
#beginDesiredClip() {
|
|
2242
|
+
const desired = this.#desired;
|
|
2243
|
+
if (desired === null) return;
|
|
2244
|
+
const clip = this.#config.clips.find((candidate) => candidate.id === desired.clipId);
|
|
2245
|
+
if (clip === void 0) {
|
|
2246
|
+
this.#fail(createError("MEDIA_SOURCE_UNSUPPORTED", `Video clip "${desired.clipId}" is not configured.`, this.#config.id, desired.clipId, null));
|
|
2247
|
+
return;
|
|
2248
|
+
}
|
|
2249
|
+
const candidates = this.#playableSources(clip);
|
|
2250
|
+
if (candidates.length === 0) {
|
|
2251
|
+
this.#fail(createError("MEDIA_SOURCE_UNSUPPORTED", `No playable source is available for video clip "${clip.id}".`, this.#config.id, clip.id, null));
|
|
2252
|
+
return;
|
|
2253
|
+
}
|
|
2254
|
+
this.#candidateSources = candidates;
|
|
2255
|
+
this.#candidateIndex = 0;
|
|
2256
|
+
if (clip.preload === "full") this.#startFullCandidate(clip, 0);
|
|
2257
|
+
else this.#startCandidate(clip, candidates[0]);
|
|
2258
|
+
}
|
|
2259
|
+
#startFullCandidate(clip, startIndex) {
|
|
2260
|
+
const source = this.#candidateSources[startIndex];
|
|
2261
|
+
if (source === void 0 || this.#destroyed) return;
|
|
2262
|
+
const generation = this.#prepareCandidate(clip, source);
|
|
2263
|
+
this.#ensureFullAsset(clip, startIndex).result.then((prepared) => {
|
|
2264
|
+
if (!this.#isCurrentGeneration(generation)) return;
|
|
2265
|
+
this.#candidateIndex = prepared.sourceIndex;
|
|
2266
|
+
this.#selectedSource = prepared.source.src;
|
|
2267
|
+
this.#loadTargetSource(prepared.asset.objectUrl, generation);
|
|
2268
|
+
}, (error) => {
|
|
2269
|
+
const packageError = error instanceof FrameByFrameError ? error : createError("FULL_PRELOAD_FAILED", `Full preload failed for video clip "${clip.id}".`, this.#config.id, clip.id, source.src, error);
|
|
2270
|
+
if (this.#isCurrentGeneration(generation)) this.#fail(packageError, this.#error !== packageError);
|
|
2271
|
+
});
|
|
2272
|
+
}
|
|
2273
|
+
#startCandidate(clip, source) {
|
|
2274
|
+
if (source === void 0 || this.#destroyed) return;
|
|
2275
|
+
const generation = this.#prepareCandidate(clip, source);
|
|
2276
|
+
this.#loadTargetSource(source.src, generation);
|
|
2277
|
+
}
|
|
2278
|
+
#prepareCandidate(clip, source) {
|
|
2279
|
+
const generation = ++this.#generation;
|
|
2280
|
+
this.#cancelFrameObservation();
|
|
2281
|
+
this.#removeSourceListeners?.();
|
|
2282
|
+
this.#removeSourceListeners = null;
|
|
2283
|
+
this.#seekInFlight = false;
|
|
2284
|
+
this.#pendingSeek = null;
|
|
2285
|
+
this.#seeking = false;
|
|
2286
|
+
this.#activeClipId = clip.id;
|
|
2287
|
+
this.#selectedSource = source.src;
|
|
2288
|
+
this.#duration = null;
|
|
2289
|
+
this.#appliedTime = null;
|
|
2290
|
+
this.#presentedTime = null;
|
|
2291
|
+
this.#error = null;
|
|
2292
|
+
this.#loadState = "loading";
|
|
2293
|
+
this.#applyClipAttributes(clip);
|
|
2294
|
+
this.#emit({
|
|
2295
|
+
type: "loadstart",
|
|
2296
|
+
clipId: clip.id
|
|
2297
|
+
});
|
|
2298
|
+
return generation;
|
|
2299
|
+
}
|
|
2300
|
+
#loadTargetSource(targetSource, generation) {
|
|
2301
|
+
this.#attachSourceListeners(generation);
|
|
2302
|
+
try {
|
|
2303
|
+
this.#target.pause();
|
|
2304
|
+
this.#target.srcObject = null;
|
|
2305
|
+
this.#target.setAttribute("src", targetSource);
|
|
2306
|
+
this.#target.load();
|
|
2307
|
+
} catch (cause) {
|
|
2308
|
+
this.#handleSourceFailure(generation, cause);
|
|
2309
|
+
}
|
|
2310
|
+
}
|
|
2311
|
+
#applyClipAttributes(clip) {
|
|
2312
|
+
this.#target.preload = clip.preload === "full" ? "auto" : clip.preload;
|
|
2313
|
+
if (clip.poster === null) this.#target.removeAttribute("poster");
|
|
2314
|
+
else this.#target.poster = clip.poster;
|
|
2315
|
+
if (clip.crossOrigin === null) this.#target.removeAttribute("crossorigin");
|
|
2316
|
+
else this.#target.crossOrigin = clip.crossOrigin;
|
|
2317
|
+
}
|
|
2318
|
+
#attachSourceListeners(generation) {
|
|
2319
|
+
const loadedMetadata = () => {
|
|
2320
|
+
if (this.#isCurrentGeneration(generation)) this.#handleLoadedMetadata();
|
|
2321
|
+
};
|
|
2322
|
+
const loadedData = () => {
|
|
2323
|
+
if (this.#isCurrentGeneration(generation)) this.#handleLoadedData();
|
|
2324
|
+
};
|
|
2325
|
+
const seeked = () => {
|
|
2326
|
+
if (this.#isCurrentGeneration(generation)) this.#handleSeeked();
|
|
2327
|
+
};
|
|
2328
|
+
const error = () => {
|
|
2329
|
+
if (this.#isCurrentGeneration(generation)) this.#handleSourceFailure(generation, this.#target.error);
|
|
2330
|
+
};
|
|
2331
|
+
this.#target.addEventListener("loadedmetadata", loadedMetadata);
|
|
2332
|
+
this.#target.addEventListener("loadeddata", loadedData);
|
|
2333
|
+
this.#target.addEventListener("seeked", seeked);
|
|
2334
|
+
this.#target.addEventListener("error", error);
|
|
2335
|
+
this.#removeSourceListeners = () => {
|
|
2336
|
+
this.#target.removeEventListener("loadedmetadata", loadedMetadata);
|
|
2337
|
+
this.#target.removeEventListener("loadeddata", loadedData);
|
|
2338
|
+
this.#target.removeEventListener("seeked", seeked);
|
|
2339
|
+
this.#target.removeEventListener("error", error);
|
|
2340
|
+
};
|
|
2341
|
+
}
|
|
2342
|
+
#handleLoadedMetadata() {
|
|
2343
|
+
const clipId = this.#activeClipId;
|
|
2344
|
+
if (clipId === null) return;
|
|
2345
|
+
this.#duration = readDuration(this.#target);
|
|
2346
|
+
this.#loadState = "metadata";
|
|
2347
|
+
this.#error = null;
|
|
2348
|
+
this.#emit({
|
|
2349
|
+
type: "loadedmetadata",
|
|
2350
|
+
clipId,
|
|
2351
|
+
duration: this.#duration
|
|
2352
|
+
});
|
|
2353
|
+
this.#resolveWaiters();
|
|
2354
|
+
this.#resolveReadinessWaiters();
|
|
2355
|
+
if (this.#activity === "active" && this.#desired?.clipId === clipId) this.#requestSeek(this.#desired);
|
|
2356
|
+
}
|
|
2357
|
+
#handleLoadedData() {
|
|
2358
|
+
const clipId = this.#activeClipId;
|
|
2359
|
+
if (clipId === null) return;
|
|
2360
|
+
this.#loadState = "ready";
|
|
2361
|
+
this.#emit({
|
|
2362
|
+
type: "loadready",
|
|
2363
|
+
clipId
|
|
2364
|
+
});
|
|
2365
|
+
this.#resolveReadinessWaiters();
|
|
2366
|
+
if (this.#activity !== "active") return;
|
|
2367
|
+
if (this.#supportsVideoFrameCallback()) this.#scheduleFrameObservation();
|
|
2368
|
+
else if (!this.#seekInFlight) this.#presentFrame(this.#target.currentTime, null, null, null);
|
|
2369
|
+
}
|
|
2370
|
+
#handleSeeked() {
|
|
2371
|
+
this.#seekInFlight = false;
|
|
2372
|
+
this.#seeking = false;
|
|
2373
|
+
if (this.#activity === "active" && !this.#supportsVideoFrameCallback()) this.#presentFrame(this.#target.currentTime, null, null, null);
|
|
2374
|
+
const pending = this.#pendingSeek;
|
|
2375
|
+
this.#pendingSeek = null;
|
|
2376
|
+
if (this.#activity === "active" && pending !== null && pending.clipId === this.#activeClipId) this.#requestSeek(pending);
|
|
2377
|
+
}
|
|
2378
|
+
#requestSeek(desired) {
|
|
2379
|
+
if (this.#activity !== "active" || desired.clipId !== this.#activeClipId || this.#loadState === "loading") return;
|
|
2380
|
+
const targetTime = this.#duration === null ? Math.max(0, desired.targetTime) : Math.min(this.#duration, Math.max(0, desired.targetTime));
|
|
2381
|
+
const normalized = {
|
|
2382
|
+
...desired,
|
|
2383
|
+
targetTime
|
|
2384
|
+
};
|
|
2385
|
+
const reference = this.#pendingSeek?.targetTime ?? this.#appliedTime ?? this.#target.currentTime;
|
|
2386
|
+
if (Math.abs(reference - targetTime) <= this.#config.timeEpsilon) return;
|
|
2387
|
+
if (this.#seekInFlight) {
|
|
2388
|
+
this.#pendingSeek = normalized;
|
|
2389
|
+
return;
|
|
2390
|
+
}
|
|
2391
|
+
this.#applySeek(normalized);
|
|
2392
|
+
}
|
|
2393
|
+
#applySeek(desired) {
|
|
2394
|
+
try {
|
|
2395
|
+
this.#target.currentTime = desired.targetTime;
|
|
2396
|
+
this.#appliedTime = desired.targetTime;
|
|
2397
|
+
this.#seekInFlight = true;
|
|
2398
|
+
this.#seeking = true;
|
|
2399
|
+
this.#emit({
|
|
2400
|
+
type: "seekrequest",
|
|
2401
|
+
clipId: desired.clipId,
|
|
2402
|
+
requestedTime: desired.requestedTime,
|
|
2403
|
+
targetTime: desired.targetTime
|
|
2404
|
+
});
|
|
2405
|
+
this.#scheduleFrameObservation();
|
|
2406
|
+
} catch (cause) {
|
|
2407
|
+
this.#fail(createError("MEDIA_SEEK_FAILED", "The native video target rejected a seek request.", this.#config.id, desired.clipId, this.#selectedSource, cause));
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
#scheduleFrameObservation() {
|
|
2411
|
+
if (this.#activity !== "active" || !this.#supportsVideoFrameCallback() || this.#activeClipId === null) return;
|
|
2412
|
+
this.#cancelFrameObservation();
|
|
2413
|
+
const generation = this.#generation;
|
|
2414
|
+
const token = ++this.#frameToken;
|
|
2415
|
+
const request = this.#target.requestVideoFrameCallback;
|
|
2416
|
+
if (request === void 0) return;
|
|
2417
|
+
this.#frameHandle = request.call(this.#target, (_now, metadata) => {
|
|
2418
|
+
if (this.#destroyed || generation !== this.#generation || token !== this.#frameToken || this.#activeClipId === null) return;
|
|
2419
|
+
this.#frameHandle = null;
|
|
2420
|
+
const presentedTime = typeof metadata.mediaTime === "number" && Number.isFinite(metadata.mediaTime) ? metadata.mediaTime : this.#target.currentTime;
|
|
2421
|
+
this.#presentFrame(presentedTime, finiteOrNull(metadata.expectedDisplayTime ?? NaN), finiteOrNull(metadata.width ?? NaN), finiteOrNull(metadata.height ?? NaN));
|
|
2422
|
+
});
|
|
2423
|
+
}
|
|
2424
|
+
#presentFrame(presentedTime, expectedDisplayTime, width, height) {
|
|
2425
|
+
const clipId = this.#activeClipId;
|
|
2426
|
+
if (this.#activity !== "active" || clipId === null || !Number.isFinite(presentedTime)) return;
|
|
2427
|
+
this.#presentedTime = presentedTime;
|
|
2428
|
+
this.#emit({
|
|
2429
|
+
type: "frame",
|
|
2430
|
+
clipId,
|
|
2431
|
+
presentedTime,
|
|
2432
|
+
expectedDisplayTime,
|
|
2433
|
+
width,
|
|
2434
|
+
height
|
|
2435
|
+
});
|
|
2436
|
+
}
|
|
2437
|
+
#handleSourceFailure(generation, cause) {
|
|
2438
|
+
if (!this.#isCurrentGeneration(generation)) return;
|
|
2439
|
+
const nextIndex = this.#candidateIndex + 1;
|
|
2440
|
+
const desiredClip = this.#activeClipId;
|
|
2441
|
+
const clip = this.#config.clips.find((candidate) => candidate.id === desiredClip);
|
|
2442
|
+
if (clip !== void 0 && nextIndex < this.#candidateSources.length) {
|
|
2443
|
+
this.#candidateIndex = nextIndex;
|
|
2444
|
+
if (clip.preload === "full") {
|
|
2445
|
+
this.#discardFullAsset(clip.id);
|
|
2446
|
+
this.#startFullCandidate(clip, nextIndex);
|
|
2447
|
+
} else this.#startCandidate(clip, this.#candidateSources[nextIndex]);
|
|
2448
|
+
return;
|
|
2449
|
+
}
|
|
2450
|
+
const mediaErrorCode = this.#target.error?.code;
|
|
2451
|
+
const code = mediaErrorCode === 3 ? "MEDIA_DECODE_FAILED" : mediaErrorCode === 4 ? "MEDIA_SOURCE_UNSUPPORTED" : "MEDIA_LOAD_FAILED";
|
|
2452
|
+
this.#fail(createError(code, code === "MEDIA_DECODE_FAILED" ? "The browser could not decode the selected video clip." : code === "MEDIA_SOURCE_UNSUPPORTED" ? "The browser could not use any source for the selected video clip." : "The selected video clip failed to load.", this.#config.id, this.#activeClipId, this.#selectedSource, cause));
|
|
2453
|
+
}
|
|
2454
|
+
#fail(error, emit = true) {
|
|
2455
|
+
++this.#generation;
|
|
2456
|
+
this.#cancelFrameObservation();
|
|
2457
|
+
this.#removeSourceListeners?.();
|
|
2458
|
+
this.#removeSourceListeners = null;
|
|
2459
|
+
this.#seekInFlight = false;
|
|
2460
|
+
this.#pendingSeek = null;
|
|
2461
|
+
this.#seeking = false;
|
|
2462
|
+
this.#loadState = "error";
|
|
2463
|
+
this.#error = error;
|
|
2464
|
+
this.#rejectWaiters(error);
|
|
2465
|
+
this.#rejectReadinessWaiters(error);
|
|
2466
|
+
if (emit) this.#emit({
|
|
2467
|
+
type: "error",
|
|
2468
|
+
error
|
|
2469
|
+
});
|
|
2470
|
+
}
|
|
2471
|
+
#resetSource(loadState) {
|
|
2472
|
+
++this.#generation;
|
|
2473
|
+
this.#cancelFrameObservation();
|
|
2474
|
+
this.#removeSourceListeners?.();
|
|
2475
|
+
this.#removeSourceListeners = null;
|
|
2476
|
+
this.#seekInFlight = false;
|
|
2477
|
+
this.#pendingSeek = null;
|
|
2478
|
+
this.#seeking = false;
|
|
2479
|
+
this.#activeClipId = null;
|
|
2480
|
+
this.#selectedSource = null;
|
|
2481
|
+
this.#duration = null;
|
|
2482
|
+
this.#appliedTime = null;
|
|
2483
|
+
this.#presentedTime = null;
|
|
2484
|
+
this.#error = null;
|
|
2485
|
+
this.#loadState = loadState;
|
|
2486
|
+
try {
|
|
2487
|
+
this.#target.pause();
|
|
2488
|
+
this.#target.srcObject = null;
|
|
2489
|
+
this.#target.removeAttribute("src");
|
|
2490
|
+
this.#target.load();
|
|
2491
|
+
} catch {}
|
|
2492
|
+
}
|
|
2493
|
+
#supportsVideoFrameCallback() {
|
|
2494
|
+
return typeof this.#target.requestVideoFrameCallback === "function";
|
|
2495
|
+
}
|
|
2496
|
+
#cancelFrameObservation() {
|
|
2497
|
+
++this.#frameToken;
|
|
2498
|
+
if (this.#frameHandle === null) return;
|
|
2499
|
+
try {
|
|
2500
|
+
const cancel = this.#target.cancelVideoFrameCallback;
|
|
2501
|
+
if (cancel !== void 0) cancel.call(this.#target, this.#frameHandle);
|
|
2502
|
+
} catch {}
|
|
2503
|
+
this.#frameHandle = null;
|
|
2504
|
+
}
|
|
2505
|
+
#resolveWaiters() {
|
|
2506
|
+
for (const waiter of this.#waiters) waiter.resolve();
|
|
2507
|
+
this.#waiters.clear();
|
|
2508
|
+
}
|
|
2509
|
+
#rejectWaiters(error) {
|
|
2510
|
+
for (const waiter of this.#waiters) waiter.reject(error);
|
|
2511
|
+
this.#waiters.clear();
|
|
2512
|
+
}
|
|
2513
|
+
#emit(event) {
|
|
2514
|
+
if (!this.#destroyed) this.#onEvent(event);
|
|
2515
|
+
}
|
|
2516
|
+
#isCurrentGeneration(generation) {
|
|
2517
|
+
return !this.#destroyed && generation === this.#generation;
|
|
2518
|
+
}
|
|
2519
|
+
#assertNotDestroyed() {
|
|
2520
|
+
if (this.#destroyed) throw new FrameByFrameError("CONTROLLER_DESTROYED", "The video renderer has already been destroyed.", { details: { bindingId: this.#config.id } });
|
|
2521
|
+
}
|
|
2522
|
+
};
|
|
2523
|
+
/** Creates one native video renderer around an already resolved and claimed target. */
|
|
2524
|
+
const createNativeVideoRenderer = (config, handle, onEvent, dependencies = defaultDependencies, activity = "active", viewportTarget = handle.target) => new NativeVideoRenderer(config, handle, onEvent, dependencies, activity, viewportTarget);
|
|
2525
|
+
|
|
2526
|
+
//#endregion
|
|
2527
|
+
//#region src/media/video-target.ts
|
|
2528
|
+
const isRecord$1 = (value) => typeof value === "object" && value !== null;
|
|
2529
|
+
const isMediaDocument = (value) => isRecord$1(value) && value["nodeType"] === 9 && typeof value["querySelector"] === "function" && typeof value["createElement"] === "function";
|
|
2530
|
+
const isMediaHtmlElement = (value) => isRecord$1(value) && value["nodeType"] === 1 && typeof value["appendChild"] === "function" && isMediaDocument(value["ownerDocument"]);
|
|
2531
|
+
const getElementName = (value) => {
|
|
2532
|
+
const localName = value["localName"];
|
|
2533
|
+
if (typeof localName === "string") return localName.toLowerCase();
|
|
2534
|
+
const tagName = value["tagName"];
|
|
2535
|
+
return typeof tagName === "string" ? tagName.toLowerCase() : "";
|
|
2536
|
+
};
|
|
2537
|
+
const isMediaVideoElement = (value) => isRecord$1(value) && value["nodeType"] === 1 && getElementName(value) === "video" && typeof value["addEventListener"] === "function" && typeof value["removeEventListener"] === "function" && typeof value["canPlayType"] === "function" && typeof value["load"] === "function" && typeof value["pause"] === "function" && typeof value["getAttribute"] === "function" && typeof value["setAttribute"] === "function" && typeof value["removeAttribute"] === "function" && typeof value["currentTime"] === "number";
|
|
2538
|
+
const getGlobalDocument$1 = () => {
|
|
2539
|
+
const candidate = globalThis.document;
|
|
2540
|
+
return isMediaDocument(candidate) ? candidate : null;
|
|
2541
|
+
};
|
|
2542
|
+
const targetError = (code, bindingId, message, cause) => {
|
|
2543
|
+
throw new FrameByFrameError(code, message, {
|
|
2544
|
+
cause,
|
|
2545
|
+
details: { bindingId }
|
|
2546
|
+
});
|
|
2547
|
+
};
|
|
2548
|
+
const resolveMediaReference = (reference, bindingId, label) => {
|
|
2549
|
+
let candidate = reference;
|
|
2550
|
+
if (typeof candidate === "function") try {
|
|
2551
|
+
candidate = candidate();
|
|
2552
|
+
} catch (cause) {
|
|
2553
|
+
return targetError("TARGET_NOT_FOUND", bindingId, `The ${label} resolver threw an error.`, cause);
|
|
2554
|
+
}
|
|
2555
|
+
if (typeof candidate === "string") {
|
|
2556
|
+
const document = getGlobalDocument$1();
|
|
2557
|
+
if (document === null) throw new FrameByFrameError("ENVIRONMENT_UNAVAILABLE", `A browser document is required to resolve the ${label} selector.`, { details: { bindingId } });
|
|
2558
|
+
try {
|
|
2559
|
+
candidate = document.querySelector(candidate);
|
|
2560
|
+
} catch (cause) {
|
|
2561
|
+
return targetError("TARGET_NOT_FOUND", bindingId, `The ${label} selector is invalid.`, cause);
|
|
2562
|
+
}
|
|
2563
|
+
}
|
|
2564
|
+
if (candidate === null || candidate === void 0) return targetError("TARGET_NOT_FOUND", bindingId, `The ${label} could not be resolved.`, candidate);
|
|
2565
|
+
return candidate;
|
|
2566
|
+
};
|
|
2567
|
+
/** Prevents more than one mounted binding from writing to the same target. */
|
|
2568
|
+
var VideoTargetRegistry = class {
|
|
2569
|
+
#owners = /* @__PURE__ */ new WeakMap();
|
|
2570
|
+
acquire(target, owner, bindingId) {
|
|
2571
|
+
if (this.#owners.has(target)) throw new FrameByFrameError("TARGET_CONFLICT", "The video target is already controlled by another mounted binding.", { details: { bindingId } });
|
|
2572
|
+
this.#owners.set(target, owner);
|
|
2573
|
+
let active = true;
|
|
2574
|
+
return () => {
|
|
2575
|
+
if (!active) return;
|
|
2576
|
+
active = false;
|
|
2577
|
+
if (this.#owners.get(target) === owner) this.#owners.delete(target);
|
|
2578
|
+
};
|
|
2579
|
+
}
|
|
2580
|
+
};
|
|
2581
|
+
const createOwnedTarget = (mountTo, bindingId) => {
|
|
2582
|
+
const document = mountTo.ownerDocument;
|
|
2583
|
+
if (!isMediaDocument(document)) throw new FrameByFrameError("ENVIRONMENT_UNAVAILABLE", "The mount container does not expose an owner document.", { details: { bindingId } });
|
|
2584
|
+
const target = document.createElement("video");
|
|
2585
|
+
if (!isMediaVideoElement(target)) return targetError("INVALID_TARGET_TYPE", bindingId, "The owner document could not create an HTMLVideoElement.", target);
|
|
2586
|
+
mountTo.appendChild(target);
|
|
2587
|
+
return target;
|
|
2588
|
+
};
|
|
2589
|
+
/** Resolves, creates, and globally claims one binding's native video target. */
|
|
2590
|
+
const resolveVideoTarget = (config, registry) => {
|
|
2591
|
+
const owner = {};
|
|
2592
|
+
let target;
|
|
2593
|
+
let owned = false;
|
|
2594
|
+
if (config.target !== void 0) {
|
|
2595
|
+
const candidate = resolveMediaReference(config.target, config.id, "video target");
|
|
2596
|
+
if (!isMediaVideoElement(candidate)) return targetError("INVALID_TARGET_TYPE", config.id, "The resolved target must be an HTMLVideoElement.", candidate);
|
|
2597
|
+
target = candidate;
|
|
2598
|
+
} else {
|
|
2599
|
+
const candidate = resolveMediaReference(config.mountTo, config.id, "video mount container");
|
|
2600
|
+
if (!isMediaHtmlElement(candidate)) return targetError("INVALID_TARGET_TYPE", config.id, "The resolved mountTo value must be an HTMLElement.", candidate);
|
|
2601
|
+
target = createOwnedTarget(candidate, config.id);
|
|
2602
|
+
owned = true;
|
|
2603
|
+
}
|
|
2604
|
+
let releaseOwnership = null;
|
|
2605
|
+
try {
|
|
2606
|
+
releaseOwnership = registry.acquire(target, owner, config.id);
|
|
2607
|
+
} catch (error) {
|
|
2608
|
+
if (owned) target.parentNode?.removeChild(target);
|
|
2609
|
+
throw error;
|
|
2610
|
+
}
|
|
2611
|
+
let active = true;
|
|
2612
|
+
return {
|
|
2613
|
+
target,
|
|
2614
|
+
owned,
|
|
2615
|
+
release: () => {
|
|
2616
|
+
if (!active) return;
|
|
2617
|
+
active = false;
|
|
2618
|
+
releaseOwnership();
|
|
2619
|
+
if (owned) target.parentNode?.removeChild(target);
|
|
2620
|
+
}
|
|
2621
|
+
};
|
|
2622
|
+
};
|
|
2623
|
+
|
|
2624
|
+
//#endregion
|
|
2625
|
+
//#region src/scroll/source.ts
|
|
2626
|
+
const isRecord = (value) => typeof value === "object" && value !== null;
|
|
2627
|
+
const hasEventTargetMethods = (value) => typeof value["addEventListener"] === "function" && typeof value["removeEventListener"] === "function";
|
|
2628
|
+
const isDocument = (value) => isRecord(value) && value["nodeType"] === 9 && hasEventTargetMethods(value) && typeof value["querySelector"] === "function" && isRecord(value["documentElement"]);
|
|
2629
|
+
const isHtmlElement = (value) => isRecord(value) && value["nodeType"] === 1 && hasEventTargetMethods(value) && typeof value["scrollLeft"] === "number" && typeof value["scrollTop"] === "number" && typeof value["scrollWidth"] === "number" && typeof value["scrollHeight"] === "number" && typeof value["clientWidth"] === "number" && typeof value["clientHeight"] === "number";
|
|
2630
|
+
const isAnimationFrameHost = (value) => isRecord(value) && typeof value["requestAnimationFrame"] === "function" && typeof value["cancelAnimationFrame"] === "function";
|
|
2631
|
+
const getGlobalDocument = () => {
|
|
2632
|
+
const candidate = globalThis.document;
|
|
2633
|
+
return isDocument(candidate) ? candidate : null;
|
|
2634
|
+
};
|
|
2635
|
+
const getGlobalWindow = () => globalThis.window;
|
|
2636
|
+
const environmentUnavailable = (message) => {
|
|
2637
|
+
throw new FrameByFrameError("ENVIRONMENT_UNAVAILABLE", message);
|
|
2638
|
+
};
|
|
2639
|
+
const sourceNotFound = (message, cause) => {
|
|
2640
|
+
throw new FrameByFrameError("SOURCE_NOT_FOUND", message, { cause });
|
|
2641
|
+
};
|
|
2642
|
+
const getDocumentMetricsTarget = (documentSource) => {
|
|
2643
|
+
const metricsTarget = documentSource.scrollingElement ?? documentSource.documentElement;
|
|
2644
|
+
if (!isHtmlElement(metricsTarget)) return sourceNotFound("The document does not expose a valid scrolling element.", metricsTarget);
|
|
2645
|
+
return metricsTarget;
|
|
2646
|
+
};
|
|
2647
|
+
const getOwnerDocument = (source) => {
|
|
2648
|
+
if (isDocument(source)) return source;
|
|
2649
|
+
const ownerDocument = source.ownerDocument;
|
|
2650
|
+
return isDocument(ownerDocument) ? ownerDocument : null;
|
|
2651
|
+
};
|
|
2652
|
+
const resolveFrameHost = (source) => {
|
|
2653
|
+
const ownerWindow = getOwnerDocument(source)?.defaultView;
|
|
2654
|
+
const frameHost = isAnimationFrameHost(ownerWindow) ? ownerWindow : getGlobalWindow();
|
|
2655
|
+
if (!isAnimationFrameHost(frameHost)) return environmentUnavailable("requestAnimationFrame and cancelAnimationFrame are required to mount a controller.");
|
|
2656
|
+
return frameHost;
|
|
2657
|
+
};
|
|
2658
|
+
const canonicalizeSource = (source) => {
|
|
2659
|
+
if (isDocument(source)) return source;
|
|
2660
|
+
const ownerDocument = getOwnerDocument(source);
|
|
2661
|
+
if (ownerDocument !== null && (source === ownerDocument.scrollingElement || ownerDocument.scrollingElement === null && source === ownerDocument.documentElement)) return ownerDocument;
|
|
2662
|
+
return source;
|
|
2663
|
+
};
|
|
2664
|
+
const resolveReference = (reference) => {
|
|
2665
|
+
let candidate = reference;
|
|
2666
|
+
if (typeof candidate === "function") try {
|
|
2667
|
+
candidate = candidate();
|
|
2668
|
+
} catch (cause) {
|
|
2669
|
+
return sourceNotFound("The scroll source resolver threw an error.", cause);
|
|
2670
|
+
}
|
|
2671
|
+
if (candidate === void 0) {
|
|
2672
|
+
const globalDocument = getGlobalDocument();
|
|
2673
|
+
if (globalDocument === null) return environmentUnavailable("A browser document is required when no scroll source is provided.");
|
|
2674
|
+
return globalDocument;
|
|
2675
|
+
}
|
|
2676
|
+
if (typeof candidate === "string") {
|
|
2677
|
+
const globalDocument = getGlobalDocument();
|
|
2678
|
+
if (globalDocument === null) return environmentUnavailable("A browser document is required to resolve a selector.");
|
|
2679
|
+
let selected;
|
|
2680
|
+
try {
|
|
2681
|
+
selected = globalDocument.querySelector(candidate);
|
|
2682
|
+
} catch (cause) {
|
|
2683
|
+
return sourceNotFound(`The scroll source selector "${candidate}" is invalid.`, cause);
|
|
2684
|
+
}
|
|
2685
|
+
if (!isHtmlElement(selected)) return sourceNotFound(`The scroll source selector "${candidate}" did not resolve to an HTMLElement.`, selected);
|
|
2686
|
+
return selected;
|
|
2687
|
+
}
|
|
2688
|
+
if (!isDocument(candidate) && !isHtmlElement(candidate)) return sourceNotFound("The scroll source must be a Document, HTMLElement, selector, or synchronous resolver.", candidate);
|
|
2689
|
+
return candidate;
|
|
2690
|
+
};
|
|
2691
|
+
/** Resolves and canonicalizes a configured source without accessing the DOM before mount. */
|
|
2692
|
+
const resolveScrollSource = (reference) => {
|
|
2693
|
+
const source = canonicalizeSource(resolveReference(reference));
|
|
2694
|
+
const frameHost = resolveFrameHost(source);
|
|
2695
|
+
return {
|
|
2696
|
+
key: source,
|
|
2697
|
+
publicSource: source,
|
|
2698
|
+
eventTarget: source,
|
|
2699
|
+
metricsTarget: isDocument(source) ? getDocumentMetricsTarget(source) : source,
|
|
2700
|
+
requestFrame: frameHost.requestAnimationFrame.bind(frameHost),
|
|
2701
|
+
cancelFrame: frameHost.cancelAnimationFrame.bind(frameHost)
|
|
2702
|
+
};
|
|
2703
|
+
};
|
|
2704
|
+
|
|
2705
|
+
//#endregion
|
|
2706
|
+
//#region src/scroll/source-scheduler.ts
|
|
2707
|
+
const clampProgress = (value) => Math.min(1, Math.max(0, value));
|
|
2708
|
+
const finiteOrZero = (value) => Number.isFinite(value) ? value : 0;
|
|
2709
|
+
const createAxisSnapshot = (offset, max) => ({
|
|
2710
|
+
offset,
|
|
2711
|
+
max,
|
|
2712
|
+
progress: max === 0 ? 0 : clampProgress(offset / max)
|
|
2713
|
+
});
|
|
2714
|
+
/** One passive listener and at most one pending animation frame per scroll source. */
|
|
2715
|
+
var SourceScheduler = class {
|
|
2716
|
+
#source;
|
|
2717
|
+
#reportAsyncError;
|
|
2718
|
+
#subscribers = /* @__PURE__ */ new Set();
|
|
2719
|
+
#handleScroll = () => {
|
|
2720
|
+
this.#schedule();
|
|
2721
|
+
};
|
|
2722
|
+
#handleFrame = () => {
|
|
2723
|
+
this.#frameHandle = null;
|
|
2724
|
+
const snapshot = this.getSnapshot();
|
|
2725
|
+
for (const subscriber of [...this.#subscribers]) try {
|
|
2726
|
+
subscriber(snapshot);
|
|
2727
|
+
} catch (error) {
|
|
2728
|
+
this.#reportAsyncError(error);
|
|
2729
|
+
}
|
|
2730
|
+
};
|
|
2731
|
+
#frameHandle = null;
|
|
2732
|
+
#xMax = 0;
|
|
2733
|
+
#yMax = 0;
|
|
2734
|
+
constructor(source, reportAsyncError) {
|
|
2735
|
+
this.#source = source;
|
|
2736
|
+
this.#reportAsyncError = reportAsyncError;
|
|
2737
|
+
}
|
|
2738
|
+
subscribe(subscriber) {
|
|
2739
|
+
const shouldAttach = this.#subscribers.size === 0;
|
|
2740
|
+
this.#subscribers.add(subscriber);
|
|
2741
|
+
if (shouldAttach) try {
|
|
2742
|
+
this.#source.eventTarget.addEventListener("scroll", this.#handleScroll, { passive: true });
|
|
2743
|
+
} catch (error) {
|
|
2744
|
+
this.#subscribers.delete(subscriber);
|
|
2745
|
+
throw error;
|
|
2746
|
+
}
|
|
2747
|
+
let active = true;
|
|
2748
|
+
return () => {
|
|
2749
|
+
if (!active) return;
|
|
2750
|
+
active = false;
|
|
2751
|
+
this.#subscribers.delete(subscriber);
|
|
2752
|
+
if (this.#subscribers.size === 0) {
|
|
2753
|
+
this.#source.eventTarget.removeEventListener("scroll", this.#handleScroll);
|
|
2754
|
+
if (this.#frameHandle !== null) {
|
|
2755
|
+
this.#source.cancelFrame(this.#frameHandle);
|
|
2756
|
+
this.#frameHandle = null;
|
|
2757
|
+
}
|
|
2758
|
+
}
|
|
2759
|
+
};
|
|
2760
|
+
}
|
|
2761
|
+
refresh() {
|
|
2762
|
+
const metrics = this.#source.metricsTarget;
|
|
2763
|
+
this.#xMax = Math.max(0, finiteOrZero(metrics.scrollWidth) - finiteOrZero(metrics.clientWidth));
|
|
2764
|
+
this.#yMax = Math.max(0, finiteOrZero(metrics.scrollHeight) - finiteOrZero(metrics.clientHeight));
|
|
2765
|
+
return this.getSnapshot();
|
|
2766
|
+
}
|
|
2767
|
+
getSnapshot() {
|
|
2768
|
+
const metrics = this.#source.metricsTarget;
|
|
2769
|
+
const x = createAxisSnapshot(finiteOrZero(metrics.scrollLeft), this.#xMax);
|
|
2770
|
+
const y = createAxisSnapshot(finiteOrZero(metrics.scrollTop), this.#yMax);
|
|
2771
|
+
return Object.freeze({
|
|
2772
|
+
x: Object.freeze(x),
|
|
2773
|
+
y: Object.freeze(y)
|
|
2774
|
+
});
|
|
2775
|
+
}
|
|
2776
|
+
#schedule() {
|
|
2777
|
+
if (this.#frameHandle === null && this.#subscribers.size > 0) this.#frameHandle = this.#source.requestFrame(this.#handleFrame);
|
|
2778
|
+
}
|
|
2779
|
+
};
|
|
2780
|
+
/** Weakly keys the shared scheduler used by every controller on one source. */
|
|
2781
|
+
var SourceRegistry = class {
|
|
2782
|
+
#schedulers = /* @__PURE__ */ new WeakMap();
|
|
2783
|
+
#reportAsyncError;
|
|
2784
|
+
constructor(reportAsyncError) {
|
|
2785
|
+
this.#reportAsyncError = reportAsyncError;
|
|
2786
|
+
}
|
|
2787
|
+
get(source) {
|
|
2788
|
+
const existing = this.#schedulers.get(source.key);
|
|
2789
|
+
if (existing !== void 0) return existing;
|
|
2790
|
+
const scheduler = new SourceScheduler(source, this.#reportAsyncError);
|
|
2791
|
+
this.#schedulers.set(source.key, scheduler);
|
|
2792
|
+
return scheduler;
|
|
2793
|
+
}
|
|
2794
|
+
};
|
|
2795
|
+
|
|
2796
|
+
//#endregion
|
|
2797
|
+
//#region src/core/public-controller.ts
|
|
2798
|
+
const reportAsyncError = (error) => {
|
|
2799
|
+
globalThis.queueMicrotask(() => {
|
|
2800
|
+
throw error;
|
|
2801
|
+
});
|
|
2802
|
+
};
|
|
2803
|
+
const sourceRegistry = new SourceRegistry(reportAsyncError);
|
|
2804
|
+
const videoTargetRegistry = new VideoTargetRegistry();
|
|
2805
|
+
const createVideoRenderer = (config, onEvent, activity) => {
|
|
2806
|
+
const handle = resolveVideoTarget(config, videoTargetRegistry);
|
|
2807
|
+
try {
|
|
2808
|
+
return createNativeVideoRenderer(config, handle, onEvent, void 0, activity);
|
|
2809
|
+
} catch (error) {
|
|
2810
|
+
handle.release();
|
|
2811
|
+
throw error;
|
|
2812
|
+
}
|
|
2813
|
+
};
|
|
2814
|
+
/** Creates an SSR-safe controller; browser capabilities are resolved by mount(). */
|
|
2815
|
+
const createFrameByFrame = (options) => createController(options, {
|
|
2816
|
+
resolveSource: resolveScrollSource,
|
|
2817
|
+
sourceRegistry,
|
|
2818
|
+
reportAsyncError,
|
|
2819
|
+
createRenderer: createVideoRenderer,
|
|
2820
|
+
supportedRenderers: /* @__PURE__ */ new Set(["video"])
|
|
2821
|
+
});
|
|
2822
|
+
|
|
2823
|
+
//#endregion
|
|
2824
|
+
export { videoTargetRegistry as a, isMediaHtmlElement as c, createNativeVideoRenderer as d, createController as f, sourceRegistry as i, isMediaVideoElement as l, FrameByFrameError as m, createVideoRenderer as n, resolveScrollSource as o, createTimeline as p, reportAsyncError as r, isMediaDocument as s, createFrameByFrame as t, resolveMediaReference as u };
|
|
2825
|
+
//# sourceMappingURL=public-controller-jGcw6iqQ.js.map
|