@editframe/elements 0.21.0-beta.0 → 0.23.7-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 (142) hide show
  1. package/dist/EF_FRAMEGEN.js +2 -3
  2. package/dist/attachContextRoot.d.ts +1 -0
  3. package/dist/attachContextRoot.js +9 -0
  4. package/dist/elements/ContextProxiesController.d.ts +1 -2
  5. package/dist/elements/EFAudio.js +2 -2
  6. package/dist/elements/EFCaptions.d.ts +1 -3
  7. package/dist/elements/EFCaptions.js +59 -51
  8. package/dist/elements/EFImage.js +2 -2
  9. package/dist/elements/EFMedia/AssetIdMediaEngine.js +1 -2
  10. package/dist/elements/EFMedia/AssetMediaEngine.js +1 -3
  11. package/dist/elements/EFMedia/BufferedSeekingInput.d.ts +1 -1
  12. package/dist/elements/EFMedia/BufferedSeekingInput.js +2 -4
  13. package/dist/elements/EFMedia/audioTasks/makeAudioBufferTask.js +4 -7
  14. package/dist/elements/EFMedia/audioTasks/makeAudioFrequencyAnalysisTask.js +1 -2
  15. package/dist/elements/EFMedia/shared/AudioSpanUtils.js +5 -9
  16. package/dist/elements/EFMedia/shared/BufferUtils.js +1 -3
  17. package/dist/elements/EFMedia/videoTasks/makeVideoBufferTask.js +4 -7
  18. package/dist/elements/EFMedia.d.ts +19 -0
  19. package/dist/elements/EFMedia.js +19 -2
  20. package/dist/elements/EFSourceMixin.js +1 -1
  21. package/dist/elements/EFSurface.js +1 -1
  22. package/dist/elements/EFTemporal.browsertest.d.ts +11 -0
  23. package/dist/elements/EFTemporal.d.ts +10 -0
  24. package/dist/elements/EFTemporal.js +82 -5
  25. package/dist/elements/EFThumbnailStrip.js +9 -16
  26. package/dist/elements/EFTimegroup.browsertest.d.ts +3 -3
  27. package/dist/elements/EFTimegroup.d.ts +35 -14
  28. package/dist/elements/EFTimegroup.js +73 -120
  29. package/dist/elements/EFVideo.d.ts +10 -0
  30. package/dist/elements/EFVideo.js +15 -2
  31. package/dist/elements/EFWaveform.js +10 -18
  32. package/dist/elements/SampleBuffer.js +1 -2
  33. package/dist/elements/TargetController.js +2 -2
  34. package/dist/elements/renderTemporalAudio.d.ts +10 -0
  35. package/dist/elements/renderTemporalAudio.js +35 -0
  36. package/dist/elements/updateAnimations.js +7 -10
  37. package/dist/gui/ContextMixin.d.ts +5 -5
  38. package/dist/gui/ContextMixin.js +151 -117
  39. package/dist/gui/Controllable.browsertest.d.ts +0 -0
  40. package/dist/gui/Controllable.d.ts +15 -0
  41. package/dist/gui/Controllable.js +9 -0
  42. package/dist/gui/EFConfiguration.js +1 -1
  43. package/dist/gui/EFControls.browsertest.d.ts +11 -0
  44. package/dist/gui/EFControls.d.ts +18 -4
  45. package/dist/gui/EFControls.js +67 -25
  46. package/dist/gui/EFDial.browsertest.d.ts +0 -0
  47. package/dist/gui/EFDial.d.ts +18 -0
  48. package/dist/gui/EFDial.js +141 -0
  49. package/dist/gui/EFFilmstrip.browsertest.d.ts +11 -0
  50. package/dist/gui/EFFilmstrip.d.ts +12 -2
  51. package/dist/gui/EFFilmstrip.js +140 -34
  52. package/dist/gui/EFFitScale.js +2 -4
  53. package/dist/gui/EFFocusOverlay.js +1 -1
  54. package/dist/gui/EFPause.browsertest.d.ts +0 -0
  55. package/dist/gui/EFPause.d.ts +23 -0
  56. package/dist/gui/EFPause.js +59 -0
  57. package/dist/gui/EFPlay.browsertest.d.ts +0 -0
  58. package/dist/gui/EFPlay.d.ts +23 -0
  59. package/dist/gui/EFPlay.js +59 -0
  60. package/dist/gui/EFPreview.d.ts +4 -0
  61. package/dist/gui/EFPreview.js +15 -6
  62. package/dist/gui/EFResizableBox.browsertest.d.ts +0 -0
  63. package/dist/gui/EFResizableBox.d.ts +34 -0
  64. package/dist/gui/EFResizableBox.js +547 -0
  65. package/dist/gui/EFScrubber.d.ts +9 -3
  66. package/dist/gui/EFScrubber.js +7 -7
  67. package/dist/gui/EFTimeDisplay.d.ts +7 -1
  68. package/dist/gui/EFTimeDisplay.js +5 -5
  69. package/dist/gui/EFToggleLoop.d.ts +9 -3
  70. package/dist/gui/EFToggleLoop.js +6 -4
  71. package/dist/gui/EFTogglePlay.d.ts +12 -4
  72. package/dist/gui/EFTogglePlay.js +24 -19
  73. package/dist/gui/EFWorkbench.js +1 -1
  74. package/dist/gui/PlaybackController.d.ts +67 -0
  75. package/dist/gui/PlaybackController.js +310 -0
  76. package/dist/gui/TWMixin.js +1 -1
  77. package/dist/gui/TargetOrContextMixin.d.ts +10 -0
  78. package/dist/gui/TargetOrContextMixin.js +98 -0
  79. package/dist/gui/efContext.d.ts +2 -2
  80. package/dist/index.d.ts +4 -0
  81. package/dist/index.js +5 -1
  82. package/dist/otel/setupBrowserTracing.d.ts +1 -1
  83. package/dist/otel/setupBrowserTracing.js +6 -4
  84. package/dist/otel/tracingHelpers.js +1 -2
  85. package/dist/style.css +1 -1
  86. package/package.json +5 -5
  87. package/src/elements/ContextProxiesController.ts +10 -10
  88. package/src/elements/EFAudio.ts +1 -0
  89. package/src/elements/EFCaptions.browsertest.ts +128 -58
  90. package/src/elements/EFCaptions.ts +60 -34
  91. package/src/elements/EFImage.browsertest.ts +1 -2
  92. package/src/elements/EFMedia/JitMediaEngine.browsertest.ts +3 -0
  93. package/src/elements/EFMedia/audioTasks/makeAudioSeekTask.chunkboundary.regression.browsertest.ts +1 -1
  94. package/src/elements/EFMedia.browsertest.ts +8 -15
  95. package/src/elements/EFMedia.ts +38 -7
  96. package/src/elements/EFSurface.browsertest.ts +2 -6
  97. package/src/elements/EFSurface.ts +1 -0
  98. package/src/elements/EFTemporal.browsertest.ts +58 -1
  99. package/src/elements/EFTemporal.ts +140 -4
  100. package/src/elements/EFThumbnailStrip.browsertest.ts +2 -8
  101. package/src/elements/EFThumbnailStrip.ts +1 -0
  102. package/src/elements/EFTimegroup.browsertest.ts +6 -7
  103. package/src/elements/EFTimegroup.ts +163 -244
  104. package/src/elements/EFVideo.browsertest.ts +143 -47
  105. package/src/elements/EFVideo.ts +26 -0
  106. package/src/elements/FetchContext.browsertest.ts +7 -2
  107. package/src/elements/TargetController.browsertest.ts +1 -0
  108. package/src/elements/TargetController.ts +1 -0
  109. package/src/elements/renderTemporalAudio.ts +108 -0
  110. package/src/elements/updateAnimations.browsertest.ts +181 -6
  111. package/src/elements/updateAnimations.ts +6 -6
  112. package/src/gui/ContextMixin.browsertest.ts +274 -27
  113. package/src/gui/ContextMixin.ts +230 -175
  114. package/src/gui/Controllable.browsertest.ts +258 -0
  115. package/src/gui/Controllable.ts +41 -0
  116. package/src/gui/EFControls.browsertest.ts +294 -80
  117. package/src/gui/EFControls.ts +139 -28
  118. package/src/gui/EFDial.browsertest.ts +84 -0
  119. package/src/gui/EFDial.ts +172 -0
  120. package/src/gui/EFFilmstrip.browsertest.ts +712 -0
  121. package/src/gui/EFFilmstrip.ts +213 -23
  122. package/src/gui/EFPause.browsertest.ts +202 -0
  123. package/src/gui/EFPause.ts +73 -0
  124. package/src/gui/EFPlay.browsertest.ts +202 -0
  125. package/src/gui/EFPlay.ts +73 -0
  126. package/src/gui/EFPreview.ts +20 -5
  127. package/src/gui/EFResizableBox.browsertest.ts +79 -0
  128. package/src/gui/EFResizableBox.ts +898 -0
  129. package/src/gui/EFScrubber.ts +7 -5
  130. package/src/gui/EFTimeDisplay.browsertest.ts +19 -19
  131. package/src/gui/EFTimeDisplay.ts +3 -1
  132. package/src/gui/EFToggleLoop.ts +6 -5
  133. package/src/gui/EFTogglePlay.ts +30 -23
  134. package/src/gui/PlaybackController.ts +522 -0
  135. package/src/gui/TWMixin.css +3 -0
  136. package/src/gui/TargetOrContextMixin.ts +185 -0
  137. package/src/gui/efContext.ts +2 -2
  138. package/src/otel/setupBrowserTracing.ts +17 -12
  139. package/test/cache-integration-verification.browsertest.ts +1 -1
  140. package/types.json +1 -1
  141. package/dist/elements/ContextProxiesController.js +0 -49
  142. /package/dist/_virtual/{_@oxc-project_runtime@0.93.0 → _@oxc-project_runtime@0.94.0}/helpers/decorate.js +0 -0
