@editframe/elements 0.20.4-beta.0 → 0.21.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/dist/DelayedLoadingState.js +0 -27
  2. package/dist/EF_FRAMEGEN.d.ts +5 -3
  3. package/dist/EF_FRAMEGEN.js +50 -11
  4. package/dist/_virtual/_@oxc-project_runtime@0.93.0/helpers/decorate.js +7 -0
  5. package/dist/elements/ContextProxiesController.js +2 -22
  6. package/dist/elements/EFAudio.js +4 -8
  7. package/dist/elements/EFCaptions.js +59 -84
  8. package/dist/elements/EFImage.js +5 -6
  9. package/dist/elements/EFMedia/AssetIdMediaEngine.js +2 -4
  10. package/dist/elements/EFMedia/AssetMediaEngine.js +35 -30
  11. package/dist/elements/EFMedia/BaseMediaEngine.js +57 -73
  12. package/dist/elements/EFMedia/BufferedSeekingInput.js +134 -76
  13. package/dist/elements/EFMedia/JitMediaEngine.js +9 -19
  14. package/dist/elements/EFMedia/audioTasks/makeAudioBufferTask.js +3 -6
  15. package/dist/elements/EFMedia/audioTasks/makeAudioFrequencyAnalysisTask.js +1 -1
  16. package/dist/elements/EFMedia/audioTasks/makeAudioInitSegmentFetchTask.js +1 -1
  17. package/dist/elements/EFMedia/audioTasks/makeAudioInputTask.js +6 -5
  18. package/dist/elements/EFMedia/audioTasks/makeAudioSeekTask.js +1 -3
  19. package/dist/elements/EFMedia/audioTasks/makeAudioSegmentFetchTask.js +1 -1
  20. package/dist/elements/EFMedia/audioTasks/makeAudioSegmentIdTask.js +1 -1
  21. package/dist/elements/EFMedia/audioTasks/makeAudioTimeDomainAnalysisTask.js +1 -1
  22. package/dist/elements/EFMedia/shared/AudioSpanUtils.js +4 -16
  23. package/dist/elements/EFMedia/shared/BufferUtils.js +2 -15
  24. package/dist/elements/EFMedia/shared/GlobalInputCache.js +0 -24
  25. package/dist/elements/EFMedia/shared/PrecisionUtils.js +0 -21
  26. package/dist/elements/EFMedia/shared/ThumbnailExtractor.js +0 -17
  27. package/dist/elements/EFMedia/tasks/makeMediaEngineTask.js +1 -10
  28. package/dist/elements/EFMedia/videoTasks/MainVideoInputCache.d.ts +29 -0
  29. package/dist/elements/EFMedia/videoTasks/MainVideoInputCache.js +32 -0
  30. package/dist/elements/EFMedia/videoTasks/ScrubInputCache.js +1 -15
  31. package/dist/elements/EFMedia/videoTasks/makeScrubVideoBufferTask.js +1 -7
  32. package/dist/elements/EFMedia/videoTasks/makeScrubVideoInputTask.js +8 -5
  33. package/dist/elements/EFMedia/videoTasks/makeScrubVideoSeekTask.js +12 -13
  34. package/dist/elements/EFMedia/videoTasks/makeScrubVideoSegmentIdTask.js +1 -1
  35. package/dist/elements/EFMedia/videoTasks/makeUnifiedVideoSeekTask.js +134 -70
  36. package/dist/elements/EFMedia/videoTasks/makeVideoBufferTask.js +7 -11
  37. package/dist/elements/EFMedia.js +26 -24
  38. package/dist/elements/EFSourceMixin.js +5 -7
  39. package/dist/elements/EFSurface.js +6 -9
  40. package/dist/elements/EFTemporal.js +19 -37
  41. package/dist/elements/EFThumbnailStrip.js +16 -59
  42. package/dist/elements/EFTimegroup.js +95 -90
  43. package/dist/elements/EFVideo.d.ts +6 -2
  44. package/dist/elements/EFVideo.js +142 -107
  45. package/dist/elements/EFWaveform.js +18 -27
  46. package/dist/elements/SampleBuffer.js +2 -5
  47. package/dist/elements/TargetController.js +3 -3
  48. package/dist/elements/durationConverter.js +4 -4
  49. package/dist/elements/updateAnimations.js +14 -35
  50. package/dist/gui/ContextMixin.js +23 -52
  51. package/dist/gui/EFConfiguration.js +7 -7
  52. package/dist/gui/EFControls.js +5 -5
  53. package/dist/gui/EFFilmstrip.js +77 -98
  54. package/dist/gui/EFFitScale.js +5 -6
  55. package/dist/gui/EFFocusOverlay.js +4 -4
  56. package/dist/gui/EFPreview.js +4 -4
  57. package/dist/gui/EFScrubber.js +9 -9
  58. package/dist/gui/EFTimeDisplay.js +5 -5
  59. package/dist/gui/EFToggleLoop.js +4 -4
  60. package/dist/gui/EFTogglePlay.js +5 -5
  61. package/dist/gui/EFWorkbench.js +5 -5
  62. package/dist/gui/TWMixin2.js +1 -1
  63. package/dist/index.d.ts +1 -0
  64. package/dist/otel/BridgeSpanExporter.d.ts +13 -0
  65. package/dist/otel/BridgeSpanExporter.js +87 -0
  66. package/dist/otel/setupBrowserTracing.d.ts +12 -0
  67. package/dist/otel/setupBrowserTracing.js +30 -0
  68. package/dist/otel/tracingHelpers.d.ts +34 -0
  69. package/dist/otel/tracingHelpers.js +113 -0
  70. package/dist/transcoding/cache/RequestDeduplicator.js +0 -21
  71. package/dist/transcoding/cache/URLTokenDeduplicator.js +1 -21
  72. package/dist/transcoding/utils/UrlGenerator.js +2 -19
  73. package/dist/utils/LRUCache.js +6 -53
  74. package/package.json +10 -2
  75. package/src/elements/EFCaptions.browsertest.ts +2 -0
  76. package/src/elements/EFMedia/AssetMediaEngine.ts +65 -37
  77. package/src/elements/EFMedia/BaseMediaEngine.ts +110 -52
  78. package/src/elements/EFMedia/BufferedSeekingInput.ts +218 -101
  79. package/src/elements/EFMedia/audioTasks/makeAudioInputTask.ts +7 -3
  80. package/src/elements/EFMedia/videoTasks/MainVideoInputCache.ts +76 -0
  81. package/src/elements/EFMedia/videoTasks/makeScrubVideoInputTask.ts +16 -10
  82. package/src/elements/EFMedia/videoTasks/makeScrubVideoSeekTask.ts +7 -1
  83. package/src/elements/EFMedia/videoTasks/makeUnifiedVideoSeekTask.ts +222 -116
  84. package/src/elements/EFMedia.ts +16 -1
  85. package/src/elements/EFTimegroup.browsertest.ts +10 -8
  86. package/src/elements/EFTimegroup.ts +164 -76
  87. package/src/elements/EFVideo.browsertest.ts +19 -27
  88. package/src/elements/EFVideo.ts +203 -101
  89. package/src/otel/BridgeSpanExporter.ts +150 -0
  90. package/src/otel/setupBrowserTracing.ts +68 -0
  91. package/src/otel/tracingHelpers.ts +251 -0
  92. package/types.json +1 -1
