@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
@@ -8,6 +8,7 @@ import {
8
8
  MP4,
9
9
  VideoSampleSink,
10
10
  } from "mediabunny";
11
+ import { withSpan } from "../../otel/tracingHelpers.js";
11
12
  import { type MediaSample, SampleBuffer } from "../SampleBuffer";
12
13
  import { roundToMilliseconds } from "./shared/PrecisionUtils";
13
14
 
@@ -177,26 +178,39 @@ export class BufferedSeekingInput {
177
178
  }
178
179
 
179
180
  async seek(trackId: number, timeMs: number) {
180
- // Apply timeline offset to map user timeline to media timeline
181
- const mediaTimeMs = timeMs + this.startTimeOffsetMs;
182
-
183
- // Round using consistent precision handling
184
- const roundedMediaTimeMs = roundToMilliseconds(mediaTimeMs);
185
-
186
- // Serialize seek operations per track (but don't block iterator creation)
187
- const existingSeek = this.trackSeekPromises.get(trackId);
188
- if (existingSeek) {
189
- await existingSeek;
190
- }
181
+ return withSpan(
182
+ "bufferedInput.seek",
183
+ {
184
+ trackId,
185
+ timeMs,
186
+ startTimeOffsetMs: this.startTimeOffsetMs,
187
+ },
188
+ undefined,
189
+ async (span) => {
190
+ // Apply timeline offset to map user timeline to media timeline
191
+ const mediaTimeMs = timeMs + this.startTimeOffsetMs;
192
+
193
+ // Round using consistent precision handling
194
+ const roundedMediaTimeMs = roundToMilliseconds(mediaTimeMs);
195
+ span.setAttribute("roundedMediaTimeMs", roundedMediaTimeMs);
196
+
197
+ // Serialize seek operations per track (but don't block iterator creation)
198
+ const existingSeek = this.trackSeekPromises.get(trackId);
199
+ if (existingSeek) {
200
+ span.setAttribute("waitedForExistingSeek", true);
201
+ await existingSeek;
202
+ }
191
203
 
192
- const seekPromise = this.seekSafe(trackId, roundedMediaTimeMs);
193
- this.trackSeekPromises.set(trackId, seekPromise);
204
+ const seekPromise = this.seekSafe(trackId, roundedMediaTimeMs);
205
+ this.trackSeekPromises.set(trackId, seekPromise);
194
206
 
195
- try {
196
- return await seekPromise;
197
- } finally {
198
- this.trackSeekPromises.delete(trackId);
199
- }
207
+ try {
208
+ return await seekPromise;
209
+ } finally {
210
+ this.trackSeekPromises.delete(trackId);
211
+ }
212
+ },
213
+ );
200
214
  }
201
215
 
