@editframe/elements 0.20.3-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 (128) hide show
  1. package/dist/DelayedLoadingState.js +0 -27
  2. package/dist/EF_FRAMEGEN.d.ts +5 -3
  3. package/dist/EF_FRAMEGEN.js +51 -29
  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.d.ts +4 -4
  11. package/dist/elements/EFMedia/AssetMediaEngine.js +41 -32
  12. package/dist/elements/EFMedia/BaseMediaEngine.d.ts +10 -2
  13. package/dist/elements/EFMedia/BaseMediaEngine.js +57 -67
  14. package/dist/elements/EFMedia/BufferedSeekingInput.js +134 -76
  15. package/dist/elements/EFMedia/JitMediaEngine.js +22 -23
  16. package/dist/elements/EFMedia/audioTasks/makeAudioBufferTask.js +4 -7
  17. package/dist/elements/EFMedia/audioTasks/makeAudioFrequencyAnalysisTask.js +1 -3
  18. package/dist/elements/EFMedia/audioTasks/makeAudioInitSegmentFetchTask.js +2 -2
  19. package/dist/elements/EFMedia/audioTasks/makeAudioInputTask.js +9 -7
  20. package/dist/elements/EFMedia/audioTasks/makeAudioSeekTask.js +1 -3
  21. package/dist/elements/EFMedia/audioTasks/makeAudioSegmentFetchTask.js +2 -12
  22. package/dist/elements/EFMedia/audioTasks/makeAudioSegmentIdTask.js +2 -2
  23. package/dist/elements/EFMedia/audioTasks/makeAudioTasksVideoOnly.browsertest.d.ts +1 -0
  24. package/dist/elements/EFMedia/audioTasks/makeAudioTimeDomainAnalysisTask.js +6 -3
  25. package/dist/elements/EFMedia/shared/AudioSpanUtils.d.ts +1 -1
  26. package/dist/elements/EFMedia/shared/AudioSpanUtils.js +5 -17
  27. package/dist/elements/EFMedia/shared/BufferUtils.d.ts +1 -1
  28. package/dist/elements/EFMedia/shared/BufferUtils.js +2 -13
  29. package/dist/elements/EFMedia/shared/GlobalInputCache.js +0 -24
  30. package/dist/elements/EFMedia/shared/MediaTaskUtils.d.ts +1 -1
  31. package/dist/elements/EFMedia/shared/PrecisionUtils.js +0 -21
  32. package/dist/elements/EFMedia/shared/RenditionHelpers.d.ts +1 -9
  33. package/dist/elements/EFMedia/shared/ThumbnailExtractor.js +0 -17
  34. package/dist/elements/EFMedia/tasks/makeMediaEngineTask.d.ts +1 -2
  35. package/dist/elements/EFMedia/tasks/makeMediaEngineTask.js +2 -16
  36. package/dist/elements/EFMedia/videoTasks/MainVideoInputCache.d.ts +29 -0
  37. package/dist/elements/EFMedia/videoTasks/MainVideoInputCache.js +32 -0
  38. package/dist/elements/EFMedia/videoTasks/ScrubInputCache.js +1 -15
  39. package/dist/elements/EFMedia/videoTasks/makeScrubVideoBufferTask.js +3 -8
  40. package/dist/elements/EFMedia/videoTasks/makeScrubVideoInitSegmentFetchTask.js +0 -2
  41. package/dist/elements/EFMedia/videoTasks/makeScrubVideoInputTask.js +8 -7
  42. package/dist/elements/EFMedia/videoTasks/makeScrubVideoSeekTask.js +12 -13
  43. package/dist/elements/EFMedia/videoTasks/makeScrubVideoSegmentFetchTask.js +0 -2
  44. package/dist/elements/EFMedia/videoTasks/makeScrubVideoSegmentIdTask.js +1 -3
  45. package/dist/elements/EFMedia/videoTasks/makeUnifiedVideoSeekTask.js +134 -71
  46. package/dist/elements/EFMedia/videoTasks/makeVideoBufferTask.js +8 -12
  47. package/dist/elements/EFMedia.d.ts +2 -1
  48. package/dist/elements/EFMedia.js +26 -23
  49. package/dist/elements/EFSourceMixin.js +5 -7
  50. package/dist/elements/EFSurface.js +6 -9
  51. package/dist/elements/EFTemporal.js +19 -37
  52. package/dist/elements/EFThumbnailStrip.js +16 -59
  53. package/dist/elements/EFTimegroup.js +96 -91
  54. package/dist/elements/EFVideo.d.ts +6 -2
  55. package/dist/elements/EFVideo.js +142 -107
  56. package/dist/elements/EFWaveform.js +18 -27
  57. package/dist/elements/SampleBuffer.js +2 -5
  58. package/dist/elements/TargetController.js +3 -3
  59. package/dist/elements/durationConverter.js +4 -4
  60. package/dist/elements/updateAnimations.js +14 -35
  61. package/dist/gui/ContextMixin.js +23 -52
  62. package/dist/gui/EFConfiguration.js +7 -7
  63. package/dist/gui/EFControls.js +5 -5
  64. package/dist/gui/EFFilmstrip.js +77 -98
  65. package/dist/gui/EFFitScale.js +5 -6
  66. package/dist/gui/EFFocusOverlay.js +4 -4
  67. package/dist/gui/EFPreview.js +4 -4
  68. package/dist/gui/EFScrubber.js +9 -9
  69. package/dist/gui/EFTimeDisplay.js +5 -5
  70. package/dist/gui/EFToggleLoop.js +4 -4
  71. package/dist/gui/EFTogglePlay.js +5 -5
  72. package/dist/gui/EFWorkbench.js +5 -5
  73. package/dist/gui/TWMixin2.js +1 -1
  74. package/dist/index.d.ts +1 -0
  75. package/dist/otel/BridgeSpanExporter.d.ts +13 -0
  76. package/dist/otel/BridgeSpanExporter.js +87 -0
  77. package/dist/otel/setupBrowserTracing.d.ts +12 -0
  78. package/dist/otel/setupBrowserTracing.js +30 -0
  79. package/dist/otel/tracingHelpers.d.ts +34 -0
  80. package/dist/otel/tracingHelpers.js +113 -0
  81. package/dist/transcoding/cache/RequestDeduplicator.js +0 -21
  82. package/dist/transcoding/cache/URLTokenDeduplicator.js +1 -21
  83. package/dist/transcoding/types/index.d.ts +6 -4
  84. package/dist/transcoding/utils/UrlGenerator.js +2 -19
  85. package/dist/utils/LRUCache.js +6 -53
  86. package/package.json +10 -2
  87. package/src/elements/EFCaptions.browsertest.ts +2 -0
  88. package/src/elements/EFMedia/AssetIdMediaEngine.test.ts +6 -4
  89. package/src/elements/EFMedia/AssetMediaEngine.browsertest.ts +25 -23
  90. package/src/elements/EFMedia/AssetMediaEngine.ts +81 -43
  91. package/src/elements/EFMedia/BaseMediaEngine.browsertest.ts +94 -0
  92. package/src/elements/EFMedia/BaseMediaEngine.ts +120 -60
  93. package/src/elements/EFMedia/BufferedSeekingInput.ts +218 -101
  94. package/src/elements/EFMedia/JitMediaEngine.ts +20 -6
  95. package/src/elements/EFMedia/audioTasks/makeAudioBufferTask.ts +5 -2
  96. package/src/elements/EFMedia/audioTasks/makeAudioFrequencyAnalysisTask.ts +0 -5
  97. package/src/elements/EFMedia/audioTasks/makeAudioInitSegmentFetchTask.ts +2 -1
  98. package/src/elements/EFMedia/audioTasks/makeAudioInputTask.ts +18 -8
  99. package/src/elements/EFMedia/audioTasks/makeAudioSegmentFetchTask.ts +4 -16
  100. package/src/elements/EFMedia/audioTasks/makeAudioSegmentIdTask.ts +4 -2
  101. package/src/elements/EFMedia/audioTasks/makeAudioTasksVideoOnly.browsertest.ts +95 -0
  102. package/src/elements/EFMedia/audioTasks/makeAudioTimeDomainAnalysisTask.ts +5 -6
  103. package/src/elements/EFMedia/shared/AudioSpanUtils.ts +5 -4
  104. package/src/elements/EFMedia/shared/BufferUtils.ts +7 -3
  105. package/src/elements/EFMedia/shared/MediaTaskUtils.ts +1 -1
  106. package/src/elements/EFMedia/shared/RenditionHelpers.browsertest.ts +41 -42
  107. package/src/elements/EFMedia/shared/RenditionHelpers.ts +0 -23
  108. package/src/elements/EFMedia/tasks/makeMediaEngineTask.ts +1 -9
  109. package/src/elements/EFMedia/videoTasks/MainVideoInputCache.ts +76 -0
  110. package/src/elements/EFMedia/videoTasks/makeScrubVideoBufferTask.ts +3 -2
  111. package/src/elements/EFMedia/videoTasks/makeScrubVideoInitSegmentFetchTask.ts +0 -5
  112. package/src/elements/EFMedia/videoTasks/makeScrubVideoInputTask.ts +17 -15
  113. package/src/elements/EFMedia/videoTasks/makeScrubVideoSeekTask.ts +7 -1
  114. package/src/elements/EFMedia/videoTasks/makeScrubVideoSegmentFetchTask.ts +0 -5
  115. package/src/elements/EFMedia/videoTasks/makeScrubVideoSegmentIdTask.ts +0 -5
  116. package/src/elements/EFMedia/videoTasks/makeUnifiedVideoSeekTask.ts +222 -125
  117. package/src/elements/EFMedia/videoTasks/makeVideoBufferTask.ts +2 -5
  118. package/src/elements/EFMedia.ts +18 -2
  119. package/src/elements/EFThumbnailStrip.media-engine.browsertest.ts +2 -1
  120. package/src/elements/EFTimegroup.browsertest.ts +10 -8
  121. package/src/elements/EFTimegroup.ts +165 -77
  122. package/src/elements/EFVideo.browsertest.ts +19 -27
  123. package/src/elements/EFVideo.ts +203 -101
  124. package/src/otel/BridgeSpanExporter.ts +150 -0
  125. package/src/otel/setupBrowserTracing.ts +68 -0
  126. package/src/otel/tracingHelpers.ts +251 -0
  127. package/src/transcoding/types/index.ts +6 -4
  128. package/types.json +1 -1