@@ -1,5 +1,6 @@
1
1
  import type { TrackFragmentIndex } from "@editframe/assets";
2
2
 
3
+ import { withSpan } from "../../otel/tracingHelpers.js";
3
4
  import type {
4
5
  AudioRendition,
5
6
  InitSegmentPaths,
@@ -121,23 +122,36 @@ export class AssetMediaEngine extends BaseMediaEngine implements MediaEngine {
121
122
  rendition: { trackId: number | undefined; src: string },
122
123
  signal: AbortSignal,
123
124
  ) {
124
- if (!rendition.trackId) {
125
- throw new Error(
126
- "[fetchInitSegment] Track ID is required for asset metadata",
127
- );
128
- }
129
- const url = this.buildInitSegmentUrl(rendition.trackId);
130
- const initSegment = this.data[rendition.trackId]?.initSegment;
131
- if (!initSegment) {
132
- throw new Error("Init segment not found");
133
- }
134
-
135
- // Use unified fetch method with Range headers
136
- const headers = {
137
- Range: `bytes=${initSegment.offset}-${initSegment.offset + initSegment.size - 1}`,
138
- };
139
-
140
- return this.fetchMediaWithHeaders(url, headers, signal);
125
+ return withSpan(
126
+ "assetEngine.fetchInitSegment",
127
+ {
128
+ trackId: rendition.trackId || -1,
129
+ src: rendition.src,
130
+ },
131
+ undefined,
132
+ async (span) => {
133
+ if (!rendition.trackId) {
134
+ throw new Error(
135
+ "[fetchInitSegment] Track ID is required for asset metadata",
136
+ );
137
+ }
138
+ const url = this.buildInitSegmentUrl(rendition.trackId);
139
+ const initSegment = this.data[rendition.trackId]?.initSegment;
140
+ if (!initSegment) {
141
+ throw new Error("Init segment not found");
142
+ }
143
+
144
+ span.setAttribute("offset", initSegment.offset);
145
+ span.setAttribute("size", initSegment.size);
146
+
147
+ // Use unified fetch method with Range headers
148
+ const headers = {
149
+ Range: `bytes=${initSegment.offset}-${initSegment.offset + initSegment.size - 1}`,
150
+ };
151
+
152
+ return this.fetchMediaWithHeaders(url, headers, signal);
153
+ },
154
+ );
141
155
  }
142
156
 
143
157
  async fetchMediaSegment(
@@ -145,26 +159,40 @@ export class AssetMediaEngine extends BaseMediaEngine implements MediaEngine {
145
159
  rendition: { trackId: number | undefined; src: string },
146
160
  signal?: AbortSignal,
147
161
  ) {
148
- if (!rendition.trackId) {
149
- throw new Error(
150
- "[fetchMediaSegment] Track ID is required for asset metadata",
151
- );
152
- }
153
- if (segmentId === undefined) {
154
- throw new Error("Segment ID is not available");
155
- }
156
- const url = this.buildMediaSegmentUrl(rendition.trackId, segmentId);
157
- const mediaSegment = this.data[rendition.trackId]?.segments[segmentId];
158
- if (!mediaSegment) {
159
- throw new Error("Media segment not found");
160
- }
161
-
162
- // Use unified fetch method with Range headers
163
- const headers = {
164
- Range: `bytes=${mediaSegment.offset}-${mediaSegment.offset + mediaSegment.size - 1}`,
165
- };
166
-
167
- return this.fetchMediaWithHeaders(url, headers, signal);
162
+ return withSpan(
163
+ "assetEngine.fetchMediaSegment",
164
+ {
165
+ segmentId,
166
+ trackId: rendition.trackId || -1,
167
+ src: rendition.src,
168
+ },
169
+ undefined,
170
+ async (span) => {
171
+ if (!rendition.trackId) {
172
+ throw new Error(
173
+ "[fetchMediaSegment] Track ID is required for asset metadata",
174
+ );
175
+ }
176
+ if (segmentId === undefined) {
177
+ throw new Error("Segment ID is not available");
178
+ }
179
+ const url = this.buildMediaSegmentUrl(rendition.trackId, segmentId);
180
+ const mediaSegment = this.data[rendition.trackId]?.segments[segmentId];
181
+ if (!mediaSegment) {
182
+ throw new Error("Media segment not found");
183
+ }
184
+
185
+ span.setAttribute("offset", mediaSegment.offset);
186
+ span.setAttribute("size", mediaSegment.size);
187
+
188
+ // Use unified fetch method with Range headers
189
+ const headers = {
190
+ Range: `bytes=${mediaSegment.offset}-${mediaSegment.offset + mediaSegment.size - 1}`,
191
+ };
192
+
193
+ return this.fetchMediaWithHeaders(url, headers, signal);
194
+ },
195
+ );
168
196
  }
169
197
 
170
198
  /**
@@ -1,3 +1,4 @@
1
+ import { withSpan } from "../../otel/tracingHelpers.js";
1
2
  import { RequestDeduplicator } from "../../transcoding/cache/RequestDeduplicator.js";
2
3
  import type {
3
4
  AudioRendition,
@@ -61,65 +62,122 @@ export abstract class BaseMediaEngine {
61
62
  signal?: AbortSignal;
62
63
  },
63
64
  ): Promise<any> {
64
- const { responseType, headers, signal } = options;
65
-
66
- // Create cache key that includes URL and headers for proper isolation
67
- // Note: We don't include signal in cache key as it would prevent proper deduplication
68
- const cacheKey = headers ? `${url}:${JSON.stringify(headers)}` : url;
69
-
70
- // Check cache first
71
- const cached = mediaCache.get(cacheKey);
72
- if (cached) {
73
- // If we have a cached promise, we need to handle the caller's abort signal
74
- // without affecting the underlying request that other instances might be using
75
- if (signal) {
76
- return this.handleAbortForCachedRequest(cached, signal);
77
- }
78
- return cached;
79
- }
80
-
81
- // Use global deduplicator to prevent concurrent requests for the same resource
82
- // Note: We do NOT pass the signal to the deduplicator - each caller manages their own abort
83
- const promise = globalRequestDeduplicator.executeRequest(
84
- cacheKey,
85
- async () => {
86
- try {
87
- // Make the fetch request WITHOUT the signal - let each caller handle their own abort
88
- // This prevents one instance's abort from affecting other instances using the shared cache
89
- const response = await this.host.fetch(url, { headers });
90
-
91
- if (responseType === "json") {
92
- return response.json();
65
+ return withSpan(
66
+ "mediaEngine.fetchWithCache",
67
+ {
68
+ url: url.length > 100 ? `${url.substring(0, 100)}...` : url,
69
+ responseType: options.responseType,
70
+ hasHeaders: !!options.headers,
71
+ },
72
+ undefined,
73
+ async (span) => {
74
+ const t0 = performance.now();
75
+ const { responseType, headers, signal } = options;
76
+
77
+ // Create cache key that includes URL and headers for proper isolation
78
+ // Note: We don't include signal in cache key as it would prevent proper deduplication
79
+ const cacheKey = headers ? `${url}:${JSON.stringify(headers)}` : url;
80
+
81
+ // Check cache first
82
+ const t1 = performance.now();
83
+ const cached = mediaCache.get(cacheKey);
84
+ const t2 = performance.now();
85
+ span.setAttribute("cacheLookupMs", Math.round((t2 - t1) * 1000) / 1000);
86
+
87
+ if (cached) {
88
+ span.setAttribute("cacheHit", true);
89
+ // If we have a cached promise, we need to handle the caller's abort signal
90
+ // without affecting the underlying request that other instances might be using
91
+ if (signal) {
92
+ const t3 = performance.now();
93
+ const result = await this.handleAbortForCachedRequest(
94
+ cached,
95
+ signal,
96
+ );
97
+ const t4 = performance.now();
98
+ span.setAttribute(
99
+ "handleAbortMs",
100
+ Math.round((t4 - t3) * 100) / 100,
101
+ );
102
+ span.setAttribute(
103
+ "totalCacheHitMs",
104
+ Math.round((t4 - t0) * 100) / 100,
105
+ );
106
+ return result;
93
107
  }
94
- return response.arrayBuffer();
95
- } catch (error) {
96
- // If the request was aborted, don't cache the error
108
+ span.setAttribute(
109
+ "totalCacheHitMs",
110
+ Math.round((t2 - t0) * 100) / 100,
111
+ );
112
+ return cached;
113
+ }
114
+
115
+ span.setAttribute("cacheHit", false);
116
+
117
+ // Use global deduplicator to prevent concurrent requests for the same resource
118
+ // Note: We do NOT pass the signal to the deduplicator - each caller manages their own abort
119
+ const promise = globalRequestDeduplicator.executeRequest(
120
+ cacheKey,
121
+ async () => {
122
+ const fetchStart = performance.now();
123
+ try {
124
+ // Make the fetch request WITHOUT the signal - let each caller handle their own abort
125
+ // This prevents one instance's abort from affecting other instances using the shared cache
126
+ const response = await this.host.fetch(url, { headers });
127
+ const fetchEnd = performance.now();
128
+ span.setAttribute("fetchMs", fetchEnd - fetchStart);
129
+
130
+ if (responseType === "json") {
131
+ return response.json();
132
+ }
133
+ const buffer = await response.arrayBuffer();
134
+ span.setAttribute("sizeBytes", buffer.byteLength);
135
+ return buffer;
136
+ } catch (error) {
137
+ // If the request was aborted, don't cache the error
138
+ if (
139
+ error instanceof DOMException &&
140
+ error.name === "AbortError"
141
+ ) {
142
+ // Remove from cache so other requests can retry
143
+ mediaCache.delete(cacheKey);
144
+ }
145
+ throw error;
146
+ }
147
+ },
148
+ );
149
+
150
+ // Cache the promise (not the result) to handle concurrent requests
151
+ mediaCache.set(cacheKey, promise);
152
+
153
+ // Handle the case where the promise might be aborted
154
+ promise.catch((error) => {
155
+ // If the request was aborted, remove it from cache to prevent corrupted data
97
156
  if (error instanceof DOMException && error.name === "AbortError") {
98
- // Remove from cache so other requests can retry
99
157
  mediaCache.delete(cacheKey);
100
158
  }
101
- throw error;
159
+ });
160
+
161
+ // If the caller has a signal, handle abort logic without affecting the underlying request
162
+ if (signal) {
163
+ const result = await this.handleAbortForCachedRequest(
164
+ promise,
165
+ signal,
166
+ );
167
+ const tEnd = performance.now();
168
+ span.setAttribute(
169
+ "totalFetchMs",
170
+ Math.round((tEnd - t0) * 100) / 100,
171
+ );
172
+ return result;
102
173
  }
174
+
175
+ const result = await promise;
176
+ const tEnd = performance.now();
177
+ span.setAttribute("totalFetchMs", Math.round((tEnd - t0) * 100) / 100);
178
+ return result;
103
179
  },
104
180
  );
105
-
106
- // Cache the promise (not the result) to handle concurrent requests
107
- mediaCache.set(cacheKey, promise);
108
-
109
- // Handle the case where the promise might be aborted
110
- promise.catch((error) => {
111
- // If the request was aborted, remove it from cache to prevent corrupted data
112
- if (error instanceof DOMException && error.name === "AbortError") {
113
- mediaCache.delete(cacheKey);
114
- }
115
- });
116
-
117
- // If the caller has a signal, handle abort logic without affecting the underlying request
118
- if (signal) {
119
- return this.handleAbortForCachedRequest(promise, signal);
120
- }
121
-
122
- return promise;
123
181
  }
124
182
 
125
183
  /**
@@ -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
  }
@@ -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,