@editframe/elements 0.20.4-beta.0 → 0.23.6-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 (183) hide show
  1. package/dist/DelayedLoadingState.js +0 -27
  2. package/dist/EF_FRAMEGEN.d.ts +5 -3
  3. package/dist/EF_FRAMEGEN.js +49 -11
  4. package/dist/_virtual/_@oxc-project_runtime@0.94.0/helpers/decorate.js +7 -0
  5. package/dist/attachContextRoot.d.ts +1 -0
  6. package/dist/attachContextRoot.js +9 -0
  7. package/dist/elements/ContextProxiesController.d.ts +1 -2
  8. package/dist/elements/EFAudio.js +5 -9
  9. package/dist/elements/EFCaptions.d.ts +1 -3
  10. package/dist/elements/EFCaptions.js +112 -129
  11. package/dist/elements/EFImage.js +6 -7
  12. package/dist/elements/EFMedia/AssetIdMediaEngine.js +2 -5
  13. package/dist/elements/EFMedia/AssetMediaEngine.js +36 -33
  14. package/dist/elements/EFMedia/BaseMediaEngine.js +57 -73
  15. package/dist/elements/EFMedia/BufferedSeekingInput.d.ts +1 -1
  16. package/dist/elements/EFMedia/BufferedSeekingInput.js +134 -78
  17. package/dist/elements/EFMedia/JitMediaEngine.js +9 -19
  18. package/dist/elements/EFMedia/audioTasks/makeAudioBufferTask.js +7 -13
  19. package/dist/elements/EFMedia/audioTasks/makeAudioFrequencyAnalysisTask.js +2 -3
  20. package/dist/elements/EFMedia/audioTasks/makeAudioInitSegmentFetchTask.js +1 -1
  21. package/dist/elements/EFMedia/audioTasks/makeAudioInputTask.js +6 -5
  22. package/dist/elements/EFMedia/audioTasks/makeAudioSeekTask.js +1 -3
  23. package/dist/elements/EFMedia/audioTasks/makeAudioSegmentFetchTask.js +1 -1
  24. package/dist/elements/EFMedia/audioTasks/makeAudioSegmentIdTask.js +1 -1
  25. package/dist/elements/EFMedia/audioTasks/makeAudioTimeDomainAnalysisTask.js +1 -1
  26. package/dist/elements/EFMedia/shared/AudioSpanUtils.js +9 -25
  27. package/dist/elements/EFMedia/shared/BufferUtils.js +2 -17
  28. package/dist/elements/EFMedia/shared/GlobalInputCache.js +0 -24
  29. package/dist/elements/EFMedia/shared/PrecisionUtils.js +0 -21
  30. package/dist/elements/EFMedia/shared/ThumbnailExtractor.js +0 -17
  31. package/dist/elements/EFMedia/tasks/makeMediaEngineTask.js +1 -10
  32. package/dist/elements/EFMedia/videoTasks/MainVideoInputCache.d.ts +29 -0
  33. package/dist/elements/EFMedia/videoTasks/MainVideoInputCache.js +32 -0
  34. package/dist/elements/EFMedia/videoTasks/ScrubInputCache.js +1 -15
  35. package/dist/elements/EFMedia/videoTasks/makeScrubVideoBufferTask.js +1 -7
  36. package/dist/elements/EFMedia/videoTasks/makeScrubVideoInputTask.js +8 -5
  37. package/dist/elements/EFMedia/videoTasks/makeScrubVideoSeekTask.js +12 -13
  38. package/dist/elements/EFMedia/videoTasks/makeScrubVideoSegmentIdTask.js +1 -1
  39. package/dist/elements/EFMedia/videoTasks/makeUnifiedVideoSeekTask.js +134 -70
  40. package/dist/elements/EFMedia/videoTasks/makeVideoBufferTask.js +11 -18
  41. package/dist/elements/EFMedia.d.ts +19 -0
  42. package/dist/elements/EFMedia.js +44 -25
  43. package/dist/elements/EFSourceMixin.js +5 -7
  44. package/dist/elements/EFSurface.js +6 -9
  45. package/dist/elements/EFTemporal.browsertest.d.ts +11 -0
  46. package/dist/elements/EFTemporal.d.ts +10 -0
  47. package/dist/elements/EFTemporal.js +100 -41
  48. package/dist/elements/EFThumbnailStrip.js +23 -73
  49. package/dist/elements/EFTimegroup.browsertest.d.ts +3 -3
  50. package/dist/elements/EFTimegroup.d.ts +35 -14
  51. package/dist/elements/EFTimegroup.js +138 -181
  52. package/dist/elements/EFVideo.d.ts +16 -2
  53. package/dist/elements/EFVideo.js +156 -108
  54. package/dist/elements/EFWaveform.js +23 -40
  55. package/dist/elements/SampleBuffer.js +3 -7
  56. package/dist/elements/TargetController.js +5 -5
  57. package/dist/elements/durationConverter.js +4 -4
  58. package/dist/elements/renderTemporalAudio.d.ts +10 -0
  59. package/dist/elements/renderTemporalAudio.js +35 -0
  60. package/dist/elements/updateAnimations.js +19 -43
  61. package/dist/gui/ContextMixin.d.ts +5 -5
  62. package/dist/gui/ContextMixin.js +167 -162
  63. package/dist/gui/Controllable.browsertest.d.ts +0 -0
  64. package/dist/gui/Controllable.d.ts +15 -0
  65. package/dist/gui/Controllable.js +9 -0
  66. package/dist/gui/EFConfiguration.js +7 -7
  67. package/dist/gui/EFControls.browsertest.d.ts +11 -0
  68. package/dist/gui/EFControls.d.ts +18 -4
  69. package/dist/gui/EFControls.js +70 -28
  70. package/dist/gui/EFDial.browsertest.d.ts +0 -0
  71. package/dist/gui/EFDial.d.ts +18 -0
  72. package/dist/gui/EFDial.js +141 -0
  73. package/dist/gui/EFFilmstrip.browsertest.d.ts +11 -0
  74. package/dist/gui/EFFilmstrip.d.ts +12 -2
  75. package/dist/gui/EFFilmstrip.js +214 -129
  76. package/dist/gui/EFFitScale.js +5 -8
  77. package/dist/gui/EFFocusOverlay.js +4 -4
  78. package/dist/gui/EFPause.browsertest.d.ts +0 -0
  79. package/dist/gui/EFPause.d.ts +23 -0
  80. package/dist/gui/EFPause.js +59 -0
  81. package/dist/gui/EFPlay.browsertest.d.ts +0 -0
  82. package/dist/gui/EFPlay.d.ts +23 -0
  83. package/dist/gui/EFPlay.js +59 -0
  84. package/dist/gui/EFPreview.d.ts +4 -0
  85. package/dist/gui/EFPreview.js +18 -9
  86. package/dist/gui/EFResizableBox.browsertest.d.ts +0 -0
  87. package/dist/gui/EFResizableBox.d.ts +34 -0
  88. package/dist/gui/EFResizableBox.js +547 -0
  89. package/dist/gui/EFScrubber.d.ts +9 -3
  90. package/dist/gui/EFScrubber.js +13 -13
  91. package/dist/gui/EFTimeDisplay.d.ts +7 -1
  92. package/dist/gui/EFTimeDisplay.js +8 -8
  93. package/dist/gui/EFToggleLoop.d.ts +9 -3
  94. package/dist/gui/EFToggleLoop.js +7 -5
  95. package/dist/gui/EFTogglePlay.d.ts +12 -4
  96. package/dist/gui/EFTogglePlay.js +26 -21
  97. package/dist/gui/EFWorkbench.js +5 -5
  98. package/dist/gui/PlaybackController.d.ts +67 -0
  99. package/dist/gui/PlaybackController.js +310 -0
  100. package/dist/gui/TWMixin.js +1 -1
  101. package/dist/gui/TWMixin2.js +1 -1
  102. package/dist/gui/TargetOrContextMixin.d.ts +10 -0
  103. package/dist/gui/TargetOrContextMixin.js +98 -0
  104. package/dist/gui/efContext.d.ts +2 -2
  105. package/dist/index.d.ts +5 -0
  106. package/dist/index.js +5 -1
  107. package/dist/otel/BridgeSpanExporter.d.ts +13 -0
  108. package/dist/otel/BridgeSpanExporter.js +87 -0
  109. package/dist/otel/setupBrowserTracing.d.ts +12 -0
  110. package/dist/otel/setupBrowserTracing.js +32 -0
  111. package/dist/otel/tracingHelpers.d.ts +34 -0
  112. package/dist/otel/tracingHelpers.js +112 -0
  113. package/dist/style.css +1 -1
  114. package/dist/transcoding/cache/RequestDeduplicator.js +0 -21
  115. package/dist/transcoding/cache/URLTokenDeduplicator.js +1 -21
  116. package/dist/transcoding/utils/UrlGenerator.js +2 -19
  117. package/dist/utils/LRUCache.js +6 -53
  118. package/package.json +13 -5
  119. package/src/elements/ContextProxiesController.ts +10 -10
  120. package/src/elements/EFAudio.ts +1 -0
  121. package/src/elements/EFCaptions.browsertest.ts +128 -56
  122. package/src/elements/EFCaptions.ts +60 -34
  123. package/src/elements/EFImage.browsertest.ts +1 -2
  124. package/src/elements/EFMedia/AssetMediaEngine.ts +65 -37
  125. package/src/elements/EFMedia/BaseMediaEngine.ts +110 -52
  126. package/src/elements/EFMedia/BufferedSeekingInput.ts +218 -101
  127. package/src/elements/EFMedia/JitMediaEngine.browsertest.ts +3 -0
  128. package/src/elements/EFMedia/audioTasks/makeAudioInputTask.ts +7 -3
  129. package/src/elements/EFMedia/audioTasks/makeAudioSeekTask.chunkboundary.regression.browsertest.ts +1 -1
  130. package/src/elements/EFMedia/videoTasks/MainVideoInputCache.ts +76 -0
  131. package/src/elements/EFMedia/videoTasks/makeScrubVideoInputTask.ts +16 -10
  132. package/src/elements/EFMedia/videoTasks/makeScrubVideoSeekTask.ts +7 -1
  133. package/src/elements/EFMedia/videoTasks/makeUnifiedVideoSeekTask.ts +222 -116
  134. package/src/elements/EFMedia.browsertest.ts +8 -15
  135. package/src/elements/EFMedia.ts +54 -8
  136. package/src/elements/EFSurface.browsertest.ts +2 -6
  137. package/src/elements/EFSurface.ts +1 -0
  138. package/src/elements/EFTemporal.browsertest.ts +58 -1
  139. package/src/elements/EFTemporal.ts +140 -4
  140. package/src/elements/EFThumbnailStrip.browsertest.ts +2 -8
  141. package/src/elements/EFThumbnailStrip.ts +1 -0
  142. package/src/elements/EFTimegroup.browsertest.ts +16 -15
  143. package/src/elements/EFTimegroup.ts +281 -275
  144. package/src/elements/EFVideo.browsertest.ts +162 -74
  145. package/src/elements/EFVideo.ts +229 -101
  146. package/src/elements/FetchContext.browsertest.ts +7 -2
  147. package/src/elements/TargetController.browsertest.ts +1 -0
  148. package/src/elements/TargetController.ts +1 -0
  149. package/src/elements/renderTemporalAudio.ts +108 -0
  150. package/src/elements/updateAnimations.browsertest.ts +181 -6
  151. package/src/elements/updateAnimations.ts +6 -6
  152. package/src/gui/ContextMixin.browsertest.ts +274 -27
  153. package/src/gui/ContextMixin.ts +230 -175
  154. package/src/gui/Controllable.browsertest.ts +258 -0
  155. package/src/gui/Controllable.ts +41 -0
  156. package/src/gui/EFControls.browsertest.ts +294 -80
  157. package/src/gui/EFControls.ts +139 -28
  158. package/src/gui/EFDial.browsertest.ts +84 -0
  159. package/src/gui/EFDial.ts +172 -0
  160. package/src/gui/EFFilmstrip.browsertest.ts +712 -0
  161. package/src/gui/EFFilmstrip.ts +213 -23
  162. package/src/gui/EFPause.browsertest.ts +202 -0
  163. package/src/gui/EFPause.ts +73 -0
  164. package/src/gui/EFPlay.browsertest.ts +202 -0
  165. package/src/gui/EFPlay.ts +73 -0
  166. package/src/gui/EFPreview.ts +20 -5
  167. package/src/gui/EFResizableBox.browsertest.ts +79 -0
  168. package/src/gui/EFResizableBox.ts +898 -0
  169. package/src/gui/EFScrubber.ts +7 -5
  170. package/src/gui/EFTimeDisplay.browsertest.ts +19 -19
  171. package/src/gui/EFTimeDisplay.ts +3 -1
  172. package/src/gui/EFToggleLoop.ts +6 -5
  173. package/src/gui/EFTogglePlay.ts +30 -23
  174. package/src/gui/PlaybackController.ts +522 -0
  175. package/src/gui/TWMixin.css +3 -0
  176. package/src/gui/TargetOrContextMixin.ts +185 -0
  177. package/src/gui/efContext.ts +2 -2
  178. package/src/otel/BridgeSpanExporter.ts +150 -0
  179. package/src/otel/setupBrowserTracing.ts +73 -0
  180. package/src/otel/tracingHelpers.ts +251 -0
  181. package/test/cache-integration-verification.browsertest.ts +1 -1
  182. package/types.json +1 -1
  183. package/dist/elements/ContextProxiesController.js +0 -69
