@editframe/elements 0.21.0-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 (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 +72 -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 +162 -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,13 +56,24 @@ 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;
60
79
  }
@@ -63,95 +82,51 @@ export class EFTimegroup extends EFTemporal(LitElement) {
63
82
  @provide({ context: timegroupContext })
64
83
  _timeGroupContext = this;
65
84
 
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";
85
+ @provide({ context: efContext })
86
+ efContext = this;
83
87
 
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
- }
88
+ mode: "fit" | "fixed" | "sequence" | "contain" = "contain";
89
+ overlapMs = 0;
94
90
 
95
- get overlapMs() {
96
- 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);
97
103
  }
98
104
 
99
- private _overlapMs = 0;
100
-
101
105
  @property({ type: String })
102
106
  fit: "none" | "contain" | "cover" = "none";
103
107
 
104
108
  #resizeObserver?: ResizeObserver;
105
109
 
110
+ #currentTime: number | undefined = undefined;
106
111
  #seekInProgress = false;
107
-
108
112
  #pendingSeekTime: number | undefined;
109
-
110
113
  #processingPendingSeek = false;
111
114
 
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
115
  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
- }
116
+ if (this.playbackController) {
117
+ return this.playbackController.runThrottledFrameTask();
149
118
  }
119
+ await this.frameTask.run();
150
120
  }
151
121
 
152
122
  @property({ type: Number, attribute: "currenttime" })
153
123
  set currentTime(time: number) {
154
- 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));
155
130
  if (!this.isRootTimegroup) {
156
131
  return;
157
132
  }
@@ -194,6 +169,9 @@ export class EFTimegroup extends EFTemporal(LitElement) {
194
169
  }
195
170
 
196
171
  get currentTime() {
172
+ if (this.playbackController) {
173
+ return this.playbackController.currentTime;
174
+ }
197
175
  return this.#currentTime ?? 0;
198
176
  }
199
177
 
@@ -205,6 +183,49 @@ export class EFTimegroup extends EFTemporal(LitElement) {
205
183
  return this.currentTime * 1000;
206
184
  }
207
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
+
208
229
  /**
209
230
  * Determines if this is a root timegroup (no parent timegroups)
210
231
  */
@@ -212,10 +233,7 @@ export class EFTimegroup extends EFTemporal(LitElement) {
212
233
  return !this.parentTimegroup;
213
234
  }
214
235
 
215
- /**
216
- * Saves time to localStorage (extracted for reuse)
217
- */
218
- #saveTimeToLocalStorage(time: number) {
236
+ saveTimeToLocalStorage(time: number) {
219
237
  try {
220
238
  if (this.id && this.isConnected && !Number.isNaN(time)) {
221
239
  localStorage.setItem(this.storageKey, time.toString());
@@ -239,7 +257,7 @@ export class EFTimegroup extends EFTemporal(LitElement) {
239
257
  this.requestUpdate();
240
258
  };
241
259
 
242
- maybeLoadTimeFromLocalStorage() {
260
+ loadTimeFromLocalStorage(): number | undefined {
243
261
  if (this.id) {
244
262
  try {
245
263
  const storedValue = localStorage.getItem(this.storageKey);
@@ -251,21 +269,25 @@ export class EFTimegroup extends EFTemporal(LitElement) {
251
269
  log("Failed to load time from localStorage", error);
252
270
  }
253
271
  }
272
+ return undefined;
254
273
  }
255
274
 
256
275
  connectedCallback() {
257
276
  super.connectedCallback();
258
- this.waitForMediaDurations().then(() => {
259
- if (this.id) {
260
- const maybeLoadedTime = this.maybeLoadTimeFromLocalStorage();
261
- if (maybeLoadedTime !== undefined) {
262
- 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
+ }
263
285
  }
264
- }
265
- if (EF_INTERACTIVE && this.seekTask.status === TaskStatus.INITIAL) {
266
- this.seekTask.run();
267
- }
268
- });
286
+ if (EF_INTERACTIVE && this.seekTask.status === TaskStatus.INITIAL) {
287
+ this.seekTask.run();
288
+ }
289
+ });
290
+ }
269
291
 
270
292
  if (this.parentTimegroup) {
271
293
  new TimegroupController(this.parentTimegroup, this);
@@ -278,7 +300,13 @@ export class EFTimegroup extends EFTemporal(LitElement) {
278
300
 
279
301
  #previousDurationMs = 0;
280
302
 
281
- 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
+
282
310
  if (this.#previousDurationMs !== this.durationMs) {
283
311
  this.#previousDurationMs = this.durationMs;
284
312
  this.runThrottledFrameTask();
@@ -549,13 +577,31 @@ export class EFTimegroup extends EFTemporal(LitElement) {
549
577
  /**
550
578
  * Returns true if the timegroup should be wrapped with a workbench.
551
579
  *
552
- * A timegroup should be wrapped with a workbench if it is the root-most timegroup
553
- * 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
554
583
  *
555
- * 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,
556
585
  * it should NOT be wrapped in a workbench.
557
586
  */
558
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
+
559
605
  return (
560
606
  EF_INTERACTIVE &&
561
607
  this.closest("ef-timegroup") === this &&
@@ -588,155 +634,22 @@ export class EFTimegroup extends EFTemporal(LitElement) {
588
634
  );
589
635
  }
590
636
 
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
- );
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);
691
644
  }
692
645
 
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
- );
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);
740
653
  }
741
654
 
742
655
  /**
@@ -780,7 +693,7 @@ export class EFTimegroup extends EFTemporal(LitElement) {
780
693
 
781
694
  await Promise.all(loaderTasks);
782
695
 
783
- efElements.map((el) => {
696
+ efElements.forEach((el) => {
784
697
  if ("productionSrc" in el && el.productionSrc instanceof Function) {
785
698
  el.setAttribute("src", el.productionSrc());
786
699
  }
@@ -815,6 +728,11 @@ export class EFTimegroup extends EFTemporal(LitElement) {
815
728
  args: () => [this.#pendingSeekTime ?? this.#currentTime] as const,
816
729
  onComplete: () => {},
817
730
  task: async ([targetTime]) => {
731
+ if (this.playbackController) {
732
+ await this.playbackController.seekTask.taskComplete;
733
+ return this.currentTime;
734
+ }
735
+
818
736
  if (!this.isRootTimegroup) {
819
737
  return;
820
738
  }
@@ -836,11 +754,11 @@ export class EFTimegroup extends EFTemporal(LitElement) {
836
754
  span.setAttribute("newTime", newTime);
837
755
  }
838
756
  // Apply the clamped time back to currentTime
757
+
839
758
  this.#currentTime = newTime;
840
759
  this.requestUpdate("currentTime");
841
760
  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
761
+ this.saveTimeToLocalStorage(this.#currentTime);
844
762
  this.#seekInProgress = false;
845
763
  return newTime;
846
764
  },