@@ -5,11 +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";
10
+ import { efContext } from "../gui/efContext.js";
11
+ import { TWMixin } from "../gui/TWMixin.js";
9
12
  import { isTracingEnabled, withSpan } from "../otel/tracingHelpers.js";
10
- import type { AudioSpan } from "../transcoding/types/index.ts";
11
- import { durationConverter } from "./durationConverter.js";
12
- import { deepGetMediaElements } from "./EFMedia.js";
13
+ import { deepGetMediaElements, type EFMedia } from "./EFMedia.js";
13
14
  import {
14
15
  deepGetElementsWithFrameTasks,
15
16
  EFTemporal,
@@ -18,12 +19,19 @@ import {
18
19
  shallowGetTemporalElements,
19
20
  timegroupContext,
20
21
  } from "./EFTemporal.js";
22
+ import { parseTimeToMs } from "./parseTimeToMs.js";
23
+ import { renderTemporalAudio } from "./renderTemporalAudio.js";
24
+ import { EFTargetable } from "./TargetController.js";
21
25
  import { TimegroupController } from "./TimegroupController.js";
22
26
  import {
23
27
  evaluateTemporalStateForAnimation,
24
28
  updateAnimations,
25
29
  } from "./updateAnimations.ts";
26
30
 
31
+ declare global {
32
+ var EF_DEV_WORKBENCH: boolean | undefined;
33
+ }
34
+
27
35
  const log = debug("ef:elements:EFTimegroup");
28
36
 
29
37
  // Cache for sequence mode duration calculations to avoid O(n) recalculation
@@ -48,110 +56,78 @@ export const shallowGetTimegroups = (
48
56
  };
49
57
 
50
58
  @customElement("ef-timegroup")
51
- 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
+
52
66
  static styles = css`
53
67
  :host {
54
68
  display: block;
69
+ position: relative;
70
+ overflow: hidden;
71
+ }
72
+
73
+ ::slotted(ef-timegroup) {
74
+ position: absolute;
55
75
  width: 100%;
56
76
  height: 100%;
57
- position: absolute;
58
77
  top: 0;
59
78
  left: 0;
79
+ overflow: initial;
60
80
  }
61
81
  `;
62
82
 
63
83
  @provide({ context: timegroupContext })
64
84
  _timeGroupContext = this;
65
85
 
66
- #currentTime: number | undefined = undefined;
67
-
68
- @property({
69
- type: String,
70
- attribute: "mode",
71
- })
72
- set mode(value: "fit" | "fixed" | "sequence" | "contain") {
73
- // Invalidate duration cache when mode changes
74
- sequenceDurationCache.delete(this);
75
- this._mode = value;
76
- }
77
-
78
- get mode() {
79
- return this._mode;
80
- }
81
-
82
- private _mode: "fit" | "fixed" | "sequence" | "contain" = "contain";
86
+ @provide({ context: efContext })
87
+ efContext = this;
83
88
 
84
- @property({
85
- type: Number,
86
- converter: durationConverter,
87
- attribute: "overlap",
88
- })
89
- set overlapMs(value: number) {
90
- // Invalidate duration cache when overlap changes
91
- sequenceDurationCache.delete(this);
92
- this._overlapMs = value;
93
- }
89
+ mode: "fit" | "fixed" | "sequence" | "contain" = "contain";
90
+ overlapMs = 0;
94
91
 
95
- get overlapMs() {
96
- return this._overlapMs;
92
+ attributeChangedCallback(
93
+ name: string,
94
+ old: string | null,
95
+ value: string | null,
96
+ ): void {
97
+ if (name === "mode" && value) {
98
+ this.mode = value as typeof this.mode;
99
+ }
100
+ if (name === "overlap" && value) {
101
+ this.overlapMs = parseTimeToMs(value);
102
+ }
103
+ super.attributeChangedCallback(name, old, value);
97
104
  }
98
105
 
99
- private _overlapMs = 0;
100
-
101
106
  @property({ type: String })
102
107
  fit: "none" | "contain" | "cover" = "none";
103
108
 
104
109
  #resizeObserver?: ResizeObserver;
105
110
 
111
+ #currentTime: number | undefined = undefined;
106
112
  #seekInProgress = false;
107
-
108
113
  #pendingSeekTime: number | undefined;
109
-
110
114
  #processingPendingSeek = false;
111
115
 
112
- #frameTaskInProgress = false;
113
-
114
- #pendingFrameTaskRun = false;
115
-
116
- #processingPendingFrameTask = false;
117
-
118
- /**
119
- * Throttles frameTask execution to ensure only one runs at a time while preserving the last request
120
- */
121
116
  private async runThrottledFrameTask(): Promise<void> {
122
- if (this.#frameTaskInProgress) {
123
- this.#pendingFrameTaskRun = true;
124
- // Wait for the current frame task to complete
125
- while (this.#frameTaskInProgress) {
126
- await this.frameTask.taskComplete;
127
- }
128
- return;
129
- }
130
-
131
- this.#frameTaskInProgress = true;
132
-
133
- try {
134
- await this.frameTask.run();
135
- } finally {
136
- this.#frameTaskInProgress = false;
137
-
138
- if (this.#pendingFrameTaskRun && !this.#processingPendingFrameTask) {
139
- this.#pendingFrameTaskRun = false;
140
- this.#processingPendingFrameTask = true;
141
- try {
142
- await this.runThrottledFrameTask();
143
- } finally {
144
- this.#processingPendingFrameTask = false;
145
- }
146
- } else {
147
- this.#pendingFrameTaskRun = false;
148
- }
117
+ if (this.playbackController) {
118
+ return this.playbackController.runThrottledFrameTask();
149
119
  }
120
+ await this.frameTask.run();
150
121
  }
151
122
 
152
123
  @property({ type: Number, attribute: "currenttime" })
153
124
  set currentTime(time: number) {
154
- time = Math.max(0, time);
125
+ if (this.playbackController) {
126
+ this.playbackController.currentTime = time;
127
+ return;
128
+ }
129
+
130
+ time = Math.max(0, Math.min(this.durationMs / 1000, time));
155
131
  if (!this.isRootTimegroup) {
156
132
  return;
157
133
  }
@@ -194,6 +170,9 @@ export class EFTimegroup extends EFTemporal(LitElement) {
194
170
  }
195
171
 
196
172
  get currentTime() {
173
+ if (this.playbackController) {
174
+ return this.playbackController.currentTime;
175
+ }
197
176
  return this.#currentTime ?? 0;
198
177
  }
199
178
 
@@ -205,6 +184,49 @@ export class EFTimegroup extends EFTemporal(LitElement) {
205
184
  return this.currentTime * 1000;
206
185
  }
207
186
 
187
+ /**
188
+ * Seek to a specific time and wait for all frames to be ready.
189
+ * This is the recommended way to seek in tests and programmatic control.
190
+ *
191
+ * @param timeMs - Time in milliseconds to seek to
192
+ * @returns Promise that resolves when the seek is complete and all visible children are ready
193
+ */
194
+ async seek(timeMs: number): Promise<void> {
195
+ this.currentTimeMs = timeMs;
196
+ await this.seekTask.taskComplete;
197
+
198
+ // Handle localStorage when playbackController delegates seek
199
+ if (this.playbackController) {
200
+ this.saveTimeToLocalStorage(this.currentTime);
201
+ }
202
+
203
+ await this.frameTask.taskComplete;
204
+
205
+ // Ensure all visible elements have completed their reactive update cycles AND frame rendering
206
+ // waitForFrameTasks() calls frameTask.run() on children, but this may happen before child
207
+ // elements have processed property changes from requestUpdate(). To ensure frame data is
208
+ // accurate, we wait for updateComplete first, then ensure the frameTask has run with the
209
+ // updated properties. Elements like EFVideo provide waitForFrameReady() for this pattern.
210
+ const temporalElements = deepGetElementsWithFrameTasks(this);
211
+ const visibleElements = temporalElements.filter((element) => {
212
+ const animationState = evaluateTemporalStateForAnimation(element);
213
+ return animationState.isVisible;
214
+ });
215
+
216
+ await Promise.all(
217
+ visibleElements.map(async (element) => {
218
+ if (
219
+ "waitForFrameReady" in element &&
220
+ typeof element.waitForFrameReady === "function"
221
+ ) {
222
+ await (element as any).waitForFrameReady();
223
+ } else {
224
+ await element.updateComplete;
225
+ }
226
+ }),
227
+ );
228
+ }
229
+
208
230
  /**
209
231
  * Determines if this is a root timegroup (no parent timegroups)
210
232
  */
@@ -212,10 +234,7 @@ export class EFTimegroup extends EFTemporal(LitElement) {
212
234
  return !this.parentTimegroup;
213
235
  }
214
236
 
215
- /**
216
- * Saves time to localStorage (extracted for reuse)
217
- */
218
- #saveTimeToLocalStorage(time: number) {
237
+ saveTimeToLocalStorage(time: number) {
219
238
  try {
220
239
  if (this.id && this.isConnected && !Number.isNaN(time)) {
221
240
  localStorage.setItem(this.storageKey, time.toString());
@@ -239,7 +258,7 @@ export class EFTimegroup extends EFTemporal(LitElement) {
239
258
  this.requestUpdate();
240
259
  };
241
260
 
242
- maybeLoadTimeFromLocalStorage() {
261
+ loadTimeFromLocalStorage(): number | undefined {
243
262
  if (this.id) {
244
263
  try {
245
264
  const storedValue = localStorage.getItem(this.storageKey);
@@ -251,21 +270,25 @@ export class EFTimegroup extends EFTemporal(LitElement) {
251
270
  log("Failed to load time from localStorage", error);
252
271
  }
253
272
  }
273
+ return undefined;
254
274
  }
255
275
 
256
276
  connectedCallback() {
257
277
  super.connectedCallback();
258
- this.waitForMediaDurations().then(() => {
259
- if (this.id) {
260
- const maybeLoadedTime = this.maybeLoadTimeFromLocalStorage();
261
- if (maybeLoadedTime !== undefined) {
262
- this.currentTime = maybeLoadedTime;
278
+
279
+ if (!this.playbackController) {
280
+ this.waitForMediaDurations().then(() => {
281
+ if (this.id) {
282
+ const maybeLoadedTime = this.loadTimeFromLocalStorage();
283
+ if (maybeLoadedTime !== undefined) {
284
+ this.currentTime = maybeLoadedTime;
285
+ }
263
286
  }
264
- }
265
- if (EF_INTERACTIVE && this.seekTask.status === TaskStatus.INITIAL) {
266
- this.seekTask.run();
267
- }
268
- });
287
+ if (EF_INTERACTIVE && this.seekTask.status === TaskStatus.INITIAL) {
288
+ this.seekTask.run();
289
+ }
290
+ });
291
+ }
269
292
 
270
293
  if (this.parentTimegroup) {
271
294
  new TimegroupController(this.parentTimegroup, this);
@@ -278,7 +301,13 @@ export class EFTimegroup extends EFTemporal(LitElement) {
278
301
 
279
302
  #previousDurationMs = 0;
280
303
 
281
- protected updated(_changedProperties: PropertyValues): void {
304
+ protected updated(changedProperties: PropertyValues): void {
305
+ super.updated(changedProperties);
306
+
307
+ if (changedProperties.has("mode") || changedProperties.has("overlapMs")) {
308
+ sequenceDurationCache.delete(this);
309
+ }
310
+
282
311
  if (this.#previousDurationMs !== this.durationMs) {
283
312
  this.#previousDurationMs = this.durationMs;
284
313
  this.runThrottledFrameTask();
@@ -549,13 +578,31 @@ export class EFTimegroup extends EFTemporal(LitElement) {
549
578
  /**
550
579
  * Returns true if the timegroup should be wrapped with a workbench.
551
580
  *
552
- * A timegroup should be wrapped with a workbench if it is the root-most timegroup
553
- * and EF_INTERACTIVE is true.
581
+ * A timegroup should be wrapped with a workbench if:
582
+ * - It's being rendered (EF_RENDERING), OR
583
+ * - It's in interactive mode (EF_INTERACTIVE) with the dev workbench flag set
554
584
  *
555
- * If the timegroup is already wrappedin a context provider like ef-preview,
585
+ * If the timegroup is already wrapped in a context provider like ef-preview,
556
586
  * it should NOT be wrapped in a workbench.
557
587
  */
558
588
  shouldWrapWithWorkbench() {
589
+ const isRendering = EF_RENDERING?.() === true;
590
+
591
+ // During rendering, always wrap with workbench (needed by EF_FRAMEGEN)
592
+ if (isRendering) {
593
+ return (
594
+ this.closest("ef-timegroup") === this &&
595
+ this.closest("ef-preview") === null &&
596
+ this.closest("ef-workbench") === null &&
597
+ this.closest("test-context") === null
598
+ );
599
+ }
600
+
601
+ // During interactive mode, respect the dev workbench flag
602
+ if (!globalThis.EF_DEV_WORKBENCH) {
603
+ return false;
604
+ }
605
+
559
606
  return (
560
607
  EF_INTERACTIVE &&
561
608
  this.closest("ef-timegroup") === this &&
@@ -588,155 +635,22 @@ export class EFTimegroup extends EFTemporal(LitElement) {
588
635
  );
589
636
  }
590
637
 
591
- async #addAudioToContext(
592
- audioContext: AudioContext | OfflineAudioContext,
593
- fromMs: number,
594
- toMs: number,
595
- ) {
596
- await this.waitForMediaDurations();
597
-
598
- // Create AbortController for audio fetch operations
599
- const abortController = new AbortController();
600
-
601
- await Promise.all(
602
- deepGetMediaElements(this).map(async (mediaElement) => {
603
- // Skip muted elements entirely - no audio fetching or processing needed
604
- if (mediaElement.mute) {
605
- return;
606
- }
607
-
608
- const mediaStartsBeforeEnd = mediaElement.startTimeMs <= toMs;
609
- const mediaEndsAfterStart = mediaElement.endTimeMs >= fromMs;
610
- const mediaOverlaps = mediaStartsBeforeEnd && mediaEndsAfterStart;
611
- if (!mediaOverlaps) {
612
- return;
613
- }
614
-
615
- // Convert from root timegroup timeline to media element's local timeline
616
- const mediaLocalFromMs = Math.max(0, fromMs - mediaElement.startTimeMs);
617
- const mediaLocalToMs = Math.min(
618
- mediaElement.endTimeMs - mediaElement.startTimeMs,
619
- toMs - mediaElement.startTimeMs,
620
- );
621
-
622
- // Skip if no valid local time range
623
- if (mediaLocalFromMs >= mediaLocalToMs) {
624
- return;
625
- }
626
-
627
- // Convert from local timeline to source media timeline (accounting for sourcein/sourceout)
628
- const sourceInMs =
629
- mediaElement.sourceInMs || mediaElement.trimStartMs || 0;
630
- const mediaSourceFromMs = mediaLocalFromMs + sourceInMs;
631
- const mediaSourceToMs = mediaLocalToMs + sourceInMs;
632
-
633
- let audio: AudioSpan | undefined;
634
- try {
635
- audio = await mediaElement.fetchAudioSpanningTime(
636
- mediaSourceFromMs,
637
- mediaSourceToMs,
638
- abortController.signal,
639
- );
640
- } catch (error) {
641
- if (
642
- error instanceof Error &&
643
- error.message.includes("No audio track available")
644
- ) {
645
- return;
646
- }
647
- throw error;
648
- }
649
-
650
- if (!audio) {
651
- return;
652
- }
653
-
654
- const bufferSource = audioContext.createBufferSource();
655
- bufferSource.buffer = await audioContext.decodeAudioData(
656
- await audio.blob.arrayBuffer(),
657
- );
658
- bufferSource.connect(audioContext.destination);
659
-
660
- // Calculate timing for placing this audio in the output context
661
- const ctxStartMs = Math.max(0, mediaElement.startTimeMs - fromMs);
662
-
663
- // Calculate offset within the fetched audio buffer
664
- // audio.startMs is now in source timeline, convert back to compare properly
665
- const requestedSourceFromMs = mediaSourceFromMs;
666
- const actualSourceStartMs = audio.startMs;
667
- const offsetInBufferMs = requestedSourceFromMs - actualSourceStartMs;
668
-
669
- // Ensure offset is never negative (this would cause audio scheduling errors)
670
- const safeOffsetMs = Math.max(0, offsetInBufferMs);
671
-
672
- // Calculate exact duration to play from the buffer (don't exceed what we need)
673
- const requestedDurationMs = mediaSourceToMs - mediaSourceFromMs;
674
- const availableAudioMs = audio.endMs - audio.startMs;
675
- const actualDurationMs = Math.min(
676
- requestedDurationMs,
677
- availableAudioMs - safeOffsetMs,
678
- );
679
-
680
- if (actualDurationMs <= 0) {
681
- return; // Skip if no valid audio duration
682
- }
683
-
684
- bufferSource.start(
685
- ctxStartMs / 1000, // When to start in output context (seconds)
686
- safeOffsetMs / 1000, // Offset into the fetched buffer (seconds)
687
- actualDurationMs / 1000, // How long to play from buffer (seconds)
688
- );
689
- }),
690
- );
638
+ /**
639
+ * Returns media elements for playback audio rendering
640
+ * For standalone media, returns [this]; for timegroups, returns all descendants
641
+ * Used by PlaybackController for audio-driven playback
642
+ */
643
+ getMediaElements(): EFMedia[] {
644
+ return deepGetMediaElements(this);
691
645
  }
692
646
 
693
- async renderAudio(fromMs: number, toMs: number) {
694
- return withSpan(
695
- "timegroup.renderAudio",
696
- {
697
- timegroupId: this.id || "unknown",
698
- fromMs,
699
- toMs,
700
- durationMs: toMs - fromMs,
701
- },
702
- undefined,
703
- async (span) => {
704
- // Here we determine the number of samples we need to render rather than the duration.
705
- // We cannot tolerate having more or fewer samples than fit exactlly into AAC frames.
706
- const durationMs = toMs - fromMs;
707
- const duration = durationMs / 1000;
708
- const exactSamples = 48000 * duration;
709
- const aacFrames = exactSamples / 1024;
710
- const alignedFrames = Math.round(aacFrames);
711
- const contextSize = alignedFrames * 1024; // AAC-aligned sample count
712
-
713
- if (isTracingEnabled()) {
714
- span.setAttribute("contextSize", contextSize);
715
- span.setAttribute("alignedFrames", alignedFrames);
716
- }
717
-
718
- // Debug logging for audio duration calculations
719
- if (contextSize <= 0) {
720
- throw new Error(
721
- `Duration must be greater than 0 when rendering audio. ${contextSize}ms`,
722
- );
723
- }
724
-
725
- let audioContext: OfflineAudioContext;
726
- try {
727
- audioContext = new OfflineAudioContext(2, contextSize, 48000);
728
- } catch (error) {
729
- throw new Error(
730
- `[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).`,
731
- );
732
- }
733
-
734
- await this.#addAudioToContext(audioContext, fromMs, toMs);
735
- const renderedBuffer = await audioContext.startRendering();
736
-
737
- return renderedBuffer;
738
- },
739
- );
647
+ /**
648
+ * Render audio buffer for playback
649
+ * Called by PlaybackController during live playback
650
+ * Delegates to shared renderTemporalAudio utility for consistent behavior
651
+ */
652
+ async renderAudio(fromMs: number, toMs: number): Promise<AudioBuffer> {
653
+ return renderTemporalAudio(this, fromMs, toMs);
740
654
  }
741
655
 
742
656
  /**
@@ -780,7 +694,7 @@ export class EFTimegroup extends EFTemporal(LitElement) {
780
694
 
781
695
  await Promise.all(loaderTasks);
782
696
 
783
- efElements.map((el) => {
697
+ efElements.forEach((el) => {
784
698
  if ("productionSrc" in el && el.productionSrc instanceof Function) {
785
699
  el.setAttribute("src", el.productionSrc());
786
700
  }
@@ -815,6 +729,11 @@ export class EFTimegroup extends EFTemporal(LitElement) {
815
729
  args: () => [this.#pendingSeekTime ?? this.#currentTime] as const,
816
730
  onComplete: () => {},
817
731
  task: async ([targetTime]) => {
732
+ if (this.playbackController) {
733
+ await this.playbackController.seekTask.taskComplete;
734
+ return this.currentTime;
735
+ }
736
+
818
737
  if (!this.isRootTimegroup) {
819
738
  return;
820
739
  }
@@ -836,11 +755,11 @@ export class EFTimegroup extends EFTemporal(LitElement) {
836
755
  span.setAttribute("newTime", newTime);
837
756
  }
838
757
  // Apply the clamped time back to currentTime
758
+
839
759
  this.#currentTime = newTime;
840
760
  this.requestUpdate("currentTime");
841
761
  await this.runThrottledFrameTask();
842
- this.#saveTimeToLocalStorage(this.#currentTime);
843
- // This has to be set false here so any following seeks are not treated as pending
762
+ this.saveTimeToLocalStorage(this.#currentTime);
844
763
  this.#seekInProgress = false;
845
764
  return newTime;
846
765
  },