@editframe/elements 0.19.2-beta.0 → 0.20.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 (96) hide show
  1. package/dist/elements/ContextProxiesController.d.ts +40 -0
  2. package/dist/elements/ContextProxiesController.js +69 -0
  3. package/dist/elements/EFCaptions.d.ts +45 -6
  4. package/dist/elements/EFCaptions.js +220 -26
  5. package/dist/elements/EFImage.js +4 -1
  6. package/dist/elements/EFMedia/AssetIdMediaEngine.d.ts +2 -1
  7. package/dist/elements/EFMedia/AssetIdMediaEngine.js +9 -0
  8. package/dist/elements/EFMedia/AssetMediaEngine.d.ts +1 -0
  9. package/dist/elements/EFMedia/AssetMediaEngine.js +11 -0
  10. package/dist/elements/EFMedia/BaseMediaEngine.d.ts +13 -1
  11. package/dist/elements/EFMedia/BaseMediaEngine.js +9 -0
  12. package/dist/elements/EFMedia/JitMediaEngine.d.ts +7 -1
  13. package/dist/elements/EFMedia/JitMediaEngine.js +24 -0
  14. package/dist/elements/EFMedia/shared/GlobalInputCache.d.ts +39 -0
  15. package/dist/elements/EFMedia/shared/GlobalInputCache.js +57 -0
  16. package/dist/elements/EFMedia/shared/ThumbnailExtractor.d.ts +27 -0
  17. package/dist/elements/EFMedia/shared/ThumbnailExtractor.js +106 -0
  18. package/dist/elements/EFMedia.js +25 -1
  19. package/dist/elements/EFSurface.browsertest.d.ts +0 -0
  20. package/dist/elements/EFSurface.d.ts +30 -0
  21. package/dist/elements/EFSurface.js +96 -0
  22. package/dist/elements/EFTemporal.js +7 -6
  23. package/dist/elements/EFThumbnailStrip.browsertest.d.ts +0 -0
  24. package/dist/elements/EFThumbnailStrip.d.ts +86 -0
  25. package/dist/elements/EFThumbnailStrip.js +490 -0
  26. package/dist/elements/EFThumbnailStrip.media-engine.browsertest.d.ts +0 -0
  27. package/dist/elements/EFTimegroup.d.ts +7 -7
  28. package/dist/elements/EFTimegroup.js +59 -16
  29. package/dist/elements/updateAnimations.browsertest.d.ts +13 -0
  30. package/dist/elements/updateAnimations.d.ts +5 -0
  31. package/dist/elements/updateAnimations.js +37 -13
  32. package/dist/getRenderInfo.js +1 -1
  33. package/dist/gui/ContextMixin.js +27 -14
  34. package/dist/gui/EFControls.browsertest.d.ts +0 -0
  35. package/dist/gui/EFControls.d.ts +38 -0
  36. package/dist/gui/EFControls.js +51 -0
  37. package/dist/gui/EFFilmstrip.d.ts +40 -1
  38. package/dist/gui/EFFilmstrip.js +240 -3
  39. package/dist/gui/EFPreview.js +2 -1
  40. package/dist/gui/EFScrubber.d.ts +6 -5
  41. package/dist/gui/EFScrubber.js +31 -21
  42. package/dist/gui/EFTimeDisplay.browsertest.d.ts +0 -0
  43. package/dist/gui/EFTimeDisplay.d.ts +2 -6
  44. package/dist/gui/EFTimeDisplay.js +13 -23
  45. package/dist/gui/TWMixin.js +1 -1
  46. package/dist/gui/currentTimeContext.d.ts +3 -0
  47. package/dist/gui/currentTimeContext.js +3 -0
  48. package/dist/gui/durationContext.d.ts +3 -0
  49. package/dist/gui/durationContext.js +3 -0
  50. package/dist/index.d.ts +3 -0
  51. package/dist/index.js +4 -1
  52. package/dist/style.css +1 -1
  53. package/dist/transcoding/types/index.d.ts +11 -0
  54. package/dist/utils/LRUCache.d.ts +46 -0
  55. package/dist/utils/LRUCache.js +382 -1
  56. package/dist/utils/LRUCache.test.d.ts +1 -0
  57. package/package.json +2 -2
  58. package/src/elements/ContextProxiesController.ts +123 -0
  59. package/src/elements/EFCaptions.browsertest.ts +1820 -0
  60. package/src/elements/EFCaptions.ts +373 -36
  61. package/src/elements/EFImage.ts +4 -1
  62. package/src/elements/EFMedia/AssetIdMediaEngine.ts +30 -1
  63. package/src/elements/EFMedia/AssetMediaEngine.ts +33 -0
  64. package/src/elements/EFMedia/BaseMediaEngine.browsertest.ts +3 -8
  65. package/src/elements/EFMedia/BaseMediaEngine.ts +35 -0
  66. package/src/elements/EFMedia/JitMediaEngine.ts +48 -0
  67. package/src/elements/EFMedia/shared/GlobalInputCache.ts +77 -0
  68. package/src/elements/EFMedia/shared/ThumbnailExtractor.ts +227 -0
  69. package/src/elements/EFMedia.ts +38 -1
  70. package/src/elements/EFSurface.browsertest.ts +155 -0
  71. package/src/elements/EFSurface.ts +141 -0
  72. package/src/elements/EFTemporal.ts +14 -8
  73. package/src/elements/EFThumbnailStrip.browsertest.ts +591 -0
  74. package/src/elements/EFThumbnailStrip.media-engine.browsertest.ts +713 -0
  75. package/src/elements/EFThumbnailStrip.ts +905 -0
  76. package/src/elements/EFTimegroup.browsertest.ts +56 -7
  77. package/src/elements/EFTimegroup.ts +88 -18
  78. package/src/elements/updateAnimations.browsertest.ts +361 -12
  79. package/src/elements/updateAnimations.ts +68 -19
  80. package/src/gui/ContextMixin.browsertest.ts +0 -25
  81. package/src/gui/ContextMixin.ts +44 -20
  82. package/src/gui/EFControls.browsertest.ts +175 -0
  83. package/src/gui/EFControls.ts +84 -0
  84. package/src/gui/EFFilmstrip.ts +323 -4
  85. package/src/gui/EFPreview.ts +2 -1
  86. package/src/gui/EFScrubber.ts +29 -25
  87. package/src/gui/EFTimeDisplay.browsertest.ts +237 -0
  88. package/src/gui/EFTimeDisplay.ts +12 -40
  89. package/src/gui/currentTimeContext.ts +5 -0
  90. package/src/gui/durationContext.ts +3 -0
  91. package/src/transcoding/types/index.ts +13 -0
  92. package/src/utils/LRUCache.test.ts +272 -0
  93. package/src/utils/LRUCache.ts +543 -0
  94. package/types.json +1 -1
  95. package/dist/transcoding/cache/CacheManager.d.ts +0 -73
  96. package/src/transcoding/cache/CacheManager.ts +0 -208