@@ -122,14 +122,16 @@ describe("AssetIdMediaEngine", () => {
122
122
 
123
123
  it("should return correct audio rendition", () => {
124
124
  const audioRendition = engine.audioRendition;
125
- expect(audioRendition.trackId).toBe(1);
126
- expect(audioRendition.src).toBe(mockAssetId);
125
+ expect(audioRendition).toBeDefined();
126
+ expect(audioRendition!.trackId).toBe(1);
127
+ expect(audioRendition!.src).toBe(mockAssetId);
127
128
  });
128
129
 
129
130
  it("should return correct video rendition", () => {
130
131
  const videoRendition = engine.videoRendition;
131
- expect(videoRendition.trackId).toBe(2);
132
- expect(videoRendition.src).toBe(mockAssetId);
132
+ expect(videoRendition).toBeDefined();
133
+ expect(videoRendition!.trackId).toBe(2);
134
+ expect(videoRendition!.src).toBe(mockAssetId);
133
135
  });
134
136
  });
135
137
 
@@ -57,8 +57,9 @@ describe("AssetMediaEngine", () => {
57
57
  expect,
58
58
  }) => {
59
59
  const audioRendition = mediaEngine.audioRendition;
60
- expect(audioRendition.trackId).toBe(2);
61
- expect(audioRendition.src).toBe(host.src);
60
+ expect(audioRendition).toBeDefined();
61
+ expect(audioRendition!.trackId).toBe(2);
62
+ expect(audioRendition!.src).toBe(host.src);
62
63
  });
