@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.
Files changed (56) hide show
  1. package/dist/elements/EFMedia/AssetMediaEngine.js +2 -1
  2. package/dist/elements/EFMedia/AssetMediaEngine.js.map +1 -1
  3. package/dist/elements/EFMedia/BaseMediaEngine.js +13 -0
  4. package/dist/elements/EFMedia/BaseMediaEngine.js.map +1 -1
  5. package/dist/elements/EFMedia/JitMediaEngine.js +2 -1
  6. package/dist/elements/EFMedia/JitMediaEngine.js.map +1 -1
  7. package/dist/elements/EFMedia/audioTasks/makeAudioBufferTask.js +11 -4
  8. package/dist/elements/EFMedia/audioTasks/makeAudioBufferTask.js.map +1 -1
  9. package/dist/elements/EFMedia/shared/BufferUtils.js +16 -1
  10. package/dist/elements/EFMedia/shared/BufferUtils.js.map +1 -1
  11. package/dist/elements/EFMedia/videoTasks/makeVideoBufferTask.js +11 -4
  12. package/dist/elements/EFMedia/videoTasks/makeVideoBufferTask.js.map +1 -1
  13. package/dist/elements/EFSurface.d.ts +4 -4
  14. package/dist/elements/EFTemporal.js +16 -2
  15. package/dist/elements/EFTemporal.js.map +1 -1
  16. package/dist/elements/EFThumbnailStrip.d.ts +4 -4
  17. package/dist/elements/EFTimegroup.d.ts +22 -0
  18. package/dist/elements/EFTimegroup.js +35 -0
  19. package/dist/elements/EFTimegroup.js.map +1 -1
  20. package/dist/elements/updateAnimations.js +3 -1
  21. package/dist/elements/updateAnimations.js.map +1 -1
  22. package/dist/gui/EFControls.d.ts +2 -2
  23. package/dist/gui/EFDial.d.ts +4 -4
  24. package/dist/gui/EFFocusOverlay.d.ts +4 -4
  25. package/dist/gui/EFPause.d.ts +4 -4
  26. package/dist/gui/EFPlay.d.ts +4 -4
  27. package/dist/gui/EFResizableBox.d.ts +4 -4
  28. package/dist/gui/EFScrubber.d.ts +4 -4
  29. package/dist/gui/EFTimeDisplay.d.ts +4 -4
  30. package/dist/gui/EFToggleLoop.d.ts +4 -4
  31. package/dist/gui/TWMixin.js +1 -1
  32. package/dist/gui/TWMixin.js.map +1 -1
  33. package/dist/index.js +0 -1
  34. package/dist/index.js.map +1 -1
  35. package/dist/style.css +877 -0
  36. package/dist/transcoding/types/index.d.ts +1 -0
  37. package/package.json +30 -8
  38. package/scripts/build-css.js +41 -0
  39. package/src/elements/EFMedia/AssetMediaEngine.ts +1 -0
  40. package/src/elements/EFMedia/BaseMediaEngine.ts +20 -0
  41. package/src/elements/EFMedia/JitMediaEngine.browsertest.ts +68 -0
  42. package/src/elements/EFMedia/JitMediaEngine.ts +1 -0
  43. package/src/elements/EFMedia/audioTasks/makeAudioBufferTask.ts +12 -0
  44. package/src/elements/EFMedia/shared/BufferUtils.ts +42 -0
  45. package/src/elements/EFMedia/videoTasks/makeVideoBufferTask.ts +12 -0
  46. package/src/elements/EFTemporal.ts +20 -4
  47. package/src/elements/EFTimegroup.browsertest.ts +198 -0
  48. package/src/elements/EFTimegroup.ts +57 -0
  49. package/src/elements/updateAnimations.browsertest.ts +801 -0
  50. package/src/elements/updateAnimations.ts +12 -1
  51. package/src/transcoding/types/index.ts +1 -0
  52. package/tsdown.config.ts +24 -3
  53. package/types.json +1 -1
  54. package/dist/elements-ZhsB7B5N.css +0 -9
  55. package/dist/elements-ZhsB7B5N.css.map +0 -1
  56. package/dist/elements.js +0 -0
@@ -74,6 +74,7 @@ interface MediaEngine {
74
74
  audioBufferDurationMs: number;
75
75
  maxVideoBufferFetches: number;
76
76
  maxAudioBufferFetches: number;
77
+ bufferThresholdMs: number;
77
78
  };
78
79
  /**
79
80
  * Extract thumbnail canvases at multiple timestamps efficiently
package/package.json CHANGED
@@ -1,15 +1,11 @@
1
1
  {
2
2
  "name": "@editframe/elements",
3
- "version": "0.25.0-beta.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.25.0-beta.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 (typeof requestAnimationFrame !== "undefined") {
262
- requestAnimationFrame(resetTemporalCache);
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 (typeof requestAnimationFrame !== "undefined") {
309
- requestAnimationFrame(resetStartTimeMsCache);
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
  );