@@ -1,4 +1,7 @@
1
+ import { LitElement } from "lit";
2
+ import { customElement } from "lit/decorators.js";
1
3
  import { assert, beforeEach, describe, test } from "vitest";
4
+ import { EFTemporal } from "./EFTemporal.js";
2
5
  import type { EFTimegroup } from "./EFTimegroup.js";
3
6
  import {
4
7
  type AnimatableElement,
@@ -7,19 +10,31 @@ import {
7
10
 
8
11
  import "./EFTimegroup.js";
9
12
 
10
- // Import the constant for tests that need to check animation precision
13
+ // Create proper temporal test elements
14
+ @customElement("test-temporal-element")
15
+ class TestTemporalElement extends EFTemporal(LitElement) {
16
+ get intrinsicDurationMs() {
17
+ return this._durationMs;
18
+ }
19
+
20
+ private _durationMs = 1000;
21
+ setDuration(duration: number) {
22
+ this._durationMs = duration;
23
+ }
24
+ }
25
+
26
+ declare global {
27
+ interface HTMLElementTagNameMap {
28
+ "test-temporal-element": TestTemporalElement;
29
+ }
30
+ }
11
31
 
12
32
  beforeEach(() => {
13
33
  // Clean up DOM
14
34
  while (document.body.children.length) {
15
35
  document.body.children[0]?.remove();
16
36
  }
17
- // Clean up localStorage
18
- for (let i = 0; i < localStorage.length; i++) {
19
- const key = localStorage.key(i);
20
- if (typeof key !== "string") continue;
21
- localStorage.removeItem(key);
22
- }
37
+ window.localStorage.clear();
23
38
  });
24
39
 
25
40
  function createTestElement(
@@ -51,6 +66,10 @@ function createTestElement(
51
66
  value: props.parentTimegroup,
52
67
  writable: true,
53
68
  });
69
+ Object.defineProperty(element, "ownCurrentTimeMs", {
70
+ value: props.ownCurrentTimeMs ?? 0,
71
+ writable: true,
72
+ });
54
73
  document.body.appendChild(element);
55
74
  return element;
56
75
  }
@@ -214,6 +233,75 @@ describe("Timeline Element Synchronizer", () => {
214
233
  assert.equal(element.style.display, "");
215
234
  });
216
235
 
236
+ test("sequence elements remain coordinated at exact end boundary", () => {
237
+ // Create a root timegroup mock
238
+ const rootTimegroup = {
239
+ currentTimeMs: 3000,
240
+ durationMs: 3000,
241
+ startTimeMs: 0,
242
+ endTimeMs: 3000,
243
+ tagName: "EF-TIMEGROUP",
244
+ } as any;
245
+
246
+ // Create a child element in sequence that spans 2000-3000ms
247
+ const element = createTestElement({
248
+ startTimeMs: 2000,
249
+ endTimeMs: 3000,
250
+ durationMs: 1000,
251
+ ownCurrentTimeMs: 1000, // At exact end of its own duration
252
+ rootTimegroup: rootTimegroup,
253
+ });
254
+
255
+ // Create REAL animations using the Web Animations API
256
+ const animation1 = element.animate([{ opacity: 0 }, { opacity: 1 }], {
257
+ duration: 1000,
258
+ delay: 0,
259
+ iterations: 1,
260
+ });
261
+
262
+ const animation2 = element.animate(
263
+ [{ transform: "scale(1)" }, { transform: "scale(1.5)" }],
264
+ {
265
+ duration: 1000,
266
+ delay: 0,
267
+ iterations: 1,
268
+ },
269
+ );
270
+
271
+ // Start with animations running
272
+ animation1.play();
273
+ animation2.play();
274
+
275
+ // Verify we have real animations
276
+ const animations = element.getAnimations({ subtree: true });
277
+ assert.equal(animations.length, 2, "Should have 2 real animations");
278
+
279
+ updateAnimations(element);
280
+
281
+ // The element should be hidden due to exclusive end condition (3000 > 3000 = false)
282
+ assert.equal(
283
+ element.style.display,
284
+ "none",
285
+ "Element should be hidden at exact end boundary due to exclusive end",
286
+ );
287
+
288
+ // BUT animations should still be coordinated to prevent jarring visual jumps
289
+ // This is the fix we want: animations coordinated even when element is hidden at exact boundary
290
+ animations.forEach((animation, index) => {
291
+ assert.approximately(
292
+ animation.currentTime as number,
293
+ 999,
294
+ 1,
295
+ `Animation ${index + 1} should be coordinated at exact end boundary to prevent visual jumps`,
296
+ );
297
+ assert.equal(
298
+ animation.playState,
299
+ "paused",
300
+ `Animation ${index + 1} should be paused after coordination`,
301
+ );
302
+ });
303
+ });
304
+
217
305
  test("uses element currentTimeMs when no rootTimegroup", () => {
218
306
  const element = createTestElement({
219
307
  currentTimeMs: 500,
@@ -299,8 +387,6 @@ describe("Timeline Element Synchronizer", () => {
299
387
  });
300
388
  animation.play();
301
389
 
302
- assert.equal(animation.playState, "running");
303
-
304
390
  updateAnimations(element);
305
391
 
306
392
  assert.equal(animation.playState, "paused");
@@ -367,9 +453,6 @@ describe("Timeline Element Synchronizer", () => {
367
453
  animation1.play();
368
454
  animation2.play();
369
455
 
370
- assert.equal(animation1.playState, "running");
371
- assert.equal(animation2.playState, "running");
372
-
373
456
  updateAnimations(element);
374
457
 
375
458
  // Both animations should be paused
@@ -415,6 +498,272 @@ describe("Timeline Element Synchronizer", () => {
415
498
 
416
499
  assert.equal(animation.playState, "paused");
417
500
  });
501
+
502
+ test("keeps completed animations available for scrubbing", async () => {
503
+ // Create a timegroup with 10s duration
504
+ const timegroup = document.createElement("ef-timegroup") as EFTimegroup;
505
+ timegroup.setAttribute("mode", "fixed");
506
+ timegroup.setAttribute("duration", "10000ms");
507
+ document.body.appendChild(timegroup);
508
+
509
+ // Create a child element with a 5s animation
510
+ const child = document.createElement("div");
511
+ timegroup.appendChild(child);
512
+
513
+ child.animate([{ opacity: 0 }, { opacity: 1 }], {
514
+ duration: 5000, // 5s animation
515
+ iterations: 1,
516
+ delay: 0,
517
+ });
518
+ timegroup.currentTime = 6;
519
+ await timegroup.seekTask.run();
520
+
521
+ // Animation should still be available even though timeline (6s) > animation duration (5s)
522
+ // This prevents animations from being removed, enabling scrubbing backwards
523
+ const animations = timegroup.getAnimations({ subtree: true });
524
+ assert.equal(
525
+ animations.length,
526
+ 1,
527
+ "REGRESSION TEST: Animation should remain available for scrubbing. This would fail with Number.EPSILON due to insufficient precision offset.",
528
+ );
529
+ });
530
+ });
531
+
532
+ describe("child element animation coordination", () => {
533
+ test("coordinates animations on non-temporal child elements", async () => {
534
+ // Create root timegroup
535
+ const rootTimegroup = document.createElement(
536
+ "ef-timegroup",
537
+ ) as EFTimegroup;
538
+ rootTimegroup.currentTimeMs = 150; // Timeline at 150ms
539
+ document.body.appendChild(rootTimegroup);
540
+
541
+ // Create parent temporal element
542
+ const parentElement = document.createElement(
543
+ "test-temporal-element",
544
+ ) as TestTemporalElement;
545
+ parentElement.setDuration(300); // 300ms duration
546
+ parentElement.setAttribute("offset", "100ms"); // Start at 100ms in root timeline
547
+ rootTimegroup.appendChild(parentElement);
548
+
549
+ // Create a regular NON-temporal HTML element inside the temporal element
550
+ const nonTemporalDiv = document.createElement("div");
551
+ parentElement.appendChild(nonTemporalDiv);
552
+
553
+ // Wait for elements to be connected and updated
554
+ await rootTimegroup.updateComplete;
555
+ await parentElement.updateComplete;
556
+
557
+ // Create animation on the NON-temporal child element
558
+ const nonTemporalAnimation = nonTemporalDiv.animate(
559
+ [{ opacity: 0 }, { opacity: 1 }],
560
+ {
561
+ duration: 1000,
562
+ },
563
+ );
564
+ nonTemporalAnimation.play();
565
+
566
+ // Call updateAnimations on root timegroup
567
+ updateAnimations(rootTimegroup);
568
+
569
+ // Parent should be visible at current timeline position (150ms is between 100ms-400ms)
570
+ assert.notEqual(
571
+ parentElement.style.display,
572
+ "none",
573
+ "Parent should be visible at current timeline time",
574
+ );
575
+
576
+ // FIXED: Non-temporal child animation should be paused and coordinated
577
+ assert.equal(
578
+ nonTemporalAnimation.playState,
579
+ "paused",
580
+ "Non-temporal child element animation should be paused and coordinated with timeline",
581
+ );
582
+ });
583
+
584
+ test("coordinates animations on deeply nested non-temporal elements", async () => {
585
+ // Create root timegroup
586
+ const rootTimegroup = document.createElement(
587
+ "ef-timegroup",
588
+ ) as EFTimegroup;
589
+ rootTimegroup.currentTimeMs = 150; // Timeline at 150ms
590
+ document.body.appendChild(rootTimegroup);
591
+
592
+ // Create parent temporal element
593
+ const parentElement = document.createElement(
594
+ "test-temporal-element",
595
+ ) as TestTemporalElement;
596
+ parentElement.setDuration(300); // 300ms duration
597
+ parentElement.setAttribute("offset", "100ms"); // Start at 100ms in root timeline
598
+ rootTimegroup.appendChild(parentElement);
599
+
600
+ // Create nested non-temporal structure: temporal > div > div > span
601
+ const outerDiv = document.createElement("div");
602
+ const innerDiv = document.createElement("div");
603
+ const span = document.createElement("span");
604
+
605
+ parentElement.appendChild(outerDiv);
606
+ outerDiv.appendChild(innerDiv);
607
+ innerDiv.appendChild(span);
608
+
609
+ // Wait for elements to be connected and updated
610
+ await rootTimegroup.updateComplete;
611
+ await parentElement.updateComplete;
612
+
613
+ // Create animations on different levels of nesting
614
+ const outerAnimation = outerDiv.animate(
615
+ [{ transform: "scale(1)" }, { transform: "scale(1.1)" }],
616
+ {
617
+ duration: 800,
618
+ },
619
+ );
620
+ const innerAnimation = innerDiv.animate(
621
+ [{ opacity: 0.5 }, { opacity: 1 }],
622
+ {
623
+ duration: 1200,
624
+ },
625
+ );
626
+ const spanAnimation = span.animate(
627
+ [{ color: "red" }, { color: "blue" }],
628
+ {
629
+ duration: 600,
630
+ },
631
+ );
632
+
633
+ outerAnimation.play();
634
+ innerAnimation.play();
635
+ spanAnimation.play();
636
+
637
+ // Call updateAnimations on root timegroup
638
+ updateAnimations(rootTimegroup);
639
+
640
+ // All nested non-temporal animations should be coordinated
641
+ assert.equal(
642
+ outerAnimation.playState,
643
+ "paused",
644
+ "Outer div animation should be coordinated",
645
+ );
646
+ assert.equal(
647
+ innerAnimation.playState,
648
+ "paused",
649
+ "Inner div animation should be coordinated",
650
+ );
651
+ assert.equal(
652
+ spanAnimation.playState,
653
+ "paused",
654
+ "Span animation should be coordinated",
655
+ );
656
+ });
657
+
658
+ test("coordinates animations on child temporal elements when they are visible", async () => {
659
+ // Create root timegroup
660
+ const rootTimegroup = document.createElement(
661
+ "ef-timegroup",
662
+ ) as EFTimegroup;
663
+ rootTimegroup.currentTimeMs = 150; // Timeline at 150ms
664
+ document.body.appendChild(rootTimegroup);
665
+
666
+ // Create parent element (timegroup acts as parent)
667
+ const parentTimegroup = document.createElement(
668
+ "ef-timegroup",
669
+ ) as EFTimegroup;
670
+ parentTimegroup.setAttribute("duration", "1000ms");
671
+ rootTimegroup.appendChild(parentTimegroup);
672
+
673
+ // Create child temporal element that WILL be visible at timeline time 150ms
674
+ const childElement = document.createElement(
675
+ "test-temporal-element",
676
+ ) as TestTemporalElement;
677
+ childElement.setDuration(300); // 300ms duration (from 100ms to 400ms in root timeline)
678
+ childElement.setAttribute("offset", "100ms"); // Start at 100ms in root timeline
679
+ parentTimegroup.appendChild(childElement);
680
+
681
+ // Wait for elements to be connected and updated
682
+ await rootTimegroup.updateComplete;
683
+ await parentTimegroup.updateComplete;
684
+ await childElement.updateComplete;
685
+
686
+ // Create animation on child element
687
+ const childAnimation = childElement.animate(
688
+ [{ opacity: 0 }, { opacity: 1 }],
689
+ {
690
+ duration: 1000,
691
+ },
692
+ );
693
+ childAnimation.play();
694
+
695
+ // Call updateAnimations on parent timegroup - this should coordinate child animations too
696
+ updateAnimations(parentTimegroup);
697
+
698
+ // Child should be visible at current timeline position (150ms is between 100ms-400ms)
699
+ assert.notEqual(
700
+ childElement.style.display,
701
+ "none",
702
+ "Child should be visible at current timeline time",
703
+ );
704
+
705
+ // FIXED: Child animation should be paused and coordinated
706
+ assert.equal(
707
+ childAnimation.playState,
708
+ "paused",
709
+ "Child element animation should be paused and coordinated with timeline",
710
+ );
711
+ });
712
+
713
+ test("does not coordinate animations on child temporal elements when they are not visible", async () => {
714
+ // Create root timegroup
715
+ const rootTimegroup = document.createElement(
716
+ "ef-timegroup",
717
+ ) as EFTimegroup;
718
+ rootTimegroup.currentTimeMs = 100; // Timeline at 100ms
719
+ document.body.appendChild(rootTimegroup);
720
+
721
+ // Create parent element (timegroup acts as parent)
722
+ const parentTimegroup = document.createElement(
723
+ "ef-timegroup",
724
+ ) as EFTimegroup;
725
+ parentTimegroup.setAttribute("duration", "1000ms");
726
+ rootTimegroup.appendChild(parentTimegroup);
727
+
728
+ // Create child temporal element that will NOT be visible at timeline time 100ms
729
+ const childElement = document.createElement(
730
+ "test-temporal-element",
731
+ ) as TestTemporalElement;
732
+ childElement.setDuration(200); // 200ms duration
733
+ childElement.setAttribute("offset", "500ms"); // Start at 500ms in root timeline (way after current time)
734
+ parentTimegroup.appendChild(childElement);
735
+
736
+ // Wait for elements to be connected and updated
737
+ await rootTimegroup.updateComplete;
738
+ await parentTimegroup.updateComplete;
739
+ await childElement.updateComplete;
740
+
741
+ // Create animation on child element
742
+ const childAnimation = childElement.animate(
743
+ [{ opacity: 0 }, { opacity: 1 }],
744
+ {
745
+ duration: 1000,
746
+ },
747
+ );
748
+ childAnimation.play();
749
+
750
+ // Call updateAnimations on parent timegroup
751
+ updateAnimations(parentTimegroup);
752
+
753
+ // Child should be hidden (display: none)
754
+ assert.equal(
755
+ childElement.style.display,
756
+ "none",
757
+ "Child should be hidden when not in visible time range",
758
+ );
759
+
760
+ // Child animation should still be running (not coordinated since child is not visible)
761
+ assert.equal(
762
+ childAnimation.playState,
763
+ "paused",
764
+ "Child animation should remain running when child element is not visible",
765
+ );
766
+ });
418
767
  });
419
768
 
420
769
  describe("edge cases", () => {
@@ -1,6 +1,5 @@
1
1
  import {
2
2
  deepGetTemporalElements,
3
- isEFTemporal,
4
3
  type TemporalMixinInterface,
5
4
  } from "./EFTemporal.ts";
6
5
 
@@ -8,7 +7,7 @@ import {
8
7
  export type AnimatableElement = TemporalMixinInterface & HTMLElement;
9
8
 
10
9
  // Constants
11
- const ANIMATION_PRECISION_OFFSET = Number.EPSILON;
10
+ const ANIMATION_PRECISION_OFFSET = 0.1; // Use 0.1ms to safely avoid completion threshold
12
11
  const DEFAULT_ANIMATION_ITERATIONS = 1;
13
12
  const PROGRESS_PROPERTY = "--ef-progress";
14
13
  const DURATION_PROPERTY = "--ef-duration";
@@ -39,8 +38,40 @@ export const evaluateTemporalState = (
39
38
  ? 1
40
39
  : Math.max(0, Math.min(1, element.currentTimeMs / element.durationMs));
41
40
 
41
+ // Root timegroups should remain visible at exact end time, but other elements use exclusive end for clean transitions
42
+ const isRootTimegroup =
43
+ element.tagName.toLowerCase() === TIMEGROUP_TAGNAME &&
44
+ !(element as any).parentTimegroup;
45
+ const useInclusiveEnd = isRootTimegroup;
46
+
42
47
  const isVisible =
43
- element.startTimeMs <= timelineTimeMs && element.endTimeMs > timelineTimeMs;
48
+ element.startTimeMs <= timelineTimeMs &&
49
+ (useInclusiveEnd
50
+ ? element.endTimeMs >= timelineTimeMs
51
+ : element.endTimeMs > timelineTimeMs);
52
+
53
+ return { progress, isVisible, timelineTimeMs };
54
+ };
55
+
56
+ /**
57
+ * Evaluates element visibility specifically for animation coordination
58
+ * Uses inclusive end boundaries to prevent animation jumps at exact boundaries
59
+ */
60
+ export const evaluateTemporalStateForAnimation = (
61
+ element: AnimatableElement,
62
+ ): TemporalState => {
63
+ // Get timeline time from root timegroup, or use element's own time if it IS a timegroup
64
+ const timelineTimeMs = (element.rootTimegroup ?? element).currentTimeMs;
65
+
66
+ const progress =
67
+ element.durationMs <= 0
68
+ ? 1
69
+ : Math.max(0, Math.min(1, element.currentTimeMs / element.durationMs));
70
+
71
+ // For animation coordination, use inclusive end for ALL elements to prevent visual jumps
72
+ const isVisible =
73
+ element.startTimeMs <= timelineTimeMs &&
74
+ element.endTimeMs >= timelineTimeMs;
44
75
 
45
76
  return { progress, isVisible, timelineTimeMs };
46
77
  };
@@ -80,9 +111,11 @@ const updateVisualState = (
80
111
  };
81
112
 
82
113
  /**
83
- * Coordinates animations to match timeline
114
+ * Coordinates animations for a single element and its subtree, using the element as the time source
84
115
  */
85
- const coordinateAnimations = (element: AnimatableElement): void => {
116
+ const coordinateAnimationsForSingleElement = (
117
+ element: AnimatableElement,
118
+ ): void => {
86
119
  const animations = element.getAnimations({ subtree: true });
87
120
 
88
121
  for (const animation of animations) {
@@ -96,17 +129,12 @@ const coordinateAnimations = (element: AnimatableElement): void => {
96
129
  }
97
130
 
98
131
  const target = effect.target;
99
- if (!target || target.closest(TIMEGROUP_TAGNAME) !== element) {
100
- continue;
101
- }
102
-
103
- const timeTarget = isEFTemporal(target)
104
- ? target
105
- : target.closest(TIMEGROUP_TAGNAME);
106
- if (!timeTarget) {
132
+ if (!target) {
107
133
  continue;
108
134
  }
109
135
 
136
+ // For animations in this element's subtree, always use this element as the time source
137
+ // This handles both animations directly on the temporal element and on its non-temporal children
110
138
  const timing = effect.getTiming();
111
139
  const duration = Number(timing.duration) || 0;
112
140
  const delay = Number(timing.delay) || 0;
@@ -118,8 +146,8 @@ const coordinateAnimations = (element: AnimatableElement): void => {
118
146
  continue;
119
147
  }
120
148
 
121
- // All timegroups are temporal, so always use ownCurrentTimeMs for local time coordination
122
- const currentTime = timeTarget.ownCurrentTimeMs ?? 0;
149
+ // Use the element itself as the time source (it's guaranteed to be temporal)
150
+ const currentTime = element.ownCurrentTimeMs ?? 0;
123
151
 
124
152
  if (currentTime < delay) {
125
153
  animation.currentTime = 0;
@@ -130,12 +158,22 @@ const coordinateAnimations = (element: AnimatableElement): void => {
130
158
  const currentIteration = Math.floor(adjustedTime / duration);
131
159
  const currentIterationTime = adjustedTime % duration;
132
160
 
161
+ // Calculate the total animation timeline length (delay + duration * iterations)
162
+ const totalAnimationLength = delay + duration * iterations;
163
+
164
+ // CRITICAL: Always keep currentTime below totalAnimationLength to prevent completion
165
+ const maxSafeCurrentTime =
166
+ totalAnimationLength - ANIMATION_PRECISION_OFFSET;
167
+
133
168
  if (currentIteration >= iterations) {
134
- animation.currentTime = duration - ANIMATION_PRECISION_OFFSET;
169
+ // Animation would be complete - clamp to just before completion
170
+ animation.currentTime = maxSafeCurrentTime;
135
171
  } else {
136
- animation.currentTime =
172
+ // Animation in progress - clamp to safe value within current iteration
173
+ const proposedCurrentTime =
137
174
  Math.min(currentIterationTime, duration - ANIMATION_PRECISION_OFFSET) +
138
175
  delay;
176
+ animation.currentTime = Math.min(proposedCurrentTime, maxSafeCurrentTime);
139
177
  }
140
178
  }
141
179
  };
@@ -151,7 +189,18 @@ export const updateAnimations = (element: AnimatableElement): void => {
151
189
  });
152
190
  updateVisualState(element, temporalState);
153
191
 
154
- if (temporalState.isVisible) {
155
- coordinateAnimations(element);
192
+ // Coordinate animations - use animation-specific visibility to prevent jumps at exact boundaries
193
+ const animationState = evaluateTemporalStateForAnimation(element);
194
+ if (animationState.isVisible) {
195
+ coordinateAnimationsForSingleElement(element);
156
196
  }
197
+
198
+ // Coordinate animations for child elements using animation-specific visibility
199
+ deepGetTemporalElements(element).forEach((temporalElement) => {
200
+ const childAnimationState =
201
+ evaluateTemporalStateForAnimation(temporalElement);
202
+ if (childAnimationState.isVisible) {
203
+ coordinateAnimationsForSingleElement(temporalElement);
204
+ }
205
+ });
157
206
  };
@@ -4,7 +4,6 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
4
4
 
5
5
  import { ContextMixin } from "./ContextMixin.js";
6
6
 
7
- // Required to test timeupdate event, we need a duration, and timegroups are a quick way to do that
8
7
  import "../elements/EFTimegroup.js";
9
8
 
10
9
  @customElement("test-context")
@@ -529,30 +528,6 @@ describe("ContextMixin", () => {
529
528
  });
530
529
  });
531
530
 
532
- test("Time update event when the currentTimeMs changed", async () => {
533
- const timegroup = document.createElement("ef-timegroup");
534
- timegroup.mode = "fixed";
535
- timegroup.duration = "10s";
536
-
537
- const preview = document.createElement("test-context");
538
- preview.append(timegroup);
539
- document.body.append(preview);
540
-
541
- type CurrentTimeEvent = CustomEvent<{ currentTimeMs: number }>;
542
-
543
- // Expect the timeupdate event to be dispatched
544
- const timeupdatePromise = new Promise<CurrentTimeEvent>((resolve) => {
545
- preview.addEventListener(
546
- "timeupdate",
547
- (event: Event) => resolve(event as CurrentTimeEvent),
548
- { once: true },
549
- );
550
- });
551
- preview.currentTimeMs = 1000;
552
- const event = await timeupdatePromise;
553
- expect(event.detail.currentTimeMs).toBe(1000);
554
- });
555
-
556
531
  describe("Reactivity", () => {
557
532
  test("should update durationMs when child tree changes", async () => {
558
533
  const element = document.createElement("test-context-reactivity");