@editframe/elements 0.25.0-beta.0 → 0.26.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/elements/EFMedia/AssetMediaEngine.js +2 -1
- package/dist/elements/EFMedia/AssetMediaEngine.js.map +1 -1
- package/dist/elements/EFMedia/BaseMediaEngine.js +13 -0
- package/dist/elements/EFMedia/BaseMediaEngine.js.map +1 -1
- package/dist/elements/EFMedia/JitMediaEngine.js +2 -1
- package/dist/elements/EFMedia/JitMediaEngine.js.map +1 -1
- package/dist/elements/EFMedia/audioTasks/makeAudioBufferTask.js +11 -4
- package/dist/elements/EFMedia/audioTasks/makeAudioBufferTask.js.map +1 -1
- package/dist/elements/EFMedia/shared/BufferUtils.js +16 -1
- package/dist/elements/EFMedia/shared/BufferUtils.js.map +1 -1
- package/dist/elements/EFMedia/videoTasks/makeVideoBufferTask.js +11 -4
- package/dist/elements/EFMedia/videoTasks/makeVideoBufferTask.js.map +1 -1
- package/dist/elements/EFSurface.d.ts +4 -4
- package/dist/elements/EFTemporal.js +16 -2
- package/dist/elements/EFTemporal.js.map +1 -1
- package/dist/elements/EFThumbnailStrip.d.ts +4 -4
- package/dist/elements/EFTimegroup.d.ts +22 -0
- package/dist/elements/EFTimegroup.js +35 -0
- package/dist/elements/EFTimegroup.js.map +1 -1
- package/dist/elements/updateAnimations.js +3 -1
- package/dist/elements/updateAnimations.js.map +1 -1
- package/dist/gui/EFControls.d.ts +2 -2
- package/dist/gui/EFDial.d.ts +4 -4
- package/dist/gui/EFFocusOverlay.d.ts +4 -4
- package/dist/gui/EFPause.d.ts +4 -4
- package/dist/gui/EFPlay.d.ts +4 -4
- package/dist/gui/EFResizableBox.d.ts +4 -4
- package/dist/gui/EFScrubber.d.ts +4 -4
- package/dist/gui/EFTimeDisplay.d.ts +4 -4
- package/dist/gui/EFToggleLoop.d.ts +4 -4
- package/dist/gui/TWMixin.js +1 -1
- package/dist/gui/TWMixin.js.map +1 -1
- package/dist/index.js +0 -1
- package/dist/index.js.map +1 -1
- package/dist/style.css +877 -0
- package/dist/transcoding/types/index.d.ts +1 -0
- package/package.json +30 -8
- package/scripts/build-css.js +41 -0
- package/src/elements/EFMedia/AssetMediaEngine.ts +1 -0
- package/src/elements/EFMedia/BaseMediaEngine.ts +20 -0
- package/src/elements/EFMedia/JitMediaEngine.browsertest.ts +68 -0
- package/src/elements/EFMedia/JitMediaEngine.ts +1 -0
- package/src/elements/EFMedia/audioTasks/makeAudioBufferTask.ts +12 -0
- package/src/elements/EFMedia/shared/BufferUtils.ts +42 -0
- package/src/elements/EFMedia/videoTasks/makeVideoBufferTask.ts +12 -0
- package/src/elements/EFTemporal.ts +20 -4
- package/src/elements/EFTimegroup.browsertest.ts +198 -0
- package/src/elements/EFTimegroup.ts +57 -0
- package/src/elements/updateAnimations.browsertest.ts +801 -0
- package/src/elements/updateAnimations.ts +12 -1
- package/src/transcoding/types/index.ts +1 -0
- package/tsdown.config.ts +24 -3
- package/types.json +1 -1
- package/dist/elements-ZhsB7B5N.css +0 -9
- package/dist/elements-ZhsB7B5N.css.map +0 -1
- package/dist/elements.js +0 -0
package/package.json
CHANGED
|
@@ -1,15 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@editframe/elements",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0-beta.0",
|
|
4
4
|
"description": "",
|
|
5
|
-
"exports": {
|
|
6
|
-
".": "./dist/index.js",
|
|
7
|
-
"./package.json": "./package.json"
|
|
8
|
-
},
|
|
9
5
|
"type": "module",
|
|
10
6
|
"scripts": {
|
|
11
7
|
"typecheck": "tsc --noEmit --emitDeclarationOnly false",
|
|
12
|
-
"build": "tsdown",
|
|
8
|
+
"build": "tsdown && node scripts/build-css.js",
|
|
13
9
|
"build:watch": "tsdown --watch",
|
|
14
10
|
"typedoc": "typedoc --json ./types.json --plugin typedoc-plugin-zod --excludeExternals ./src && jq -c . ./types.json > ./types.tmp.json && mv ./types.tmp.json ./types.json"
|
|
15
11
|
},
|
|
@@ -17,7 +13,7 @@
|
|
|
17
13
|
"license": "UNLICENSED",
|
|
18
14
|
"dependencies": {
|
|
19
15
|
"@bramus/style-observer": "^1.3.0",
|
|
20
|
-
"@editframe/assets": "0.
|
|
16
|
+
"@editframe/assets": "0.26.0-beta.0",
|
|
21
17
|
"@lit/context": "^1.1.6",
|
|
22
18
|
"@lit/task": "^1.0.3",
|
|
23
19
|
"@opentelemetry/api": "^1.9.0",
|
|
@@ -36,9 +32,35 @@
|
|
|
36
32
|
"@types/dom-webcodecs": "^0.1.11",
|
|
37
33
|
"@types/node": "^20.14.13",
|
|
38
34
|
"autoprefixer": "^10.4.19",
|
|
35
|
+
"postcss": "^8.4.38",
|
|
36
|
+
"tailwindcss": "^3.4.3",
|
|
39
37
|
"typescript": "^5.5.4"
|
|
40
38
|
},
|
|
41
39
|
"main": "./dist/index.js",
|
|
42
40
|
"module": "./dist/index.js",
|
|
43
|
-
"types": "./dist/index.d.ts"
|
|
41
|
+
"types": "./dist/index.d.ts",
|
|
42
|
+
"exports": {
|
|
43
|
+
".": {
|
|
44
|
+
"import": {
|
|
45
|
+
"types": "./dist/index.d.ts",
|
|
46
|
+
"default": "./dist/index.js"
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
"./package.json": "./package.json",
|
|
50
|
+
"./styles.css": "./dist/style.css",
|
|
51
|
+
"./types.json": "./types.json"
|
|
52
|
+
},
|
|
53
|
+
"publishConfig": {
|
|
54
|
+
"exports": {
|
|
55
|
+
".": {
|
|
56
|
+
"import": {
|
|
57
|
+
"types": "./dist/index.d.ts",
|
|
58
|
+
"default": "./dist/index.js"
|
|
59
|
+
}
|
|
60
|
+
},
|
|
61
|
+
"./package.json": "./package.json",
|
|
62
|
+
"./styles.css": "./dist/style.css",
|
|
63
|
+
"./types.json": "./types.json"
|
|
64
|
+
}
|
|
65
|
+
}
|
|
44
66
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import autoprefixer from "autoprefixer";
|
|
4
|
+
import { mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
5
|
+
import { dirname, join } from "path";
|
|
6
|
+
import postcss from "postcss";
|
|
7
|
+
import tailwindcss from "tailwindcss";
|
|
8
|
+
import { fileURLToPath } from "url";
|
|
9
|
+
|
|
10
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const srcDir = join(__dirname, "..", "src");
|
|
12
|
+
const distDir = join(__dirname, "..", "dist");
|
|
13
|
+
|
|
14
|
+
// Ensure dist directory exists
|
|
15
|
+
mkdirSync(distDir, { recursive: true });
|
|
16
|
+
|
|
17
|
+
// Read source CSS
|
|
18
|
+
const cssPath = join(srcDir, "elements.css");
|
|
19
|
+
const css = readFileSync(cssPath, "utf-8");
|
|
20
|
+
|
|
21
|
+
// Process through PostCSS
|
|
22
|
+
console.log("Processing CSS through Tailwind and PostCSS...");
|
|
23
|
+
|
|
24
|
+
postcss([
|
|
25
|
+
tailwindcss({
|
|
26
|
+
content: [join(srcDir, "**/*.ts")],
|
|
27
|
+
}),
|
|
28
|
+
autoprefixer(),
|
|
29
|
+
])
|
|
30
|
+
.process(css, { from: cssPath, to: join(distDir, "style.css") })
|
|
31
|
+
.then((result) => {
|
|
32
|
+
writeFileSync(join(distDir, "style.css"), result.css);
|
|
33
|
+
if (result.map) {
|
|
34
|
+
writeFileSync(join(distDir, "style.css.map"), result.map.toString());
|
|
35
|
+
}
|
|
36
|
+
console.log("✅ CSS processed and written to dist/style.css");
|
|
37
|
+
})
|
|
38
|
+
.catch((error) => {
|
|
39
|
+
console.error("❌ CSS processing failed:", error);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
});
|
|
@@ -346,6 +346,7 @@ export class AssetMediaEngine extends BaseMediaEngine implements MediaEngine {
|
|
|
346
346
|
audioBufferDurationMs: 2000,
|
|
347
347
|
maxVideoBufferFetches: 1,
|
|
348
348
|
maxAudioBufferFetches: 1,
|
|
349
|
+
bufferThresholdMs: 30000, // Timeline-aware buffering threshold
|
|
349
350
|
};
|
|
350
351
|
}
|
|
351
352
|
|
|
@@ -482,4 +482,24 @@ export abstract class BaseMediaEngine {
|
|
|
482
482
|
segmentId: number,
|
|
483
483
|
rendition: VideoRendition,
|
|
484
484
|
): number[];
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* Get buffer configuration for this media engine
|
|
488
|
+
* Can be overridden by subclasses to provide custom buffer settings
|
|
489
|
+
*/
|
|
490
|
+
getBufferConfig(): {
|
|
491
|
+
videoBufferDurationMs: number;
|
|
492
|
+
audioBufferDurationMs: number;
|
|
493
|
+
maxVideoBufferFetches: number;
|
|
494
|
+
maxAudioBufferFetches: number;
|
|
495
|
+
bufferThresholdMs: number;
|
|
496
|
+
} {
|
|
497
|
+
return {
|
|
498
|
+
videoBufferDurationMs: 10000, // 10 seconds
|
|
499
|
+
audioBufferDurationMs: 10000, // 10 seconds
|
|
500
|
+
maxVideoBufferFetches: 3,
|
|
501
|
+
maxAudioBufferFetches: 3,
|
|
502
|
+
bufferThresholdMs: 30000, // 30 seconds - timeline-aware buffering threshold
|
|
503
|
+
};
|
|
504
|
+
}
|
|
485
505
|
}
|
|
@@ -155,4 +155,72 @@ describe("JitMediaEngine", () => {
|
|
|
155
155
|
"http://localhost:63315/api/v1/transcode/{rendition}/{segmentId}.m4s?url=http%3A%2F%2Fweb%3A3000%2Fhead-moov-480p.mp4",
|
|
156
156
|
});
|
|
157
157
|
});
|
|
158
|
+
|
|
159
|
+
test("calculatePlayheadDistance utility function", async ({ expect }) => {
|
|
160
|
+
const { calculatePlayheadDistance } = await import(
|
|
161
|
+
"./shared/BufferUtils.js"
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// Element is currently active (playhead within element bounds)
|
|
165
|
+
expect(
|
|
166
|
+
calculatePlayheadDistance(
|
|
167
|
+
{ startTimeMs: 0, endTimeMs: 2000 },
|
|
168
|
+
1000, // playhead at 1s
|
|
169
|
+
),
|
|
170
|
+
).toBe(0);
|
|
171
|
+
|
|
172
|
+
// Element hasn't started yet (playhead before element)
|
|
173
|
+
expect(
|
|
174
|
+
calculatePlayheadDistance(
|
|
175
|
+
{ startTimeMs: 2000, endTimeMs: 4000 },
|
|
176
|
+
0, // playhead at 0s
|
|
177
|
+
),
|
|
178
|
+
).toBe(2000);
|
|
179
|
+
|
|
180
|
+
// Element already finished (playhead after element)
|
|
181
|
+
expect(
|
|
182
|
+
calculatePlayheadDistance(
|
|
183
|
+
{ startTimeMs: 0, endTimeMs: 2000 },
|
|
184
|
+
5000, // playhead at 5s
|
|
185
|
+
),
|
|
186
|
+
).toBe(3000);
|
|
187
|
+
|
|
188
|
+
// Playhead at element start boundary
|
|
189
|
+
expect(
|
|
190
|
+
calculatePlayheadDistance(
|
|
191
|
+
{ startTimeMs: 2000, endTimeMs: 4000 },
|
|
192
|
+
2000, // playhead exactly at start
|
|
193
|
+
),
|
|
194
|
+
).toBe(0);
|
|
195
|
+
|
|
196
|
+
// Playhead at element end boundary
|
|
197
|
+
expect(
|
|
198
|
+
calculatePlayheadDistance(
|
|
199
|
+
{ startTimeMs: 2000, endTimeMs: 4000 },
|
|
200
|
+
4000, // playhead exactly at end
|
|
201
|
+
),
|
|
202
|
+
).toBe(0);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("buffer config includes timeline threshold", async ({ expect }) => {
|
|
206
|
+
const configuration = document.createElement("ef-configuration");
|
|
207
|
+
const apiHost = `${window.location.protocol}//${window.location.host}`;
|
|
208
|
+
configuration.setAttribute("api-host", apiHost);
|
|
209
|
+
configuration.apiHost = apiHost;
|
|
210
|
+
configuration.signingURL = "";
|
|
211
|
+
|
|
212
|
+
const video = document.createElement("ef-video");
|
|
213
|
+
video.src = "http://web:3000/head-moov-480p.mp4";
|
|
214
|
+
configuration.appendChild(video);
|
|
215
|
+
document.body.appendChild(configuration);
|
|
216
|
+
|
|
217
|
+
// Wait for media engine to initialize
|
|
218
|
+
const mediaEngine = await video.mediaEngineTask.taskComplete;
|
|
219
|
+
|
|
220
|
+
// Check that buffer config includes the threshold
|
|
221
|
+
const bufferConfig = mediaEngine.getBufferConfig();
|
|
222
|
+
expect(bufferConfig.bufferThresholdMs).toBe(30000);
|
|
223
|
+
|
|
224
|
+
configuration.remove();
|
|
225
|
+
});
|
|
158
226
|
});
|
|
@@ -206,6 +206,7 @@ export class JitMediaEngine extends BaseMediaEngine implements MediaEngine {
|
|
|
206
206
|
audioBufferDurationMs: 8000,
|
|
207
207
|
maxVideoBufferFetches: 3,
|
|
208
208
|
maxAudioBufferFetches: 3,
|
|
209
|
+
bufferThresholdMs: 30000, // Timeline-aware buffering threshold
|
|
209
210
|
};
|
|
210
211
|
}
|
|
211
212
|
|
|
@@ -62,8 +62,19 @@ export const makeAudioBufferTask = (host: EFMedia): AudioBufferTask => {
|
|
|
62
62
|
bufferDurationMs,
|
|
63
63
|
maxParallelFetches,
|
|
64
64
|
enableBuffering: host.enableAudioBuffering,
|
|
65
|
+
bufferThresholdMs: engineConfig.bufferThresholdMs,
|
|
65
66
|
};
|
|
66
67
|
|
|
68
|
+
// Timeline context for priority-based buffering
|
|
69
|
+
const timelineContext =
|
|
70
|
+
host.rootTimegroup?.currentTimeMs !== undefined
|
|
71
|
+
? {
|
|
72
|
+
elementStartMs: host.startTimeMs,
|
|
73
|
+
elementEndMs: host.endTimeMs,
|
|
74
|
+
playheadMs: host.rootTimegroup.currentTimeMs,
|
|
75
|
+
}
|
|
76
|
+
: undefined;
|
|
77
|
+
|
|
67
78
|
return manageMediaBuffer<AudioRendition>(
|
|
68
79
|
seekTimeMs,
|
|
69
80
|
currentConfig,
|
|
@@ -99,6 +110,7 @@ export const makeAudioBufferTask = (host: EFMedia): AudioBufferTask => {
|
|
|
99
110
|
},
|
|
100
111
|
logError: console.error,
|
|
101
112
|
},
|
|
113
|
+
timelineContext,
|
|
102
114
|
);
|
|
103
115
|
},
|
|
104
116
|
});
|
|
@@ -21,6 +21,7 @@ export interface MediaBufferConfig {
|
|
|
21
21
|
maxParallelFetches: number;
|
|
22
22
|
enableBuffering: boolean;
|
|
23
23
|
enableContinuousBuffering?: boolean;
|
|
24
|
+
bufferThresholdMs?: number; // Timeline-aware buffering threshold (default: 30000ms)
|
|
24
25
|
}
|
|
25
26
|
|
|
26
27
|
/**
|
|
@@ -189,6 +190,26 @@ export const getUnrequestedSegments = (
|
|
|
189
190
|
return segmentIds.filter((id) => !bufferState.requestedSegments.has(id));
|
|
190
191
|
};
|
|
191
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Calculate distance from element to playhead position
|
|
195
|
+
* Returns 0 if element is currently active, otherwise returns distance in milliseconds
|
|
196
|
+
*/
|
|
197
|
+
export const calculatePlayheadDistance = (
|
|
198
|
+
element: { startTimeMs: number; endTimeMs: number },
|
|
199
|
+
playheadMs: number,
|
|
200
|
+
): number => {
|
|
201
|
+
// Element hasn't started yet
|
|
202
|
+
if (playheadMs < element.startTimeMs) {
|
|
203
|
+
return element.startTimeMs - playheadMs;
|
|
204
|
+
}
|
|
205
|
+
// Element already finished
|
|
206
|
+
if (playheadMs > element.endTimeMs) {
|
|
207
|
+
return playheadMs - element.endTimeMs;
|
|
208
|
+
}
|
|
209
|
+
// Element is currently active
|
|
210
|
+
return 0;
|
|
211
|
+
};
|
|
212
|
+
|
|
192
213
|
/**
|
|
193
214
|
* Core media buffering orchestration logic - prefetch only, no data storage
|
|
194
215
|
* Integrates with BaseMediaEngine's existing caching and request deduplication
|
|
@@ -202,11 +223,32 @@ export const manageMediaBuffer = async <
|
|
|
202
223
|
durationMs: number,
|
|
203
224
|
signal: AbortSignal,
|
|
204
225
|
deps: MediaBufferDependencies<T>,
|
|
226
|
+
timelineContext?: {
|
|
227
|
+
elementStartMs: number;
|
|
228
|
+
elementEndMs: number;
|
|
229
|
+
playheadMs: number;
|
|
230
|
+
},
|
|
205
231
|
): Promise<MediaBufferState> => {
|
|
206
232
|
if (!config.enableBuffering) {
|
|
207
233
|
return currentState;
|
|
208
234
|
}
|
|
209
235
|
|
|
236
|
+
// Timeline-aware buffering: skip if element is too far from playhead
|
|
237
|
+
if (timelineContext && config.bufferThresholdMs !== undefined) {
|
|
238
|
+
const distance = calculatePlayheadDistance(
|
|
239
|
+
{
|
|
240
|
+
startTimeMs: timelineContext.elementStartMs,
|
|
241
|
+
endTimeMs: timelineContext.elementEndMs,
|
|
242
|
+
},
|
|
243
|
+
timelineContext.playheadMs,
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
if (distance > config.bufferThresholdMs) {
|
|
247
|
+
// Element is too far from playhead, skip buffering
|
|
248
|
+
return currentState;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
210
252
|
const rendition = await deps.getRendition();
|
|
211
253
|
if (!rendition) {
|
|
212
254
|
// Cannot buffer without a rendition
|
|
@@ -57,8 +57,19 @@ export const makeVideoBufferTask = (host: EFVideo): VideoBufferTask => {
|
|
|
57
57
|
bufferDurationMs,
|
|
58
58
|
maxParallelFetches,
|
|
59
59
|
enableBuffering: host.enableVideoBuffering,
|
|
60
|
+
bufferThresholdMs: engineConfig.bufferThresholdMs,
|
|
60
61
|
};
|
|
61
62
|
|
|
63
|
+
// Timeline context for priority-based buffering
|
|
64
|
+
const timelineContext =
|
|
65
|
+
host.rootTimegroup?.currentTimeMs !== undefined
|
|
66
|
+
? {
|
|
67
|
+
elementStartMs: host.startTimeMs,
|
|
68
|
+
elementEndMs: host.endTimeMs,
|
|
69
|
+
playheadMs: host.rootTimegroup.currentTimeMs,
|
|
70
|
+
}
|
|
71
|
+
: undefined;
|
|
72
|
+
|
|
62
73
|
return manageMediaBuffer<VideoRendition>(
|
|
63
74
|
seekTimeMs,
|
|
64
75
|
currentConfig,
|
|
@@ -91,6 +102,7 @@ export const makeVideoBufferTask = (host: EFVideo): VideoBufferTask => {
|
|
|
91
102
|
},
|
|
92
103
|
logError: console.error,
|
|
93
104
|
},
|
|
105
|
+
timelineContext,
|
|
94
106
|
);
|
|
95
107
|
},
|
|
96
108
|
});
|
|
@@ -256,10 +256,18 @@ export const deepGetElementsWithFrameTasks = (
|
|
|
256
256
|
};
|
|
257
257
|
|
|
258
258
|
let temporalCache: Map<Element, TemporalMixinInterface[]>;
|
|
259
|
+
let temporalCacheResetScheduled = false;
|
|
259
260
|
export const resetTemporalCache = () => {
|
|
260
261
|
temporalCache = new Map();
|
|
261
|
-
if (
|
|
262
|
-
requestAnimationFrame
|
|
262
|
+
if (
|
|
263
|
+
typeof requestAnimationFrame !== "undefined" &&
|
|
264
|
+
!temporalCacheResetScheduled
|
|
265
|
+
) {
|
|
266
|
+
temporalCacheResetScheduled = true;
|
|
267
|
+
requestAnimationFrame(() => {
|
|
268
|
+
temporalCacheResetScheduled = false;
|
|
269
|
+
resetTemporalCache();
|
|
270
|
+
});
|
|
263
271
|
}
|
|
264
272
|
};
|
|
265
273
|
resetTemporalCache();
|
|
@@ -303,10 +311,18 @@ export class OwnCurrentTimeController implements ReactiveController {
|
|
|
303
311
|
type Constructor<T = {}> = new (...args: any[]) => T;
|
|
304
312
|
|
|
305
313
|
let startTimeMsCache = new WeakMap<Element, number>();
|
|
314
|
+
let startTimeMsCacheResetScheduled = false;
|
|
306
315
|
const resetStartTimeMsCache = () => {
|
|
307
316
|
startTimeMsCache = new WeakMap();
|
|
308
|
-
if (
|
|
309
|
-
requestAnimationFrame
|
|
317
|
+
if (
|
|
318
|
+
typeof requestAnimationFrame !== "undefined" &&
|
|
319
|
+
!startTimeMsCacheResetScheduled
|
|
320
|
+
) {
|
|
321
|
+
startTimeMsCacheResetScheduled = true;
|
|
322
|
+
requestAnimationFrame(() => {
|
|
323
|
+
startTimeMsCacheResetScheduled = false;
|
|
324
|
+
resetStartTimeMsCache();
|
|
325
|
+
});
|
|
310
326
|
}
|
|
311
327
|
};
|
|
312
328
|
resetStartTimeMsCache();
|
|
@@ -669,4 +669,202 @@ describe("Dynamic content updates", () => {
|
|
|
669
669
|
assert.equal(media.mediaEngineTaskCount, 1);
|
|
670
670
|
});
|
|
671
671
|
});
|
|
672
|
+
|
|
673
|
+
describe("custom frame tasks", () => {
|
|
674
|
+
test("executes registered callback on frame update", async () => {
|
|
675
|
+
const timegroup = renderTimegroup(
|
|
676
|
+
html`<ef-timegroup mode="fixed" duration="5s"></ef-timegroup>`,
|
|
677
|
+
);
|
|
678
|
+
|
|
679
|
+
let callbackExecuted = false;
|
|
680
|
+
const callback = () => {
|
|
681
|
+
callbackExecuted = true;
|
|
682
|
+
};
|
|
683
|
+
|
|
684
|
+
timegroup.addFrameTask(callback);
|
|
685
|
+
await timegroup.seek(1000);
|
|
686
|
+
|
|
687
|
+
assert.equal(callbackExecuted, true);
|
|
688
|
+
}, 1000);
|
|
689
|
+
|
|
690
|
+
test("callback receives correct timing information", async () => {
|
|
691
|
+
const timegroup = renderTimegroup(
|
|
692
|
+
html`<ef-timegroup mode="fixed" duration="5000ms"></ef-timegroup>`,
|
|
693
|
+
);
|
|
694
|
+
|
|
695
|
+
let receivedInfo: any = null;
|
|
696
|
+
const callback = (info: any) => {
|
|
697
|
+
receivedInfo = info;
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
timegroup.addFrameTask(callback);
|
|
701
|
+
await timegroup.seek(2000);
|
|
702
|
+
|
|
703
|
+
assert.equal(receivedInfo.ownCurrentTimeMs, 2000);
|
|
704
|
+
assert.equal(receivedInfo.currentTimeMs, 2000);
|
|
705
|
+
assert.equal(receivedInfo.durationMs, 5000);
|
|
706
|
+
assert.equal(receivedInfo.percentComplete, 0.4);
|
|
707
|
+
assert.equal(receivedInfo.element, timegroup);
|
|
708
|
+
}, 1000);
|
|
709
|
+
|
|
710
|
+
test("executes multiple callbacks in parallel", async () => {
|
|
711
|
+
const timegroup = renderTimegroup(
|
|
712
|
+
html`<ef-timegroup mode="fixed" duration="5s"></ef-timegroup>`,
|
|
713
|
+
);
|
|
714
|
+
|
|
715
|
+
let callback1Executed = false;
|
|
716
|
+
let callback2Executed = false;
|
|
717
|
+
let callback3Executed = false;
|
|
718
|
+
|
|
719
|
+
timegroup.addFrameTask(() => {
|
|
720
|
+
callback1Executed = true;
|
|
721
|
+
});
|
|
722
|
+
timegroup.addFrameTask(() => {
|
|
723
|
+
callback2Executed = true;
|
|
724
|
+
});
|
|
725
|
+
timegroup.addFrameTask(() => {
|
|
726
|
+
callback3Executed = true;
|
|
727
|
+
});
|
|
728
|
+
|
|
729
|
+
await timegroup.seek(1000);
|
|
730
|
+
|
|
731
|
+
assert.equal(callback1Executed, true);
|
|
732
|
+
assert.equal(callback2Executed, true);
|
|
733
|
+
assert.equal(callback3Executed, true);
|
|
734
|
+
}, 1000);
|
|
735
|
+
|
|
736
|
+
test("async callbacks block frame pipeline", async () => {
|
|
737
|
+
const timegroup = renderTimegroup(
|
|
738
|
+
html`<ef-timegroup mode="fixed" duration="5s"></ef-timegroup>`,
|
|
739
|
+
);
|
|
740
|
+
|
|
741
|
+
let asyncCallbackCompleted = false;
|
|
742
|
+
const executionOrder: string[] = [];
|
|
743
|
+
|
|
744
|
+
const asyncCallback = async () => {
|
|
745
|
+
executionOrder.push("async-start");
|
|
746
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
747
|
+
asyncCallbackCompleted = true;
|
|
748
|
+
executionOrder.push("async-end");
|
|
749
|
+
};
|
|
750
|
+
|
|
751
|
+
timegroup.addFrameTask(asyncCallback);
|
|
752
|
+
|
|
753
|
+
const seekPromise = timegroup.seek(1000);
|
|
754
|
+
executionOrder.push("seek-called");
|
|
755
|
+
|
|
756
|
+
await seekPromise;
|
|
757
|
+
executionOrder.push("seek-complete");
|
|
758
|
+
|
|
759
|
+
assert.equal(asyncCallbackCompleted, true);
|
|
760
|
+
assert.deepEqual(executionOrder, [
|
|
761
|
+
"seek-called",
|
|
762
|
+
"async-start",
|
|
763
|
+
"async-end",
|
|
764
|
+
"seek-complete",
|
|
765
|
+
]);
|
|
766
|
+
}, 1000);
|
|
767
|
+
|
|
768
|
+
test("cleanup function removes callback", async () => {
|
|
769
|
+
const timegroup = renderTimegroup(
|
|
770
|
+
html`<ef-timegroup mode="fixed" duration="5s"></ef-timegroup>`,
|
|
771
|
+
);
|
|
772
|
+
|
|
773
|
+
let callbackExecutionCount = 0;
|
|
774
|
+
const cleanup = timegroup.addFrameTask(() => {
|
|
775
|
+
callbackExecutionCount++;
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
await timegroup.seek(1000);
|
|
779
|
+
assert.equal(callbackExecutionCount, 1);
|
|
780
|
+
|
|
781
|
+
cleanup();
|
|
782
|
+
await timegroup.seek(2000);
|
|
783
|
+
assert.equal(callbackExecutionCount, 1);
|
|
784
|
+
}, 1000);
|
|
785
|
+
|
|
786
|
+
test("removeFrameTask removes callback", async () => {
|
|
787
|
+
const timegroup = renderTimegroup(
|
|
788
|
+
html`<ef-timegroup mode="fixed" duration="5s"></ef-timegroup>`,
|
|
789
|
+
);
|
|
790
|
+
|
|
791
|
+
let callbackExecutionCount = 0;
|
|
792
|
+
const callback = () => {
|
|
793
|
+
callbackExecutionCount++;
|
|
794
|
+
};
|
|
795
|
+
|
|
796
|
+
timegroup.addFrameTask(callback);
|
|
797
|
+
await timegroup.seek(1000);
|
|
798
|
+
assert.equal(callbackExecutionCount, 1);
|
|
799
|
+
|
|
800
|
+
timegroup.removeFrameTask(callback);
|
|
801
|
+
await timegroup.seek(2000);
|
|
802
|
+
assert.equal(callbackExecutionCount, 1);
|
|
803
|
+
}, 1000);
|
|
804
|
+
|
|
805
|
+
test("addFrameTask throws error for non-function", () => {
|
|
806
|
+
const timegroup = renderTimegroup(
|
|
807
|
+
html`<ef-timegroup mode="fixed" duration="5s"></ef-timegroup>`,
|
|
808
|
+
);
|
|
809
|
+
|
|
810
|
+
assert.throws(() => {
|
|
811
|
+
timegroup.addFrameTask("not a function" as any);
|
|
812
|
+
}, "Frame task callback must be a function");
|
|
813
|
+
}, 1000);
|
|
814
|
+
|
|
815
|
+
test("custom frame tasks persist after disconnect and reconnect", async () => {
|
|
816
|
+
const container = document.createElement("div");
|
|
817
|
+
document.body.appendChild(container);
|
|
818
|
+
|
|
819
|
+
const timegroup = document.createElement("ef-timegroup") as EFTimegroup;
|
|
820
|
+
timegroup.setAttribute("mode", "fixed");
|
|
821
|
+
timegroup.setAttribute("duration", "5s");
|
|
822
|
+
container.appendChild(timegroup);
|
|
823
|
+
|
|
824
|
+
let callbackWorkedAfterReconnect = false;
|
|
825
|
+
const callback = () => {
|
|
826
|
+
callbackWorkedAfterReconnect = true;
|
|
827
|
+
};
|
|
828
|
+
|
|
829
|
+
timegroup.addFrameTask(callback);
|
|
830
|
+
|
|
831
|
+
// Disconnect and reconnect
|
|
832
|
+
container.removeChild(timegroup);
|
|
833
|
+
callbackWorkedAfterReconnect = false; // Reset after disconnect
|
|
834
|
+
container.appendChild(timegroup);
|
|
835
|
+
|
|
836
|
+
// Callback should still work after reconnect
|
|
837
|
+
await timegroup.seek(2000);
|
|
838
|
+
assert.equal(
|
|
839
|
+
callbackWorkedAfterReconnect,
|
|
840
|
+
true,
|
|
841
|
+
"Callback should still work after reconnect",
|
|
842
|
+
);
|
|
843
|
+
|
|
844
|
+
container.remove();
|
|
845
|
+
}, 1000);
|
|
846
|
+
|
|
847
|
+
test("sync and async callbacks execute together", async () => {
|
|
848
|
+
const timegroup = renderTimegroup(
|
|
849
|
+
html`<ef-timegroup mode="fixed" duration="5s"></ef-timegroup>`,
|
|
850
|
+
);
|
|
851
|
+
|
|
852
|
+
let syncExecuted = false;
|
|
853
|
+
let asyncExecuted = false;
|
|
854
|
+
|
|
855
|
+
timegroup.addFrameTask(() => {
|
|
856
|
+
syncExecuted = true;
|
|
857
|
+
});
|
|
858
|
+
|
|
859
|
+
timegroup.addFrameTask(async () => {
|
|
860
|
+
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
861
|
+
asyncExecuted = true;
|
|
862
|
+
});
|
|
863
|
+
|
|
864
|
+
await timegroup.seek(1000);
|
|
865
|
+
|
|
866
|
+
assert.equal(syncExecuted, true);
|
|
867
|
+
assert.equal(asyncExecuted, true);
|
|
868
|
+
}, 1000);
|
|
869
|
+
});
|
|
672
870
|
});
|
|
@@ -34,6 +34,15 @@ declare global {
|
|
|
34
34
|
|
|
35
35
|
const log = debug("ef:elements:EFTimegroup");
|
|
36
36
|
|
|
37
|
+
// Custom frame task callback type
|
|
38
|
+
export type FrameTaskCallback = (info: {
|
|
39
|
+
ownCurrentTimeMs: number;
|
|
40
|
+
currentTimeMs: number;
|
|
41
|
+
durationMs: number;
|
|
42
|
+
percentComplete: number;
|
|
43
|
+
element: EFTimegroup;
|
|
44
|
+
}) => void | Promise<void>;
|
|
45
|
+
|
|
37
46
|
// Cache for sequence mode duration calculations to avoid O(n) recalculation
|
|
38
47
|
let sequenceDurationCache: WeakMap<EFTimegroup, number> = new WeakMap();
|
|
39
48
|
|
|
@@ -125,6 +134,7 @@ export class EFTimegroup extends EFTargetable(EFTemporal(TWMixin(LitElement))) {
|
|
|
125
134
|
#seekInProgress = false;
|
|
126
135
|
#pendingSeekTime: number | undefined;
|
|
127
136
|
#processingPendingSeek = false;
|
|
137
|
+
#customFrameTasks: Set<FrameTaskCallback> = new Set();
|
|
128
138
|
|
|
129
139
|
/**
|
|
130
140
|
* Get the effective FPS for this timegroup.
|
|
@@ -276,6 +286,33 @@ export class EFTimegroup extends EFTargetable(EFTemporal(TWMixin(LitElement))) {
|
|
|
276
286
|
return !this.parentTimegroup;
|
|
277
287
|
}
|
|
278
288
|
|
|
289
|
+
/**
|
|
290
|
+
* Register a custom frame task callback that will be executed during frame rendering.
|
|
291
|
+
* The callback receives timing information and can be async or sync.
|
|
292
|
+
* Multiple callbacks can be registered and will execute in parallel.
|
|
293
|
+
*
|
|
294
|
+
* @param callback - Function to execute on each frame
|
|
295
|
+
* @returns A cleanup function that removes the callback when called
|
|
296
|
+
*/
|
|
297
|
+
addFrameTask(callback: FrameTaskCallback): () => void {
|
|
298
|
+
if (typeof callback !== "function") {
|
|
299
|
+
throw new Error("Frame task callback must be a function");
|
|
300
|
+
}
|
|
301
|
+
this.#customFrameTasks.add(callback);
|
|
302
|
+
return () => {
|
|
303
|
+
this.#customFrameTasks.delete(callback);
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Remove a previously registered custom frame task callback.
|
|
309
|
+
*
|
|
310
|
+
* @param callback - The callback function to remove
|
|
311
|
+
*/
|
|
312
|
+
removeFrameTask(callback: FrameTaskCallback): void {
|
|
313
|
+
this.#customFrameTasks.delete(callback);
|
|
314
|
+
}
|
|
315
|
+
|
|
279
316
|
saveTimeToLocalStorage(time: number) {
|
|
280
317
|
try {
|
|
281
318
|
if (this.id && this.isConnected && !Number.isNaN(time)) {
|
|
@@ -759,6 +796,26 @@ export class EFTimegroup extends EFTargetable(EFTemporal(TWMixin(LitElement))) {
|
|
|
759
796
|
undefined,
|
|
760
797
|
async () => {
|
|
761
798
|
await this.waitForFrameTasks();
|
|
799
|
+
|
|
800
|
+
// Execute custom frame tasks
|
|
801
|
+
if (this.#customFrameTasks.size > 0) {
|
|
802
|
+
const percentComplete =
|
|
803
|
+
this.durationMs > 0 ? ownCurrentTimeMs / this.durationMs : 0;
|
|
804
|
+
const frameInfo = {
|
|
805
|
+
ownCurrentTimeMs,
|
|
806
|
+
currentTimeMs,
|
|
807
|
+
durationMs: this.durationMs,
|
|
808
|
+
percentComplete,
|
|
809
|
+
element: this,
|
|
810
|
+
};
|
|
811
|
+
|
|
812
|
+
await Promise.all(
|
|
813
|
+
Array.from(this.#customFrameTasks).map((callback) =>
|
|
814
|
+
Promise.resolve(callback(frameInfo)),
|
|
815
|
+
),
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
|
|
762
819
|
updateAnimations(this);
|
|
763
820
|
},
|
|
764
821
|
);
|