63
64
 
64
65
  test("returns video rendition with correct properties", ({
@@ -67,9 +68,10 @@ describe("AssetMediaEngine", () => {
67
68
  expect,
68
69
  }) => {
69
70
  const videoRendition = mediaEngine.videoRendition;
70
- expect(videoRendition.trackId).toBe(1);
71
- expect(videoRendition.src).toBe(host.src);
72
- expect(videoRendition.startTimeOffsetMs).toBeCloseTo(66.6, 0);
71
+ expect(videoRendition).toBeDefined();
72
+ expect(videoRendition!.trackId).toBe(1);
73
+ expect(videoRendition!.src).toBe(host.src);
74
+ expect(videoRendition!.startTimeOffsetMs).toBeCloseTo(66.6, 0);
73
75
  });
74
76
 
75
77
  test("provides templates for asset endpoints", ({ mediaEngine, expect }) => {
@@ -100,39 +102,39 @@ describe("AssetMediaEngine", () => {
100
102
 
101
103
  describe("bars n tone segment id computation", () => {
102
104
  test("computes 0ms is 0", ({ expect, mediaEngine }) => {
103
- expect(
104
- mediaEngine.computeSegmentId(0, mediaEngine.getVideoRendition()),
105
- ).toBe(0);
105
+ const videoRendition = mediaEngine.getVideoRendition();
106
+ expect(videoRendition).toBeDefined();
107
+ expect(mediaEngine.computeSegmentId(0, videoRendition!)).toBe(0);
106
108
  });
107
109
 
108
110
  test("computes 2000 is 1", ({ expect, mediaEngine }) => {
109
- expect(
110
- mediaEngine.computeSegmentId(2000, mediaEngine.getVideoRendition()),
111
- ).toBe(1);
111
+ const videoRendition = mediaEngine.getVideoRendition();
112
+ expect(videoRendition).toBeDefined();
113
+ expect(mediaEngine.computeSegmentId(2000, videoRendition!)).toBe(1);
112
114
  });
113
115
 
114
116
  test("computes 4000 is 2", ({ expect, mediaEngine }) => {
115
- expect(
116
- mediaEngine.computeSegmentId(4000, mediaEngine.getVideoRendition()),
117
- ).toBe(2);
117
+ const videoRendition = mediaEngine.getVideoRendition();
118
+ expect(videoRendition).toBeDefined();
119
+ expect(mediaEngine.computeSegmentId(4000, videoRendition!)).toBe(2);
118
120
  });
119
121
 
120
122
  test("computes 6000 is 3", ({ expect, mediaEngine }) => {
121
- expect(
122
- mediaEngine.computeSegmentId(6000, mediaEngine.getVideoRendition()),
123
- ).toBe(3);
123
+ const videoRendition = mediaEngine.getVideoRendition();
124
+ expect(videoRendition).toBeDefined();
125
+ expect(mediaEngine.computeSegmentId(6000, videoRendition!)).toBe(3);
124
126
  });
125
127
 
126
128
  test("computes 8000 is 4", ({ expect, mediaEngine }) => {
127
- expect(
128
- mediaEngine.computeSegmentId(8000, mediaEngine.getVideoRendition()),
129
- ).toBe(4);
129
+ const videoRendition = mediaEngine.getVideoRendition();
130
+ expect(videoRendition).toBeDefined();
131
+ expect(mediaEngine.computeSegmentId(8000, videoRendition!)).toBe(4);
130
132
  });
131
133
 
132
134
  test("computes 7975 is 3", ({ expect, mediaEngine }) => {
133
- expect(
134
- mediaEngine.computeSegmentId(7975, mediaEngine.getVideoRendition()),
135
- ).toBe(3);
135
+ const videoRendition = mediaEngine.getVideoRendition();
136
+ expect(videoRendition).toBeDefined();
137
+ expect(mediaEngine.computeSegmentId(7975, videoRendition!)).toBe(3);
136
138
  });
137
139
  });
138
140
  });
@@ -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,
@@ -54,16 +55,28 @@ export class AssetMediaEngine extends BaseMediaEngine implements MediaEngine {
54
55
  }
55
56
 
56
57
  get videoRendition() {
58
+ const videoTrack = this.videoTrackIndex;
59
+
60
+ if (!videoTrack || videoTrack.track === undefined) {
61
+ return undefined;
62
+ }
63
+
57
64
  return {
58
- trackId: this.videoTrackIndex?.track,
65
+ trackId: videoTrack.track,
59
66
  src: this.src,
60
- startTimeOffsetMs: this.videoTrackIndex?.startTimeOffsetMs,
67
+ startTimeOffsetMs: videoTrack.startTimeOffsetMs,
61
68
  };
62
69
  }
63
70
 
64
71
  get audioRendition() {
72
+ const audioTrack = this.audioTrackIndex;
73
+
74
+ if (!audioTrack || audioTrack.track === undefined) {
75
+ return undefined;
76
+ }
77
+
65
78
  return {
66
- trackId: this.audioTrackIndex?.track,
79
+ trackId: audioTrack.track,
67
80
  src: this.src,
68
81
  };
69
82
  }
@@ -109,23 +122,36 @@ export class AssetMediaEngine extends BaseMediaEngine implements MediaEngine {
109
122
  rendition: { trackId: number | undefined; src: string },
110
123
  signal: AbortSignal,
111
124
  ) {
112
- if (!rendition.trackId) {
113
- throw new Error(
114
- "[fetchInitSegment] Track ID is required for asset metadata",
115
- );
116
- }
117
- const url = this.buildInitSegmentUrl(rendition.trackId);
118
- const initSegment = this.data[rendition.trackId]?.initSegment;
119
- if (!initSegment) {
120
- throw new Error("Init segment not found");
121
- }
122
-
123
- // Use unified fetch method with Range headers
124
- const headers = {
125
- Range: `bytes=${initSegment.offset}-${initSegment.offset + initSegment.size - 1}`,
126
- };
127
-
128
- 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
+ );
129
155
  }
130
156
 
131
157
  async fetchMediaSegment(
@@ -133,26 +159,40 @@ export class AssetMediaEngine extends BaseMediaEngine implements MediaEngine {
133
159
  rendition: { trackId: number | undefined; src: string },
134
160
  signal?: AbortSignal,
135
161
  ) {
136
- if (!rendition.trackId) {
137
- throw new Error(
138
- "[fetchMediaSegment] Track ID is required for asset metadata",
139
- );
140
- }
141
- if (segmentId === undefined) {
142
- throw new Error("Segment ID is not available");
143
- }
144
- const url = this.buildMediaSegmentUrl(rendition.trackId, segmentId);
145
- const mediaSegment = this.data[rendition.trackId]?.segments[segmentId];
146
- if (!mediaSegment) {
147
- throw new Error("Media segment not found");
148
- }
149
-
150
- // Use unified fetch method with Range headers
151
- const headers = {
152
- Range: `bytes=${mediaSegment.offset}-${mediaSegment.offset + mediaSegment.size - 1}`,
153
- };
154
-
155
- 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
+ );
156
196
  }
157
197
 
158
198
  /**
@@ -322,9 +362,7 @@ export class AssetMediaEngine extends BaseMediaEngine implements MediaEngine {
322
362
  // This is because Asset segments are independent timeline fragments
323
363
 
324
364
  if (!rendition.trackId) {
325
- throw new Error(
326
- "[convertToSegmentRelativeTimestamps] Track ID is required for asset metadata",
327
- );
365
+ throw new Error("Track ID is required for asset metadata");
328
366
  }
329
367
  // For AssetMediaEngine, we need to calculate the actual segment start time
330
368
  // using the precise segment boundaries from the track fragment index
@@ -34,6 +34,100 @@ class TestMediaEngine extends BaseMediaEngine {
34
34
  }
35
35
  }
36
36
 
37
+ // Test implementation for video-only assets
38
+ // @ts-expect-error missing implementations
39
+ class VideoOnlyMediaEngine extends BaseMediaEngine {
40
+ fetchMediaSegment = vi.fn();
41
+ public host: EFMedia;
42
+
43
+ constructor(host: EFMedia) {
44
+ super(host);
45
+ this.host = host;
46
+ }
47
+
48
+ get videoRendition() {
49
+ return {
50
+ trackId: 1,
51
+ src: "test-video.mp4",
52
+ segmentDurationMs: 2000,
53
+ };
54
+ }
55
+
56
+ get audioRendition() {
57
+ return undefined; // Video-only asset
58
+ }
59
+ }
60
+
61
+ // Test implementation for audio-only assets
62
+ // @ts-expect-error missing implementations
63
+ class AudioOnlyMediaEngine extends BaseMediaEngine {
64
+ fetchMediaSegment = vi.fn();
65
+ public host: EFMedia;
66
+
67
+ constructor(host: EFMedia) {
68
+ super(host);
69
+ this.host = host;
70
+ }
71
+
72
+ get videoRendition() {
73
+ return undefined; // Audio-only asset
74
+ }
75
+
76
+ get audioRendition() {
77
+ return {
78
+ trackId: 1,
79
+ src: "test-audio.mp4",
80
+ segmentDurationMs: 1000,
81
+ };
82
+ }
83
+ }
84
+
85
+ describe("BaseMediaEngine API Contract", () => {
86
+ test("getAudioRendition returns audio rendition when available", ({
87
+ expect,
88
+ }) => {
89
+ const host = document.createElement("ef-video") as EFMedia;
90
+ const engine = new TestMediaEngine(host);
91
+
92
+ const result = engine.getAudioRendition();
93
+ expect(result).toBeDefined();
94
+ expect(result?.trackId).toBe(2);
95
+ expect(result?.src).toBe("test-audio.mp4");
96
+ });
97
+
98
+ test("getAudioRendition returns undefined for video-only assets", ({
99
+ expect,
100
+ }) => {
101
+ const host = document.createElement("ef-video") as EFMedia;
102
+ const engine = new VideoOnlyMediaEngine(host);
103
+
104
+ const result = engine.getAudioRendition();
105
+ expect(result).toBeUndefined();
106
+ });
107
+
108
+ test("getVideoRendition returns video rendition when available", ({
109
+ expect,
110
+ }) => {
111
+ const host = document.createElement("ef-video") as EFMedia;
112
+ const engine = new TestMediaEngine(host);
113
+
114
+ const result = engine.getVideoRendition();
115
+ expect(result).toBeDefined();
116
+ expect(result?.trackId).toBe(1);
117
+ expect(result?.src).toBe("test-video.mp4");
118
+ });
119
+
120
+ test("getVideoRendition returns undefined for audio-only assets", ({
121
+ expect,
122
+ }) => {
123
+ const host = document.createElement("ef-audio") as EFMedia;
124
+ const engine = new AudioOnlyMediaEngine(host);
125
+
126
+ const result = engine.getVideoRendition();
127
+ expect(result).toBeUndefined();
128
+ });
129
+ });
130
+
37
131
  describe("BaseMediaEngine deduplication", () => {
38
132
  test("should fetch segment successfully", async ({ expect }) => {
39
133
  const host = document.createElement("ef-video") as EFMedia;
@@ -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,
@@ -23,17 +24,19 @@ export abstract class BaseMediaEngine {
23
24
  abstract get videoRendition(): VideoRendition | undefined;
24
25
  abstract get audioRendition(): AudioRendition | undefined;
25
26
 
26
- getVideoRendition(): VideoRendition {
27
- if (!this.videoRendition) {
28
- throw new Error("No video rendition available");
29
- }
27
+ /**
28
+ * Get video rendition if available. Returns undefined for audio-only assets.
29
+ * Callers should handle undefined gracefully.
30
+ */
31
+ getVideoRendition(): VideoRendition | undefined {
30
32
  return this.videoRendition;
31
33
  }
32
34
 
33
- getAudioRendition(): AudioRendition {
34
- if (!this.audioRendition) {
35
- throw new Error("No audio rendition available");
36
- }
35
+ /**
36
+ * Get audio rendition if available. Returns undefined for video-only assets.
37
+ * Callers should handle undefined gracefully.
38
+ */
39
+ getAudioRendition(): AudioRendition | undefined {
37
40
  return this.audioRendition;
38
41
  }
39
42
 
@@ -59,65 +62,122 @@ export abstract class BaseMediaEngine {
59
62
  signal?: AbortSignal;
60
63
  },
61
64
  ): Promise<any> {
62
- const { responseType, headers, signal } = options;
63
-
64
- // Create cache key that includes URL and headers for proper isolation
65
- // Note: We don't include signal in cache key as it would prevent proper deduplication
66
- const cacheKey = headers ? `${url}:${JSON.stringify(headers)}` : url;
67
-
68
- // Check cache first
69
- const cached = mediaCache.get(cacheKey);
70
- if (cached) {
71
- // If we have a cached promise, we need to handle the caller's abort signal
72
- // without affecting the underlying request that other instances might be using
73
- if (signal) {
74
- return this.handleAbortForCachedRequest(cached, signal);
75
- }
76
- return cached;
77
- }
78
-
79
- // Use global deduplicator to prevent concurrent requests for the same resource
80
- // Note: We do NOT pass the signal to the deduplicator - each caller manages their own abort
81
- const promise = globalRequestDeduplicator.executeRequest(
82
- cacheKey,
83
- async () => {
84
- try {
85
- // Make the fetch request WITHOUT the signal - let each caller handle their own abort
86
- // This prevents one instance's abort from affecting other instances using the shared cache
87
- const response = await this.host.fetch(url, { headers });
88
-
89
- if (responseType === "json") {
90
- 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;
91
107
  }
92
- return response.arrayBuffer();
93
- } catch (error) {
94
- // 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
95
156
  if (error instanceof DOMException && error.name === "AbortError") {
96
- // Remove from cache so other requests can retry
97
157
  mediaCache.delete(cacheKey);
98
158
  }
99
- 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;
100
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;
101
179
  },
102
180
  );
103
-
104
- // Cache the promise (not the result) to handle concurrent requests
105
- mediaCache.set(cacheKey, promise);
106
-
107
- // Handle the case where the promise might be aborted
108
- promise.catch((error) => {
109
- // If the request was aborted, remove it from cache to prevent corrupted data
110
- if (error instanceof DOMException && error.name === "AbortError") {
111
- mediaCache.delete(cacheKey);
112
- }
113
- });
114
-
115
- // If the caller has a signal, handle abort logic without affecting the underlying request
116
- if (signal) {
117
- return this.handleAbortForCachedRequest(promise, signal);
118
- }
119
-
120
- return promise;
121
181
  }
122
182
 
123
183
  /**