202
216
  private async resetIterator(track: InputTrack) {
@@ -224,90 +238,193 @@ export class BufferedSeekingInput {
224
238
  #seekLock?: PromiseWithResolvers<void>;
225
239
 
226
240
  private async seekSafe(trackId: number, timeMs: number) {
227
- if (this.#seekLock) {
228
- await this.#seekLock.promise;
229
- }
230
- const seekLock = Promise.withResolvers<void>();
231
- this.#seekLock = seekLock;
232
-
233
- try {
234
- const track = await this.getTrack(trackId);
235
- const trackBuffer = this.getTrackBuffer(track);
236
-
237
- const roundedTimeMs = roundToMilliseconds(timeMs);
238
- const firstTimestampMs = roundToMilliseconds(
239
- (await track.getFirstTimestamp()) * 1000,
240
- );
241
-
242
- if (roundedTimeMs < firstTimestampMs) {
243
- console.error("Seeking outside bounds of input", {
244
- roundedTimeMs,
245
- firstTimestampMs,
246
- });
247
- throw new NoSample(
248
- `Seeking outside bounds of input ${roundedTimeMs} < ${firstTimestampMs}`,
249
- );
250
- }
251
-
252
- // Check if we need to reset iterator for seeks outside current buffer range
253
- const bufferContents = trackBuffer.getContents();
254
- if (bufferContents.length > 0) {
255
- const bufferStartMs = roundToMilliseconds(
256
- trackBuffer.firstTimestamp * 1000,
257
- );
258
-
259
- if (roundedTimeMs < bufferStartMs) {
260
- await this.resetIterator(track);
261
- }
262
- }
263
-
264
- const alreadyInBuffer = trackBuffer.find(timeMs);
265
- if (alreadyInBuffer) return alreadyInBuffer;
266
-
267
- const iterator = this.getTrackIterator(track);
268
- while (true) {
269
- const { done, value: decodedSample } = await iterator.next();
270
-
271
- if (decodedSample) {
272
- trackBuffer.push(decodedSample);
273
- }
274
- const foundSample = trackBuffer.find(roundedTimeMs);
275
- if (foundSample) {
276
- return foundSample;
277
- }
278
- if (done) {
279
- break;
241
+ return withSpan(
242
+ "bufferedInput.seekSafe",
243
+ {
244
+ trackId,
245
+ timeMs,
246
+ },
247
+ undefined,
248
+ async (span) => {
249
+ if (this.#seekLock) {
250
+ span.setAttribute("waitedForSeekLock", true);
251
+ await this.#seekLock.promise;
280
252
  }
281
- }
282
-
283
- // Check if we're seeking to the exact end of the track (legitimate use case)
284
- const finalBufferContents = trackBuffer.getContents();
285
- if (finalBufferContents.length > 0) {
286
- const lastSample = finalBufferContents[finalBufferContents.length - 1];
287
- const lastSampleEndMs = roundToMilliseconds(
288
- ((lastSample?.timestamp || 0) + (lastSample?.duration || 0)) * 1000,
289
- );
290
-
291
- // Only return last sample if seeking to exactly the track duration
292
- // (end of video) AND we have the final segment loaded
293
- const trackDurationMs = (await track.computeDuration()) * 1000;
294
- const isSeekingToTrackEnd =
295
- roundToMilliseconds(timeMs) === roundToMilliseconds(trackDurationMs);
296
- const isAtEndOfTrack = roundToMilliseconds(timeMs) >= lastSampleEndMs;
297
-
298
- if (isSeekingToTrackEnd && isAtEndOfTrack) {
299
- return lastSample;
253
+ const seekLock = Promise.withResolvers<void>();
254
+ this.#seekLock = seekLock;
255
+
256
+ try {
257
+ const track = await this.getTrack(trackId);
258
+ span.setAttribute("trackType", track.type);
259
+
260
+ const trackBuffer = this.getTrackBuffer(track);
261
+
262
+ const roundedTimeMs = roundToMilliseconds(timeMs);
263
+ const firstTimestampMs = roundToMilliseconds(
264
+ (await track.getFirstTimestamp()) * 1000,
265
+ );
266
+ span.setAttribute("firstTimestampMs", firstTimestampMs);
267
+
268
+ if (roundedTimeMs < firstTimestampMs) {
269
+ console.error("Seeking outside bounds of input", {
270
+ roundedTimeMs,
271
+ firstTimestampMs,
272
+ });
273
+ throw new NoSample(
274
+ `Seeking outside bounds of input ${roundedTimeMs} < ${firstTimestampMs}`,
275
+ );
276
+ }
277
+
278
+ // Check if we need to reset iterator for seeks outside current buffer range
279
+ const bufferContents = trackBuffer.getContents();
280
+ span.setAttribute("bufferContentsLength", bufferContents.length);
281
+
282
+ if (bufferContents.length > 0) {
283
+ const bufferStartMs = roundToMilliseconds(
284
+ trackBuffer.firstTimestamp * 1000,
285
+ );
286
+ span.setAttribute("bufferStartMs", bufferStartMs);
287
+
288
+ if (roundedTimeMs < bufferStartMs) {
289
+ span.setAttribute("resetIterator", true);
290
+ await this.resetIterator(track);
291
+ }
292
+ }
293
+
294
+ const alreadyInBuffer = trackBuffer.find(timeMs);
295
+ if (alreadyInBuffer) {
296
+ span.setAttribute("foundInBuffer", true);
297
+ span.setAttribute("bufferSize", trackBuffer.length);
298
+ const contents = trackBuffer.getContents();
299
+ if (contents.length > 0) {
300
+ span.setAttribute(
301
+ "bufferTimestamps",
302
+ contents
303
+ .map((s) => Math.round((s.timestamp || 0) * 1000))
304
+ .slice(0, 10)
305
+ .join(","),
306
+ );
307
+ }
308
+ return alreadyInBuffer;
309
+ }
310
+
311
+ // Buffer miss - record buffer state
312
+ span.setAttribute("foundInBuffer", false);
313
+ span.setAttribute("bufferSize", trackBuffer.length);
314
+ span.setAttribute("requestedTimeMs", Math.round(timeMs));
315
+
316
+ const contents = trackBuffer.getContents();
317
+ if (contents.length > 0) {
318
+ const firstSample = contents[0];
319
+ const lastSample = contents[contents.length - 1];
320
+ if (firstSample && lastSample) {
321
+ const bufferStartMs = Math.round(
322
+ (firstSample.timestamp || 0) * 1000,
323
+ );
324
+ const bufferEndMs = Math.round(
325
+ ((lastSample.timestamp || 0) + (lastSample.duration || 0)) *
326
+ 1000,
327
+ );
328
+ span.setAttribute("bufferStartMs", bufferStartMs);
329
+ span.setAttribute("bufferEndMs", bufferEndMs);
330
+ span.setAttribute(
331
+ "bufferRangeMs",
332
+ `${bufferStartMs}-${bufferEndMs}`,
333
+ );
334
+ }
335
+ }
336
+
337
+ const iterator = this.getTrackIterator(track);
338
+ let iterationCount = 0;
339
+ const decodeStart = performance.now();
340
+
341
+ while (true) {
342
+ iterationCount++;
343
+ const iterStart = performance.now();
344
+ const { done, value: decodedSample } = await iterator.next();
345
+ const iterEnd = performance.now();
346
+
347
+ // Record individual iteration timing for first 5 iterations
348
+ if (iterationCount <= 5) {
349
+ span.setAttribute(
350
+ `iter${iterationCount}Ms`,
351
+ Math.round((iterEnd - iterStart) * 100) / 100,
352
+ );
353
+ }
354
+
355
+ if (decodedSample) {
356
+ trackBuffer.push(decodedSample);
357
+ if (iterationCount <= 5) {
358
+ span.setAttribute(
359
+ `iter${iterationCount}Timestamp`,
360
+ Math.round((decodedSample.timestamp || 0) * 1000),
361
+ );
362
+ }
363
+ }
364
+
365
+ const foundSample = trackBuffer.find(roundedTimeMs);
366
+ if (foundSample) {
367
+ const decodeEnd = performance.now();
368
+ span.setAttribute("iterationCount", iterationCount);
369
+ span.setAttribute(
370
+ "decodeMs",
371
+ Math.round((decodeEnd - decodeStart) * 100) / 100,
372
+ );
373
+ span.setAttribute(
374
+ "avgIterMs",
375
+ Math.round(((decodeEnd - decodeStart) / iterationCount) * 100) /
376
+ 100,
377
+ );
378
+ span.setAttribute("foundSample", true);
379
+ span.setAttribute(
380
+ "foundTimestamp",
381
+ Math.round((foundSample.timestamp || 0) * 1000),
382
+ );
383
+ return foundSample;
384
+ }
385
+ if (done) {
386
+ break;
387
+ }
388
+ }
389
+
390
+ span.setAttribute("iterationCount", iterationCount);
391
+ span.setAttribute("reachedEnd", true);
392
+
393
+ // Check if we're seeking to the exact end of the track (legitimate use case)
394
+ const finalBufferContents = trackBuffer.getContents();
395
+ if (finalBufferContents.length > 0) {
396
+ const lastSample =
397
+ finalBufferContents[finalBufferContents.length - 1];
398
+ const lastSampleEndMs = roundToMilliseconds(
399
+ ((lastSample?.timestamp || 0) + (lastSample?.duration || 0)) *
400
+ 1000,
401
+ );
402
+
403
+ // Only return last sample if seeking to exactly the track duration
404
+ // (end of video) AND we have the final segment loaded
405
+ const trackDurationMs = (await track.computeDuration()) * 1000;
406
+ const isSeekingToTrackEnd =
407
+ roundToMilliseconds(timeMs) ===
408
+ roundToMilliseconds(trackDurationMs);
409
+ const isAtEndOfTrack =
410
+ roundToMilliseconds(timeMs) >= lastSampleEndMs;
411
+
412
+ if (isSeekingToTrackEnd && isAtEndOfTrack) {
413
+ span.setAttribute("returnedLastSample", true);
414
+ return lastSample;
415
+ }
416
+ }
417
+
418
+ // For all other cases (seeking within track but outside buffer range), throw error
419
+ // The caller should ensure the correct segment is loaded before seeking
420
+ throw new NoSample(
421
+ `Sample not found for time ${timeMs} in ${track.type} track ${trackId}`,
422
+ );
423
+ } finally {
424
+ this.#seekLock = undefined;
425
+ seekLock.resolve();
300
426
  }
301
- }
302
-
303
- // For all other cases (seeking within track but outside buffer range), throw error
304
- // The caller should ensure the correct segment is loaded before seeking
305
- throw new NoSample(
306
- `Sample not found for time ${timeMs} in ${track.type} track ${trackId}`,
307
- );
308
- } finally {
309
- this.#seekLock = undefined;
310
- seekLock.resolve();
311
- }
427
+ },
428
+ );
312
429
  }
313
430
  }
@@ -55,10 +55,13 @@ const test = baseTest.extend<{
55
55
  const apiHost = `${window.location.protocol}//${window.location.host}`;
56
56
  configuration.setAttribute("api-host", apiHost);
57
57
  configuration.apiHost = apiHost;
58
+ configuration.signingURL = ""; // Disable URL signing for tests
58
59
  const host = document.createElement("ef-video");
59
60
  configuration.appendChild(host);
60
61
  host.src = "http://web:3000/head-moov-480p.mp4";
62
+ document.body.appendChild(configuration);
61
63
  await use(host);
64
+ configuration.remove();
62
65
  },
63
66
  urlGenerator: async ({}, use: any) => {
64
67
  // UrlGenerator points to integrated proxy server (same host/port as test runner)
@@ -19,6 +19,8 @@ export const makeAudioInputTask = (host: EFMedia): InputTask => {
19
19
  onComplete: (_value) => {},
20
20
  task: async (_, { signal }) => {
21
21
  const mediaEngine = await host.mediaEngineTask.taskComplete;
22
+ if (signal.aborted) return undefined;
23
+
22
24
  const audioRendition = mediaEngine?.audioRendition;
23
25
 
24
26
  // Return undefined if no audio rendition available (video-only asset)
@@ -27,9 +29,10 @@ export const makeAudioInputTask = (host: EFMedia): InputTask => {
27
29
  }
28
30
 
29
31
  const initSegment = await host.audioInitSegmentFetchTask.taskComplete;
30
- signal.throwIfAborted();
32
+ if (signal.aborted) return undefined;
33
+
31
34
  const segment = await host.audioSegmentFetchTask.taskComplete;
32
- signal.throwIfAborted();
35
+ if (signal.aborted) return undefined;
33
36
 
34
37
  if (!initSegment || !segment) {
35
38
  return undefined;
@@ -38,7 +41,8 @@ export const makeAudioInputTask = (host: EFMedia): InputTask => {
38
41
  const startTimeOffsetMs = audioRendition.startTimeOffsetMs;
39
42
 
40
43
  const arrayBuffer = await new Blob([initSegment, segment]).arrayBuffer();
41
- signal.throwIfAborted();
44
+ if (signal.aborted) return undefined;
45
+
42
46
  return new BufferedSeekingInput(arrayBuffer, {
43
47
  videoBufferSize: EFMedia.VIDEO_SAMPLE_BUFFER_SIZE,
44
48
  audioBufferSize: EFMedia.AUDIO_SAMPLE_BUFFER_SIZE,
@@ -145,7 +145,7 @@ describe("Audio Seek Task - Chunk Boundary Regression Test", () => {
145
145
 
146
146
  // Now trigger the localStorage restoration that happens in waitForMediaDurations().then()
147
147
  // This will load currentTime = 4.0 from localStorage, jumping from 0ms to 4000ms
148
- const loadedTime = timegroup.maybeLoadTimeFromLocalStorage();
148
+ const loadedTime = timegroup.loadTimeFromLocalStorage();
149
149
  if (loadedTime !== undefined) {
150
150
  timegroup.currentTime = loadedTime;
151
151
  }
@@ -0,0 +1,76 @@
1
+ import type { BufferedSeekingInput } from "../BufferedSeekingInput";
2
+
3
+ /**
4
+ * Cache for main video BufferedSeekingInput instances
5
+ * Main video segments are typically 2s long, so we can reuse the same input
6
+ * for multiple frames within that segment (e.g., 60 frames at 30fps)
7
+ */
8
+ export class MainVideoInputCache {
9
+ private cache = new Map<string, BufferedSeekingInput>();
10
+ private maxCacheSize = 10; // Keep last 10 main inputs (covers 20 seconds at 2s/segment)
11
+
12
+ /**
13
+ * Create a cache key that uniquely identifies a segment
14
+ */
15
+ private getCacheKey(
16
+ src: string,
17
+ segmentId: number,
18
+ renditionId: string | undefined,
19
+ ): string {
20
+ return `${src}:${renditionId || "default"}:${segmentId}`;
21
+ }
22
+
23
+ /**
24
+ * Get or create BufferedSeekingInput for a main video segment
25
+ */
26
+ async getOrCreateInput(
27
+ src: string,
28
+ segmentId: number,
29
+ renditionId: string | undefined,
30
+ createInputFn: () => Promise<BufferedSeekingInput | undefined>,
31
+ ): Promise<BufferedSeekingInput | undefined> {
32
+ const cacheKey = this.getCacheKey(src, segmentId, renditionId);
33
+
34
+ // Check if we already have this segment cached
35
+ const cached = this.cache.get(cacheKey);
36
+ if (cached) {
37
+ return cached;
38
+ }
39
+
40
+ // Create new input
41
+ const input = await createInputFn();
42
+ if (!input) {
43
+ return undefined;
44
+ }
45
+
46
+ // Add to cache and maintain size limit
47
+ this.cache.set(cacheKey, input);
48
+
49
+ // Evict oldest entries if cache is too large (LRU-like behavior)
50
+ if (this.cache.size > this.maxCacheSize) {
51
+ const oldestKey = this.cache.keys().next().value;
52
+ if (oldestKey !== undefined) {
53
+ this.cache.delete(oldestKey);
54
+ }
55
+ }
56
+
57
+ return input;
58
+ }
59
+
60
+ /**
61
+ * Clear the entire cache (called when video changes)
62
+ */
63
+ clear() {
64
+ this.cache.clear();
65
+ }
66
+
67
+ /**
68
+ * Get cache statistics
69
+ */
70
+ getStats() {
71
+ return {
72
+ size: this.cache.size,
73
+ cacheKeys: Array.from(this.cache.keys()),
74
+ };
75
+ }
76
+ }
@@ -8,7 +8,7 @@ import type { InputTask } from "../shared/MediaTaskUtils";
8
8
  export const makeScrubVideoInputTask = (host: EFVideo): InputTask => {
9
9
  return new Task<
10
10
  readonly [ArrayBuffer | undefined, ArrayBuffer | undefined],
11
- BufferedSeekingInput
11
+ BufferedSeekingInput | undefined
12
12
  >(host, {
13
13
  args: () =>
14
14
  [
@@ -19,27 +19,33 @@ export const makeScrubVideoInputTask = (host: EFVideo): InputTask => {
19
19
  console.error("scrubVideoInputTask error", error);
20
20
  },
21
21
  onComplete: (_value) => {},
22
- task: async () => {
22
+ task: async (_, { signal }) => {
23
23
  const initSegment =
24
24
  await host.scrubVideoInitSegmentFetchTask.taskComplete;
25
+ if (signal.aborted) return undefined;
26
+
25
27
  const segment = await host.scrubVideoSegmentFetchTask.taskComplete;
28
+ if (signal.aborted) return undefined;
29
+
26
30
  if (!initSegment || !segment) {
27
31
  throw new Error("Scrub init segment or segment is not available");
28
32
  }
29
33
 
30
34
  // Get startTimeOffsetMs from the scrub rendition if available
31
35
  const mediaEngine = await host.mediaEngineTask.taskComplete;
36
+ if (signal.aborted) return undefined;
37
+
32
38
  const scrubRendition = mediaEngine.getScrubVideoRendition();
33
39
  const startTimeOffsetMs = scrubRendition?.startTimeOffsetMs;
34
40
 
35
- const input = new BufferedSeekingInput(
36
- await new Blob([initSegment, segment]).arrayBuffer(),
37
- {
38
- videoBufferSize: EFMedia.VIDEO_SAMPLE_BUFFER_SIZE,
39
- audioBufferSize: EFMedia.AUDIO_SAMPLE_BUFFER_SIZE,
40
- startTimeOffsetMs,
41
- },
42
- );
41
+ const arrayBuffer = await new Blob([initSegment, segment]).arrayBuffer();
42
+ if (signal.aborted) return undefined;
43
+
44
+ const input = new BufferedSeekingInput(arrayBuffer, {
45
+ videoBufferSize: EFMedia.VIDEO_SAMPLE_BUFFER_SIZE,
46
+ audioBufferSize: EFMedia.AUDIO_SAMPLE_BUFFER_SIZE,
47
+ startTimeOffsetMs,
48
+ });
43
49
  return input;
44
50
  },
45
51
  });
@@ -94,13 +94,19 @@ export const makeScrubVideoSeekTask = (host: EFVideo): ScrubVideoSeekTask => {
94
94
  return undefined;
95
95
  }
96
96
 
97
+ if (signal.aborted) {
98
+ return undefined;
99
+ }
100
+
97
101
  // Get video track and seek to precise time within the 30s scrub segment
98
102
  const videoTrack = await scrubInput.getFirstVideoTrack();
99
103
  if (!videoTrack) {
100
104
  return undefined;
101
105
  }
102
106
 
103
- signal.throwIfAborted();
107
+ if (signal.aborted) {
108
+ return undefined;
109
+ }
104
110
 
105
111
  const sample = (await scrubInput.seek(
106
112
  videoTrack.id,