@@ -5,10 +5,12 @@ import { css, html, LitElement, type PropertyValues } from "lit";
5
5
  import { customElement, property } from "lit/decorators.js";
6
6
 
7
7
  import { EF_INTERACTIVE } from "../EF_INTERACTIVE.js";
8
+ import { EF_RENDERING } from "../EF_RENDERING.js";
8
9
  import { isContextMixin } from "../gui/ContextMixin.js";
9
- import type { AudioSpan } from "../transcoding/types/index.ts";
10
- import { durationConverter } from "./durationConverter.js";
11
- import { deepGetMediaElements } from "./EFMedia.js";
10
+ import { efContext } from "../gui/efContext.js";
11
+ import { TWMixin } from "../gui/TWMixin.js";
12
+ import { isTracingEnabled, withSpan } from "../otel/tracingHelpers.js";
13
+ import { deepGetMediaElements, type EFMedia } from "./EFMedia.js";
12
14
  import {
13
15
  deepGetElementsWithFrameTasks,
14
16
  EFTemporal,
@@ -17,12 +19,19 @@ import {
17
19
  shallowGetTemporalElements,
18
20
  timegroupContext,
19
21
  } from "./EFTemporal.js";
22
+ import { parseTimeToMs } from "./parseTimeToMs.js";
23
+ import { renderTemporalAudio } from "./renderTemporalAudio.js";
24
+ import { EFTargetable } from "./TargetController.js";
20
25
  import { TimegroupController } from "./TimegroupController.js";
21
26
  import {
22
27
  evaluateTemporalStateForAnimation,
23
28
  updateAnimations,
24
29
  } from "./updateAnimations.ts";
25
30
 
31
+ declare global {
32
+ var EF_DEV_WORKBENCH: boolean | undefined;
33
+ }
34
+
26
35
  const log = debug("ef:elements:EFTimegroup");
27
36
 
28
37
  // Cache for sequence mode duration calculations to avoid O(n) recalculation
@@ -47,13 +56,24 @@ export const shallowGetTimegroups = (
47
56
  };
48
57
 
49
58
  @customElement("ef-timegroup")
50
- export class EFTimegroup extends EFTemporal(LitElement) {
59
+ export class EFTimegroup extends EFTargetable(EFTemporal(TWMixin(LitElement))) {
60
+ static get observedAttributes(): string[] {
61
+ // biome-ignore lint/complexity/noThisInStatic: It's okay to use this here
62
+ const parentAttributes = super.observedAttributes || [];
63
+ return [...parentAttributes, "mode", "overlap", "currenttime", "fit"];
64
+ }
65
+
51
66
  static styles = css`
52
67
  :host {
53
68
  display: block;
69
+ position: relative;
70
+ overflow: hidden;
71
+ }
72
+
73
+ ::slotted(ef-timegroup) {
74
+ position: absolute;
54
75
  width: 100%;
55
76
  height: 100%;
56
- position: absolute;
57
77
  top: 0;
58
78
  left: 0;
59
79
  }
@@ -62,95 +82,51 @@ export class EFTimegroup extends EFTemporal(LitElement) {
62
82
  @provide({ context: timegroupContext })
63
83
  _timeGroupContext = this;
64
84
 
65
- #currentTime: number | undefined = undefined;
66
-
67
- @property({
68
- type: String,
69
- attribute: "mode",
70
- })
71
- set mode(value: "fit" | "fixed" | "sequence" | "contain") {
72
- // Invalidate duration cache when mode changes
73
- sequenceDurationCache.delete(this);
74
- this._mode = value;
75
- }
76
-
77
- get mode() {
78
- return this._mode;
79
- }
80
-
81
- private _mode: "fit" | "fixed" | "sequence" | "contain" = "contain";
85
+ @provide({ context: efContext })
86
+ efContext = this;
82
87
 
83
- @property({
84
- type: Number,
85
- converter: durationConverter,
86
- attribute: "overlap",
87
- })
88
- set overlapMs(value: number) {
89
- // Invalidate duration cache when overlap changes
90
- sequenceDurationCache.delete(this);
91
- this._overlapMs = value;
92
- }
88
+ mode: "fit" | "fixed" | "sequence" | "contain" = "contain";
89
+ overlapMs = 0;
93
90
 
94
- get overlapMs() {
95
- return this._overlapMs;
91
+ attributeChangedCallback(
92
+ name: string,
93
+ old: string | null,
94
+ value: string | null,
95
+ ): void {
96
+ if (name === "mode" && value) {
97
+ this.mode = value as typeof this.mode;
98
+ }
99
+ if (name === "overlap" && value) {
100
+ this.overlapMs = parseTimeToMs(value);
101
+ }
102
+ super.attributeChangedCallback(name, old, value);
96
103
  }
97
104
 
98
- private _overlapMs = 0;
99
-
100
105
  @property({ type: String })
101
106
  fit: "none" | "contain" | "cover" = "none";
102
107
 
103
108
  #resizeObserver?: ResizeObserver;
104
109
 
110
+ #currentTime: number | undefined = undefined;
105
111
  #seekInProgress = false;
106
-
107
112
  #pendingSeekTime: number | undefined;
108
-
109
113
  #processingPendingSeek = false;
110
114
 
111
- #frameTaskInProgress = false;
112
-
113
- #pendingFrameTaskRun = false;
114
-
115
- #processingPendingFrameTask = false;
116
-
117
- /**
118
- * Throttles frameTask execution to ensure only one runs at a time while preserving the last request
119
- */
120
115
  private async runThrottledFrameTask(): Promise<void> {
121
- if (this.#frameTaskInProgress) {
122
- this.#pendingFrameTaskRun = true;
123
- // Wait for the current frame task to complete
124
- while (this.#frameTaskInProgress) {
125
- await this.frameTask.taskComplete;
126
- }
127
- return;
128
- }
129
-
130
- this.#frameTaskInProgress = true;
131
-
132
- try {
133
- await this.frameTask.run();
134
- } finally {
135
- this.#frameTaskInProgress = false;
136
-
137
- if (this.#pendingFrameTaskRun && !this.#processingPendingFrameTask) {
138
- this.#pendingFrameTaskRun = false;
139
- this.#processingPendingFrameTask = true;
140
- try {
141
- await this.runThrottledFrameTask();
142
- } finally {
143
- this.#processingPendingFrameTask = false;
144
- }
145
- } else {
146
- this.#pendingFrameTaskRun = false;
147
- }
116
+ if (this.playbackController) {
117
+ return this.playbackController.runThrottledFrameTask();
148
118
  }
119
+ await this.frameTask.run();
149
120
  }
150
121
 
151
122
  @property({ type: Number, attribute: "currenttime" })
152
123
  set currentTime(time: number) {
153
- time = Math.max(0, time);
124
+ if (this.playbackController) {
125
+ this.playbackController.currentTime = time;
126
+ return;
127
+ }
128
+
129
+ time = Math.max(0, Math.min(this.durationMs / 1000, time));
154
130
  if (!this.isRootTimegroup) {
155
131
  return;
156
132
  }
@@ -193,6 +169,9 @@ export class EFTimegroup extends EFTemporal(LitElement) {
193
169
  }
194
170
 
195
171
  get currentTime() {
172
+ if (this.playbackController) {
173
+ return this.playbackController.currentTime;
174
+ }
196
175
  return this.#currentTime ?? 0;
197
176
  }
198
177
 
@@ -204,6 +183,49 @@ export class EFTimegroup extends EFTemporal(LitElement) {
204
183
  return this.currentTime * 1000;
205
184
  }
206
185
 
186
+ /**
187
+ * Seek to a specific time and wait for all frames to be ready.
188
+ * This is the recommended way to seek in tests and programmatic control.
189
+ *
190
+ * @param timeMs - Time in milliseconds to seek to
191
+ * @returns Promise that resolves when the seek is complete and all visible children are ready
192
+ */
193
+ async seek(timeMs: number): Promise<void> {
194
+ this.currentTimeMs = timeMs;
195
+ await this.seekTask.taskComplete;
196
+
197
+ // Handle localStorage when playbackController delegates seek
198
+ if (this.playbackController) {
199
+ this.saveTimeToLocalStorage(this.currentTime);
200
+ }
201
+
202
+ await this.frameTask.taskComplete;
203
+
204
+ // Ensure all visible elements have completed their reactive update cycles AND frame rendering
205
+ // waitForFrameTasks() calls frameTask.run() on children, but this may happen before child
206
+ // elements have processed property changes from requestUpdate(). To ensure frame data is
207
+ // accurate, we wait for updateComplete first, then ensure the frameTask has run with the
208
+ // updated properties. Elements like EFVideo provide waitForFrameReady() for this pattern.
209
+ const temporalElements = deepGetElementsWithFrameTasks(this);
210
+ const visibleElements = temporalElements.filter((element) => {
211
+ const animationState = evaluateTemporalStateForAnimation(element);
212
+ return animationState.isVisible;
213
+ });
214
+
215
+ await Promise.all(
216
+ visibleElements.map(async (element) => {
217
+ if (
218
+ "waitForFrameReady" in element &&
219
+ typeof element.waitForFrameReady === "function"
220
+ ) {
221
+ await (element as any).waitForFrameReady();
222
+ } else {
223
+ await element.updateComplete;
224
+ }
225
+ }),
226
+ );
227
+ }
228
+
207
229
  /**
208
230
  * Determines if this is a root timegroup (no parent timegroups)
209
231
  */
@@ -211,10 +233,7 @@ export class EFTimegroup extends EFTemporal(LitElement) {
211
233
  return !this.parentTimegroup;
212
234
  }
213
235
 
214
- /**
215
- * Saves time to localStorage (extracted for reuse)
216
- */
217
- #saveTimeToLocalStorage(time: number) {
236
+ saveTimeToLocalStorage(time: number) {
218
237
  try {
219
238
  if (this.id && this.isConnected && !Number.isNaN(time)) {
220
239
  localStorage.setItem(this.storageKey, time.toString());
@@ -238,7 +257,7 @@ export class EFTimegroup extends EFTemporal(LitElement) {
238
257
  this.requestUpdate();
239
258
  };
240
259
 
241
- maybeLoadTimeFromLocalStorage() {
260
+ loadTimeFromLocalStorage(): number | undefined {
242
261
  if (this.id) {
243
262
  try {
244
263
  const storedValue = localStorage.getItem(this.storageKey);
@@ -250,21 +269,25 @@ export class EFTimegroup extends EFTemporal(LitElement) {
250
269
  log("Failed to load time from localStorage", error);
251
270
  }
252
271
  }
272
+ return undefined;
253
273
  }
254
274
 
255
275
  connectedCallback() {
256
276
  super.connectedCallback();
257
- this.waitForMediaDurations().then(() => {
258
- if (this.id) {
259
- const maybeLoadedTime = this.maybeLoadTimeFromLocalStorage();
260
- if (maybeLoadedTime !== undefined) {
261
- this.currentTime = maybeLoadedTime;
277
+
278
+ if (!this.playbackController) {
279
+ this.waitForMediaDurations().then(() => {
280
+ if (this.id) {
281
+ const maybeLoadedTime = this.loadTimeFromLocalStorage();
282
+ if (maybeLoadedTime !== undefined) {
283
+ this.currentTime = maybeLoadedTime;
284
+ }
262
285
  }
263
- }
264
- if (EF_INTERACTIVE && this.seekTask.status === TaskStatus.INITIAL) {
265
- this.seekTask.run();
266
- }
267
- });
286
+ if (EF_INTERACTIVE && this.seekTask.status === TaskStatus.INITIAL) {
287
+ this.seekTask.run();
288
+ }
289
+ });
290
+ }
268
291
 
269
292
  if (this.parentTimegroup) {
270
293
  new TimegroupController(this.parentTimegroup, this);
@@ -277,7 +300,13 @@ export class EFTimegroup extends EFTemporal(LitElement) {
277
300
 
278
301
  #previousDurationMs = 0;
279
302
 
280
- protected updated(_changedProperties: PropertyValues): void {
303
+ protected updated(changedProperties: PropertyValues): void {
304
+ super.updated(changedProperties);
305
+
306
+ if (changedProperties.has("mode") || changedProperties.has("overlapMs")) {
307
+ sequenceDurationCache.delete(this);
308
+ }
309
+
281
310
  if (this.#previousDurationMs !== this.durationMs) {
282
311
  this.#previousDurationMs = this.durationMs;
283
312
  this.runThrottledFrameTask();
@@ -422,18 +451,47 @@ export class EFTimegroup extends EFTemporal(LitElement) {
422
451
  }
423
452
 
424
453
  async waitForFrameTasks() {
425
- const temporalElements = deepGetElementsWithFrameTasks(this);
454
+ const result = await withSpan(
455
+ "timegroup.waitForFrameTasks",
456
+ {
457
+ timegroupId: this.id || "unknown",
458
+ mode: this.mode,
459
+ },
460
+ undefined,
461
+ async (span) => {
462
+ const innerStart = performance.now();
463
+
464
+ const temporalElements = deepGetElementsWithFrameTasks(this);
465
+ if (isTracingEnabled()) {
466
+ span.setAttribute("temporalElementsCount", temporalElements.length);
467
+ }
426
468
 
427
- // Filter to only include temporally visible elements for frame processing
428
- // Use animation-friendly visibility to prevent animation jumps at exact boundaries
429
- const visibleElements = temporalElements.filter((element) => {
430
- const animationState = evaluateTemporalStateForAnimation(element);
431
- return animationState.isVisible;
432
- });
469
+ // Filter to only include temporally visible elements for frame processing
470
+ // Use animation-friendly visibility to prevent animation jumps at exact boundaries
471
+ const visibleElements = temporalElements.filter((element) => {
472
+ const animationState = evaluateTemporalStateForAnimation(element);
473
+ return animationState.isVisible;
474
+ });
475
+ if (isTracingEnabled()) {
476
+ span.setAttribute("visibleElementsCount", visibleElements.length);
477
+ }
433
478
 
434
- await Promise.all(
435
- visibleElements.map((element) => element.frameTask.run()),
479
+ const promiseStart = performance.now();
480
+
481
+ await Promise.all(
482
+ visibleElements.map((element) => element.frameTask.run()),
483
+ );
484
+ const promiseEnd = performance.now();
485
+
486
+ const innerEnd = performance.now();
487
+ if (isTracingEnabled()) {
488
+ span.setAttribute("actualInnerMs", innerEnd - innerStart);
489
+ span.setAttribute("promiseAwaitMs", promiseEnd - promiseStart);
490
+ }
491
+ },
436
492
  );
493
+
494
+ return result;
437
495
  }
438
496
 
439
497
  mediaDurationsPromise: Promise<void> | undefined = undefined;
@@ -452,37 +510,53 @@ export class EFTimegroup extends EFTemporal(LitElement) {
452
510
  * in calculations and it was not clear why.
453
511
  */
454
512
  async #waitForMediaDurations() {
455
- // We must await updateComplete to ensure all media elements inside this are connected
456
- // and will match deepGetMediaElements
457
- await this.updateComplete;
458
- const mediaElements = deepGetMediaElements(this);
459
- // Then, we must await the fragmentIndexTask to ensure all media elements have their
460
- // fragment index loaded, which is where their duration is parsed from.
461
- await Promise.all(
462
- mediaElements.map((m) =>
463
- m.mediaEngineTask.value ? Promise.resolve() : m.mediaEngineTask.run(),
464
- ),
465
- );
513
+ return withSpan(
514
+ "timegroup.waitForMediaDurations",
515
+ {
516
+ timegroupId: this.id || "unknown",
517
+ mode: this.mode,
518
+ },
519
+ undefined,
520
+ async (span) => {
521
+ // We must await updateComplete to ensure all media elements inside this are connected
522
+ // and will match deepGetMediaElements
523
+ await this.updateComplete;
524
+ const mediaElements = deepGetMediaElements(this);
525
+ if (isTracingEnabled()) {
526
+ span.setAttribute("mediaElementsCount", mediaElements.length);
527
+ }
528
+
529
+ // Then, we must await the fragmentIndexTask to ensure all media elements have their
530
+ // fragment index loaded, which is where their duration is parsed from.
531
+ await Promise.all(
532
+ mediaElements.map((m) =>
533
+ m.mediaEngineTask.value
534
+ ? Promise.resolve()
535
+ : m.mediaEngineTask.run(),
536
+ ),
537
+ );
466
538
 
467
- // After waiting for durations, we must force some updates to cascade and ensure all temporal elements
468
- // have correct durations and start times. It is not ideal that we have to do this inside here,
469
- // but it is the best current way to ensure that all temporal elements have correct durations and start times.
539
+ // After waiting for durations, we must force some updates to cascade and ensure all temporal elements
540
+ // have correct durations and start times. It is not ideal that we have to do this inside here,
541
+ // but it is the best current way to ensure that all temporal elements have correct durations and start times.
470
542
 
471
- // Next, we must flush the startTimeMs cache to ensure all media elements have their
472
- // startTimeMs parsed fresh, otherwise the startTimeMs is cached per animation frame.
473
- flushStartTimeMsCache();
543
+ // Next, we must flush the startTimeMs cache to ensure all media elements have their
544
+ // startTimeMs parsed fresh, otherwise the startTimeMs is cached per animation frame.
545
+ flushStartTimeMsCache();
474
546
 
475
- // Flush duration cache since child durations may have changed
476
- flushSequenceDurationCache();
547
+ // Flush duration cache since child durations may have changed
548
+ flushSequenceDurationCache();
477
549
 
478
- // Request an update to the currentTime of this group, ensuring that time updates will cascade
479
- // down to children, forcing sequence groups to arrange correctly.
480
- // This also makes the filmstrip update correctly.
481
- this.requestUpdate("currentTime");
482
- // Finally, we must await updateComplete to ensure all temporal elements have their
483
- // currentTime updated and all animations have run.
550
+ // Request an update to the currentTime of this group, ensuring that time updates will cascade
551
+ // down to children, forcing sequence groups to arrange correctly.
552
+ // This also makes the filmstrip update correctly.
553
+ this.requestUpdate("currentTime");
554
+ // Finally, we must await updateComplete to ensure all temporal elements have their
555
+ // currentTime updated and all animations have run.
484
556
 
485
- await this.updateComplete;
557
+ await this.updateComplete;
558
+ },
559
+ );
486
560
  }
487
561
 
488
562
  get childTemporals() {
@@ -503,13 +577,31 @@ export class EFTimegroup extends EFTemporal(LitElement) {
503
577
  /**
504
578
  * Returns true if the timegroup should be wrapped with a workbench.
505
579
  *
506
- * A timegroup should be wrapped with a workbench if it is the root-most timegroup
507
- * and EF_INTERACTIVE is true.
580
+ * A timegroup should be wrapped with a workbench if:
581
+ * - It's being rendered (EF_RENDERING), OR
582
+ * - It's in interactive mode (EF_INTERACTIVE) with the dev workbench flag set
508
583
  *
509
- * If the timegroup is already wrappedin a context provider like ef-preview,
584
+ * If the timegroup is already wrapped in a context provider like ef-preview,
510
585
  * it should NOT be wrapped in a workbench.
511
586
  */
512
587
  shouldWrapWithWorkbench() {
588
+ const isRendering = EF_RENDERING?.() === true;
589
+
590
+ // During rendering, always wrap with workbench (needed by EF_FRAMEGEN)
591
+ if (isRendering) {
592
+ return (
593
+ this.closest("ef-timegroup") === this &&
594
+ this.closest("ef-preview") === null &&
595
+ this.closest("ef-workbench") === null &&
596
+ this.closest("test-context") === null
597
+ );
598
+ }
599
+
600
+ // During interactive mode, respect the dev workbench flag
601
+ if (!globalThis.EF_DEV_WORKBENCH) {
602
+ return false;
603
+ }
604
+
513
605
  return (
514
606
  EF_INTERACTIVE &&
515
607
  this.closest("ef-timegroup") === this &&
@@ -542,138 +634,22 @@ export class EFTimegroup extends EFTemporal(LitElement) {
542
634
  );
543
635
  }
544
636
 
545
- async #addAudioToContext(
546
- audioContext: AudioContext | OfflineAudioContext,
547
- fromMs: number,
548
- toMs: number,
549
- ) {
550
- await this.waitForMediaDurations();
551
-
552
- // Create AbortController for audio fetch operations
553
- const abortController = new AbortController();
554
-
555
- await Promise.all(
556
- deepGetMediaElements(this).map(async (mediaElement) => {
557
- // Skip muted elements entirely - no audio fetching or processing needed
558
- if (mediaElement.mute) {
559
- return;
560
- }
561
-
562
- const mediaStartsBeforeEnd = mediaElement.startTimeMs <= toMs;
563
- const mediaEndsAfterStart = mediaElement.endTimeMs >= fromMs;
564
- const mediaOverlaps = mediaStartsBeforeEnd && mediaEndsAfterStart;
565
- if (!mediaOverlaps) {
566
- return;
567
- }
568
-
569
- // Convert from root timegroup timeline to media element's local timeline
570
- const mediaLocalFromMs = Math.max(0, fromMs - mediaElement.startTimeMs);
571
- const mediaLocalToMs = Math.min(
572
- mediaElement.endTimeMs - mediaElement.startTimeMs,
573
- toMs - mediaElement.startTimeMs,
574
- );
575
-
576
- // Skip if no valid local time range
577
- if (mediaLocalFromMs >= mediaLocalToMs) {
578
- return;
579
- }
580
-
581
- // Convert from local timeline to source media timeline (accounting for sourcein/sourceout)
582
- const sourceInMs =
583
- mediaElement.sourceInMs || mediaElement.trimStartMs || 0;
584
- const mediaSourceFromMs = mediaLocalFromMs + sourceInMs;
585
- const mediaSourceToMs = mediaLocalToMs + sourceInMs;
586
-
587
- let audio: AudioSpan | undefined;
588
- try {
589
- audio = await mediaElement.fetchAudioSpanningTime(
590
- mediaSourceFromMs,
591
- mediaSourceToMs,
592
- abortController.signal,
593
- );
594
- } catch (error) {
595
- if (
596
- error instanceof Error &&
597
- error.message.includes("No audio track available")
598
- ) {
599
- return;
600
- }
601
- throw error;
602
- }
603
-
604
- if (!audio) {
605
- return;
606
- }
607
-
608
- const bufferSource = audioContext.createBufferSource();
609
- bufferSource.buffer = await audioContext.decodeAudioData(
610
- await audio.blob.arrayBuffer(),
611
- );
612
- bufferSource.connect(audioContext.destination);
613
-
614
- // Calculate timing for placing this audio in the output context
615
- const ctxStartMs = Math.max(0, mediaElement.startTimeMs - fromMs);
616
-
617
- // Calculate offset within the fetched audio buffer
618
- // audio.startMs is now in source timeline, convert back to compare properly
619
- const requestedSourceFromMs = mediaSourceFromMs;
620
- const actualSourceStartMs = audio.startMs;
621
- const offsetInBufferMs = requestedSourceFromMs - actualSourceStartMs;
622
-
623
- // Ensure offset is never negative (this would cause audio scheduling errors)
624
- const safeOffsetMs = Math.max(0, offsetInBufferMs);
625
-
626
- // Calculate exact duration to play from the buffer (don't exceed what we need)
627
- const requestedDurationMs = mediaSourceToMs - mediaSourceFromMs;
628
- const availableAudioMs = audio.endMs - audio.startMs;
629
- const actualDurationMs = Math.min(
630
- requestedDurationMs,
631
- availableAudioMs - safeOffsetMs,
632
- );
633
-
634
- if (actualDurationMs <= 0) {
635
- return; // Skip if no valid audio duration
636
- }
637
-
638
- bufferSource.start(
639
- ctxStartMs / 1000, // When to start in output context (seconds)
640
- safeOffsetMs / 1000, // Offset into the fetched buffer (seconds)
641
- actualDurationMs / 1000, // How long to play from buffer (seconds)
642
- );
643
- }),
644
- );
637
+ /**
638
+ * Returns media elements for playback audio rendering
639
+ * For standalone media, returns [this]; for timegroups, returns all descendants
640
+ * Used by PlaybackController for audio-driven playback
641
+ */
642
+ getMediaElements(): EFMedia[] {
643
+ return deepGetMediaElements(this);
645
644
  }
646
645
 
647
- async renderAudio(fromMs: number, toMs: number) {
648
- // Here we determine the number of samples we need to render rather than the duration.
649
- // We cannot tolerate having more or fewer samples than fit exactlly into AAC frames.
650
- const durationMs = toMs - fromMs;
651
- const duration = durationMs / 1000;
652
- const exactSamples = 48000 * duration;
653
- const aacFrames = exactSamples / 1024;
654
- const alignedFrames = Math.round(aacFrames);
655
- const contextSize = alignedFrames * 1024; // AAC-aligned sample count
656
-
657
- // Debug logging for audio duration calculations
658
- if (contextSize <= 0) {
659
- throw new Error(
660
- `Duration must be greater than 0 when rendering audio. ${contextSize}ms`,
661
- );
662
- }
663
-
664
- let audioContext: OfflineAudioContext;
665
- try {
666
- audioContext = new OfflineAudioContext(2, contextSize, 48000);
667
- } catch (error) {
668
- throw new Error(
669
- `[EFTimegroup.renderAudio] Failed to create OfflineAudioContext(2, ${contextSize}, 48000) for renderAudio(${fromMs}, ${toMs}) with contextSize=${contextSize}: ${error instanceof Error ? error.message : String(error)}. This typically happens when audio parameters are invalid (e.g., contextSize <= 0).`,
670
- );
671
- }
672
-
673
- await this.#addAudioToContext(audioContext, fromMs, toMs);
674
- const renderedBuffer = await audioContext.startRendering();
675
-
676
- return renderedBuffer;
646
+ /**
647
+ * Render audio buffer for playback
648
+ * Called by PlaybackController during live playback
649
+ * Delegates to shared renderTemporalAudio utility for consistent behavior
650
+ */
651
+ async renderAudio(fromMs: number, toMs: number): Promise<AudioBuffer> {
652
+ return renderTemporalAudio(this, fromMs, toMs);
677
653
  }
678
654
 
679
655
  /**
@@ -717,7 +693,7 @@ export class EFTimegroup extends EFTemporal(LitElement) {
717
693
 
718
694
  await Promise.all(loaderTasks);
719
695
 
720
- efElements.map((el) => {
696
+ efElements.forEach((el) => {
721
697
  if ("productionSrc" in el && el.productionSrc instanceof Function) {
722
698
  el.setAttribute("src", el.productionSrc());
723
699
  }
@@ -728,10 +704,21 @@ export class EFTimegroup extends EFTemporal(LitElement) {
728
704
  // autoRun: EF_INTERACTIVE,
729
705
  autoRun: false,
730
706
  args: () => [this.ownCurrentTimeMs, this.currentTimeMs] as const,
731
- task: async ([]) => {
707
+ task: async ([ownCurrentTimeMs, currentTimeMs]) => {
732
708
  if (this.isRootTimegroup) {
733
- await this.waitForFrameTasks();
734
- updateAnimations(this);
709
+ await withSpan(
710
+ "timegroup.frameTask",
711
+ {
712
+ timegroupId: this.id || "unknown",
713
+ ownCurrentTimeMs,
714
+ currentTimeMs,
715
+ },
716
+ undefined,
717
+ async () => {
718
+ await this.waitForFrameTasks();
719
+ updateAnimations(this);
720
+ },
721
+ );
735
722
  }
736
723
  },
737
724
  });
@@ -741,22 +728,41 @@ export class EFTimegroup extends EFTemporal(LitElement) {
741
728
  args: () => [this.#pendingSeekTime ?? this.#currentTime] as const,
742
729
  onComplete: () => {},
743
730
  task: async ([targetTime]) => {
731
+ if (this.playbackController) {
732
+ await this.playbackController.seekTask.taskComplete;
733
+ return this.currentTime;
734
+ }
735
+
744
736
  if (!this.isRootTimegroup) {
745
737
  return;
746
738
  }
747
- await this.waitForMediaDurations();
748
- const newTime = Math.max(
749
- 0,
750
- Math.min(targetTime ?? 0, this.durationMs / 1000),
739
+ return withSpan(
740
+ "timegroup.seekTask",
741
+ {
742
+ timegroupId: this.id || "unknown",
743
+ targetTime: targetTime ?? 0,
744
+ durationMs: this.durationMs,
745
+ },
746
+ undefined,
747
+ async (span) => {
748
+ await this.waitForMediaDurations();
749
+ const newTime = Math.max(
750
+ 0,
751
+ Math.min(targetTime ?? 0, this.durationMs / 1000),
752
+ );
753
+ if (isTracingEnabled()) {
754
+ span.setAttribute("newTime", newTime);
755
+ }
756
+ // Apply the clamped time back to currentTime
757
+
758
+ this.#currentTime = newTime;
759
+ this.requestUpdate("currentTime");
760
+ await this.runThrottledFrameTask();
761
+ this.saveTimeToLocalStorage(this.#currentTime);
762
+ this.#seekInProgress = false;
763
+ return newTime;
764
+ },
751
765
  );
752
- // Apply the clamped time back to currentTime
753
- this.#currentTime = newTime;
754
- this.requestUpdate("currentTime");
755
- await this.runThrottledFrameTask();
756
- this.#saveTimeToLocalStorage(this.#currentTime);
757
- // This has to be set false here so any following seeks are not treated as pending
758
- this.#seekInProgress = false;
759
- return newTime;
760
766
  },
761
767
  });
762
768
  }