@editframe/elements 0.18.7-beta.0 → 0.18.19-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.
- package/dist/elements/EFAudio.d.ts +1 -2
- package/dist/elements/EFAudio.js +6 -9
- package/dist/elements/EFMedia/AssetIdMediaEngine.js +4 -1
- package/dist/elements/EFMedia/AssetMediaEngine.d.ts +3 -4
- package/dist/elements/EFMedia/AssetMediaEngine.js +28 -17
- package/dist/elements/EFMedia/BaseMediaEngine.d.ts +30 -11
- package/dist/elements/EFMedia/BaseMediaEngine.js +83 -31
- package/dist/elements/EFMedia/JitMediaEngine.d.ts +2 -4
- package/dist/elements/EFMedia/JitMediaEngine.js +12 -12
- package/dist/elements/EFMedia/audioTasks/makeAudioSeekTask.js +7 -2
- package/dist/elements/EFVideo.d.ts +0 -1
- package/dist/elements/EFVideo.js +0 -9
- package/dist/elements/TargetController.js +3 -2
- package/package.json +2 -2
- package/src/elements/EFAudio.ts +7 -20
- package/src/elements/EFMedia/AssetIdMediaEngine.ts +10 -1
- package/src/elements/EFMedia/AssetMediaEngine.ts +45 -21
- package/src/elements/EFMedia/BaseMediaEngine.browsertest.ts +311 -0
- package/src/elements/EFMedia/BaseMediaEngine.ts +168 -51
- package/src/elements/EFMedia/JitMediaEngine.browsertest.ts +2 -12
- package/src/elements/EFMedia/JitMediaEngine.ts +25 -16
- package/src/elements/EFMedia/audioTasks/makeAudioSeekTask.ts +10 -1
- package/src/elements/EFTemporal.browsertest.ts +47 -0
- package/src/elements/EFVideo.browsertest.ts +127 -281
- package/src/elements/EFVideo.ts +9 -9
- package/src/elements/TargetController.ts +6 -2
- package/test/__cache__/GET__api_v1_transcode_audio_1_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__32da3954ba60c96ad732020c65a08ebc/metadata.json +3 -8
- package/test/__cache__/GET__api_v1_transcode_audio_2_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__b0b2b07efcf607de8ee0f650328c32f7/metadata.json +3 -8
- package/test/__cache__/GET__api_v1_transcode_audio_3_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__a75c2252b542e0c152c780e9a8d7b154/metadata.json +3 -8
- package/test/__cache__/GET__api_v1_transcode_audio_4_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__a64ff1cfb1b52cae14df4b5dfa1e222b/metadata.json +3 -8
- package/test/__cache__/GET__api_v1_transcode_audio_5_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__91e8a522f950809b9f09f4173113b4b0/metadata.json +3 -8
- package/test/__cache__/GET__api_v1_transcode_audio_init_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__e66d2c831d951e74ad0aeaa6489795d0/metadata.json +3 -8
- package/test/__cache__/GET__api_v1_transcode_high_1_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__26197f6f7c46cacb0a71134131c3f775/data.bin +0 -0
- package/test/__cache__/GET__api_v1_transcode_high_1_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__26197f6f7c46cacb0a71134131c3f775/metadata.json +4 -9
- package/test/__cache__/GET__api_v1_transcode_high_2_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__4cb6774cd3650ccf59c8f8dc6678c0b9/data.bin +0 -0
- package/test/__cache__/GET__api_v1_transcode_high_2_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__4cb6774cd3650ccf59c8f8dc6678c0b9/metadata.json +4 -9
- package/test/__cache__/GET__api_v1_transcode_high_3_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__0b3b2b1c8933f7fcf8a9ecaa88d58b41/data.bin +0 -0
- package/test/__cache__/GET__api_v1_transcode_high_3_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__0b3b2b1c8933f7fcf8a9ecaa88d58b41/metadata.json +4 -9
- package/test/__cache__/GET__api_v1_transcode_high_4_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__a6fb05a22b18d850f7f2950bbcdbdeed/data.bin +0 -0
- package/test/__cache__/GET__api_v1_transcode_high_4_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__a6fb05a22b18d850f7f2950bbcdbdeed/metadata.json +4 -9
- package/test/__cache__/GET__api_v1_transcode_high_5_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__a50058c7c3602e90879fe3428ed891f4/data.bin +0 -0
- package/test/__cache__/GET__api_v1_transcode_high_5_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__a50058c7c3602e90879fe3428ed891f4/metadata.json +4 -9
- package/test/__cache__/GET__api_v1_transcode_high_init_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__0798c479b44aaeef850609a430f6e613/data.bin +0 -0
- package/test/__cache__/GET__api_v1_transcode_high_init_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__0798c479b44aaeef850609a430f6e613/metadata.json +4 -9
- package/test/__cache__/GET__api_v1_transcode_manifest_json_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__3be92a0437de726b431ed5af2369158a/metadata.json +2 -4
- package/test/recordReplayProxyPlugin.js +46 -31
- package/test/setup.ts +16 -0
- package/test/useAssetMSW.ts +54 -0
- package/test/useMSW.ts +4 -11
- package/types.json +1 -1
- package/dist/elements/MediaController.d.ts +0 -30
- package/src/elements/EFMedia/BaseMediaEngine.test.ts +0 -164
- package/src/elements/MediaController.ts +0 -98
- package/test/__cache__/GET__api_v1_transcode_audio_1_mp4_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4_bytes_0__9ed2d25c675aa6bb6ff5b3ae23887c71/data.bin +0 -0
- package/test/__cache__/GET__api_v1_transcode_audio_1_mp4_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4_bytes_0__9ed2d25c675aa6bb6ff5b3ae23887c71/metadata.json +0 -22
- package/test/__cache__/GET__api_v1_transcode_audio_2_mp4_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4_bytes_0__d5a3309a2bf756dd6e304807eb402f56/data.bin +0 -0
- package/test/__cache__/GET__api_v1_transcode_audio_2_mp4_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4_bytes_0__d5a3309a2bf756dd6e304807eb402f56/metadata.json +0 -22
- package/test/__cache__/GET__api_v1_transcode_audio_3_mp4_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4_bytes_0__773254bb671e3466fca8677139fb239e/data.bin +0 -0
- package/test/__cache__/GET__api_v1_transcode_audio_3_mp4_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4_bytes_0__773254bb671e3466fca8677139fb239e/metadata.json +0 -22
- /package/dist/elements/EFMedia/{BaseMediaEngine.test.d.ts → BaseMediaEngine.browsertest.d.ts} +0 -0
|
@@ -16,28 +16,32 @@ import {
|
|
|
16
16
|
} from "./shared/PrecisionUtils";
|
|
17
17
|
|
|
18
18
|
export class AssetMediaEngine extends BaseMediaEngine implements MediaEngine {
|
|
19
|
+
public src: string;
|
|
20
|
+
protected data: Record<number, TrackFragmentIndex> = {};
|
|
21
|
+
durationMs = 0;
|
|
22
|
+
|
|
23
|
+
constructor(host: EFMedia, src: string) {
|
|
24
|
+
super(host);
|
|
25
|
+
this.src = src;
|
|
26
|
+
}
|
|
27
|
+
|
|
19
28
|
static async fetch(host: EFMedia, urlGenerator: UrlGenerator, src: string) {
|
|
29
|
+
const engine = new AssetMediaEngine(host, src);
|
|
20
30
|
const url = urlGenerator.generateTrackFragmentIndexUrl(src);
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
if (src.startsWith("/")) {
|
|
24
|
-
src = src.slice(1);
|
|
25
|
-
}
|
|
26
|
-
return new AssetMediaEngine(host, src, data);
|
|
27
|
-
}
|
|
28
|
-
durationMs: number;
|
|
31
|
+
const data = await engine.fetchManifest(url);
|
|
32
|
+
engine.data = data as Record<number, TrackFragmentIndex>;
|
|
29
33
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
public src: string,
|
|
33
|
-
protected data: Record<number, TrackFragmentIndex>,
|
|
34
|
-
) {
|
|
35
|
-
super();
|
|
36
|
-
const longestFragment = Object.values(data).reduce(
|
|
34
|
+
// Calculate duration from the data
|
|
35
|
+
const longestFragment = Object.values(engine.data).reduce(
|
|
37
36
|
(max, fragment) => Math.max(max, fragment.duration / fragment.timescale),
|
|
38
37
|
0,
|
|
39
38
|
);
|
|
40
|
-
|
|
39
|
+
engine.durationMs = longestFragment * 1000;
|
|
40
|
+
|
|
41
|
+
if (src.startsWith("/")) {
|
|
42
|
+
engine.src = src.slice(1);
|
|
43
|
+
}
|
|
44
|
+
return engine;
|
|
41
45
|
}
|
|
42
46
|
|
|
43
47
|
get audioTrackIndex() {
|
|
@@ -113,15 +117,15 @@ export class AssetMediaEngine extends BaseMediaEngine implements MediaEngine {
|
|
|
113
117
|
throw new Error("Init segment not found");
|
|
114
118
|
}
|
|
115
119
|
|
|
116
|
-
// Use
|
|
120
|
+
// Use unified fetch method with Range headers
|
|
117
121
|
const headers = {
|
|
118
122
|
Range: `bytes=${initSegment.offset}-${initSegment.offset + initSegment.size - 1}`,
|
|
119
123
|
};
|
|
120
124
|
|
|
121
|
-
return this.
|
|
125
|
+
return this.fetchMediaWithHeaders(url, headers, signal);
|
|
122
126
|
}
|
|
123
127
|
|
|
124
|
-
async
|
|
128
|
+
async fetchMediaSegment(
|
|
125
129
|
segmentId: number,
|
|
126
130
|
rendition: { trackId: number | undefined; src: string },
|
|
127
131
|
signal?: AbortSignal,
|
|
@@ -138,12 +142,12 @@ export class AssetMediaEngine extends BaseMediaEngine implements MediaEngine {
|
|
|
138
142
|
throw new Error("Media segment not found");
|
|
139
143
|
}
|
|
140
144
|
|
|
141
|
-
// Use
|
|
145
|
+
// Use unified fetch method with Range headers
|
|
142
146
|
const headers = {
|
|
143
147
|
Range: `bytes=${mediaSegment.offset}-${mediaSegment.offset + mediaSegment.size - 1}`,
|
|
144
148
|
};
|
|
145
149
|
|
|
146
|
-
return this.
|
|
150
|
+
return this.fetchMediaWithHeaders(url, headers, signal);
|
|
147
151
|
}
|
|
148
152
|
|
|
149
153
|
/**
|
|
@@ -156,11 +160,21 @@ export class AssetMediaEngine extends BaseMediaEngine implements MediaEngine {
|
|
|
156
160
|
_durationMs: number,
|
|
157
161
|
): SegmentTimeRange[] {
|
|
158
162
|
if (fromMs >= toMs || !rendition.trackId) {
|
|
163
|
+
console.warn(
|
|
164
|
+
`calculateAudioSegmentRange: invalid fromMs ${fromMs} toMs ${toMs} rendition ${JSON.stringify(
|
|
165
|
+
rendition,
|
|
166
|
+
)}`,
|
|
167
|
+
);
|
|
159
168
|
return [];
|
|
160
169
|
}
|
|
161
170
|
|
|
162
171
|
const track = this.data[rendition.trackId];
|
|
163
172
|
if (!track) {
|
|
173
|
+
console.warn(
|
|
174
|
+
`calculateAudioSegmentRange: track not found for rendition ${JSON.stringify(
|
|
175
|
+
rendition,
|
|
176
|
+
)}`,
|
|
177
|
+
);
|
|
164
178
|
return [];
|
|
165
179
|
}
|
|
166
180
|
|
|
@@ -186,6 +200,16 @@ export class AssetMediaEngine extends BaseMediaEngine implements MediaEngine {
|
|
|
186
200
|
});
|
|
187
201
|
}
|
|
188
202
|
}
|
|
203
|
+
if (segmentRanges.length === 0) {
|
|
204
|
+
console.warn(
|
|
205
|
+
`calculateAudioSegmentRange: no segments found for fromMs ${fromMs} toMs ${toMs} rendition ${JSON.stringify(
|
|
206
|
+
{
|
|
207
|
+
rendition,
|
|
208
|
+
track,
|
|
209
|
+
},
|
|
210
|
+
)}`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
189
213
|
|
|
190
214
|
return segmentRanges;
|
|
191
215
|
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { describe, vi } from "vitest";
|
|
2
|
+
import { test as baseTest } from "../../../test/useMSW.js";
|
|
3
|
+
|
|
4
|
+
import type { EFMedia } from "../EFMedia.js";
|
|
5
|
+
import { BaseMediaEngine, mediaCache } from "./BaseMediaEngine.js";
|
|
6
|
+
|
|
7
|
+
const test = baseTest.extend<{}>({});
|
|
8
|
+
|
|
9
|
+
// Test implementation of BaseMediaEngine for testing
|
|
10
|
+
class TestMediaEngine extends BaseMediaEngine {
|
|
11
|
+
fetchMediaSegment = vi.fn();
|
|
12
|
+
public host: EFMedia;
|
|
13
|
+
|
|
14
|
+
constructor(host: EFMedia) {
|
|
15
|
+
super(host);
|
|
16
|
+
this.host = host;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
get videoRendition() {
|
|
20
|
+
return {
|
|
21
|
+
trackId: 1,
|
|
22
|
+
src: "test-video.mp4",
|
|
23
|
+
segmentDurationMs: 2000,
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
get audioRendition() {
|
|
28
|
+
return {
|
|
29
|
+
trackId: 2,
|
|
30
|
+
src: "test-audio.mp4",
|
|
31
|
+
segmentDurationMs: 1000,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("BaseMediaEngine deduplication", () => {
|
|
37
|
+
test("should fetch segment successfully", async ({ expect }) => {
|
|
38
|
+
const host = document.createElement("ef-video") as EFMedia;
|
|
39
|
+
const engine = new TestMediaEngine(host);
|
|
40
|
+
engine.fetchMediaSegment.mockResolvedValue(new ArrayBuffer(1024));
|
|
41
|
+
|
|
42
|
+
const rendition = { trackId: 1, src: "test.mp4" };
|
|
43
|
+
const result = await engine.fetchMediaSegment(1, rendition);
|
|
44
|
+
|
|
45
|
+
expect(result).toEqual(new ArrayBuffer(1024));
|
|
46
|
+
expect(engine.fetchMediaSegment).toHaveBeenCalledWith(1, rendition);
|
|
47
|
+
expect(engine.fetchMediaSegment).toHaveBeenCalledTimes(1);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("should deduplicate concurrent requests for same segment", async ({
|
|
51
|
+
expect,
|
|
52
|
+
}) => {
|
|
53
|
+
const host = document.createElement("ef-video") as EFMedia;
|
|
54
|
+
const engine = new TestMediaEngine(host);
|
|
55
|
+
const mockSegmentData = new ArrayBuffer(1024);
|
|
56
|
+
engine.fetchMediaSegment.mockResolvedValue(mockSegmentData);
|
|
57
|
+
|
|
58
|
+
const rendition = { trackId: 1, src: "test.mp4" };
|
|
59
|
+
|
|
60
|
+
// Make two concurrent requests for the same segment
|
|
61
|
+
const [result1, result2] = await Promise.all([
|
|
62
|
+
engine.fetchMediaSegmentWithDeduplication(1, rendition),
|
|
63
|
+
engine.fetchMediaSegmentWithDeduplication(1, rendition),
|
|
64
|
+
]);
|
|
65
|
+
|
|
66
|
+
// Both should return the same result
|
|
67
|
+
expect(result1).toBe(mockSegmentData);
|
|
68
|
+
expect(result2).toBe(mockSegmentData);
|
|
69
|
+
|
|
70
|
+
// But fetchMediaSegment should only be called once due to deduplication
|
|
71
|
+
expect(engine.fetchMediaSegment).toHaveBeenCalledTimes(1);
|
|
72
|
+
expect(engine.fetchMediaSegment).toHaveBeenCalledWith(1, rendition);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("should handle different segments as separate requests", async ({
|
|
76
|
+
expect,
|
|
77
|
+
}) => {
|
|
78
|
+
const host = document.createElement("ef-video") as EFMedia;
|
|
79
|
+
const engine = new TestMediaEngine(host);
|
|
80
|
+
const mockSegmentData = new ArrayBuffer(1024);
|
|
81
|
+
engine.fetchMediaSegment.mockResolvedValue(mockSegmentData);
|
|
82
|
+
|
|
83
|
+
const rendition = { trackId: 1, src: "test.mp4" };
|
|
84
|
+
|
|
85
|
+
// Make concurrent requests for different segments
|
|
86
|
+
const [result1, result2] = await Promise.all([
|
|
87
|
+
engine.fetchMediaSegmentWithDeduplication(1, rendition),
|
|
88
|
+
engine.fetchMediaSegmentWithDeduplication(2, rendition),
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
expect(result1).toBe(mockSegmentData);
|
|
92
|
+
expect(result2).toBe(mockSegmentData);
|
|
93
|
+
|
|
94
|
+
// Should call fetchMediaSegment twice for different segments
|
|
95
|
+
expect(engine.fetchMediaSegment).toHaveBeenCalledTimes(2);
|
|
96
|
+
expect(engine.fetchMediaSegment).toHaveBeenCalledWith(1, rendition);
|
|
97
|
+
expect(engine.fetchMediaSegment).toHaveBeenCalledWith(2, rendition);
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("should handle different renditions as separate requests", async ({
|
|
101
|
+
expect,
|
|
102
|
+
}) => {
|
|
103
|
+
const host = document.createElement("ef-video") as EFMedia;
|
|
104
|
+
const engine = new TestMediaEngine(host);
|
|
105
|
+
const mockSegmentData = new ArrayBuffer(1024);
|
|
106
|
+
engine.fetchMediaSegment.mockResolvedValue(mockSegmentData);
|
|
107
|
+
|
|
108
|
+
const rendition1 = { trackId: 1, src: "test1.mp4" };
|
|
109
|
+
const rendition2 = { trackId: 2, src: "test2.mp4" };
|
|
110
|
+
|
|
111
|
+
// Make concurrent requests for same segment but different renditions
|
|
112
|
+
const [result1, result2] = await Promise.all([
|
|
113
|
+
engine.fetchMediaSegmentWithDeduplication(1, rendition1),
|
|
114
|
+
engine.fetchMediaSegmentWithDeduplication(1, rendition2),
|
|
115
|
+
]);
|
|
116
|
+
|
|
117
|
+
expect(result1).toBe(mockSegmentData);
|
|
118
|
+
expect(result2).toBe(mockSegmentData);
|
|
119
|
+
|
|
120
|
+
// Should call fetchMediaSegment twice for different renditions
|
|
121
|
+
expect(engine.fetchMediaSegment).toHaveBeenCalledTimes(2);
|
|
122
|
+
expect(engine.fetchMediaSegment).toHaveBeenCalledWith(1, rendition1);
|
|
123
|
+
expect(engine.fetchMediaSegment).toHaveBeenCalledWith(1, rendition2);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("should propagate errors correctly", async ({ expect }) => {
|
|
127
|
+
const host = document.createElement("ef-video") as EFMedia;
|
|
128
|
+
const engine = new TestMediaEngine(host);
|
|
129
|
+
const error = new Error("Fetch failed");
|
|
130
|
+
engine.fetchMediaSegment.mockRejectedValue(error);
|
|
131
|
+
|
|
132
|
+
const rendition = { trackId: 1, src: "test.mp4" };
|
|
133
|
+
|
|
134
|
+
await expect(
|
|
135
|
+
engine.fetchMediaSegmentWithDeduplication(1, rendition),
|
|
136
|
+
).rejects.toThrow("Fetch failed");
|
|
137
|
+
expect(engine.fetchMediaSegment).toHaveBeenCalledWith(1, rendition);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("should retry failed requests after error", async ({ expect }) => {
|
|
141
|
+
const host = document.createElement("ef-video") as EFMedia;
|
|
142
|
+
const engine = new TestMediaEngine(host);
|
|
143
|
+
const rendition = { trackId: 1, src: "test.mp4" };
|
|
144
|
+
const error = new Error("First attempt failed");
|
|
145
|
+
const mockSegmentData = new ArrayBuffer(1024);
|
|
146
|
+
|
|
147
|
+
// First request fails
|
|
148
|
+
engine.fetchMediaSegment.mockRejectedValueOnce(error);
|
|
149
|
+
await expect(
|
|
150
|
+
engine.fetchMediaSegmentWithDeduplication(1, rendition),
|
|
151
|
+
).rejects.toThrow("First attempt failed");
|
|
152
|
+
|
|
153
|
+
// Second request should succeed (not deduplicated since first failed)
|
|
154
|
+
engine.fetchMediaSegment.mockResolvedValue(mockSegmentData);
|
|
155
|
+
const result = await engine.fetchMediaSegmentWithDeduplication(
|
|
156
|
+
1,
|
|
157
|
+
rendition,
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
expect(result).toBe(mockSegmentData);
|
|
161
|
+
expect(engine.fetchMediaSegment).toHaveBeenCalledTimes(2);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("should track pending requests correctly", async ({ expect }) => {
|
|
165
|
+
const host = document.createElement("ef-video") as EFMedia;
|
|
166
|
+
const engine = new TestMediaEngine(host);
|
|
167
|
+
const mockSegmentData = new ArrayBuffer(1024);
|
|
168
|
+
engine.fetchMediaSegment.mockResolvedValue(mockSegmentData);
|
|
169
|
+
|
|
170
|
+
const rendition = { trackId: 1, src: "test.mp4" };
|
|
171
|
+
|
|
172
|
+
// Start a request but don't await it
|
|
173
|
+
const promise = engine.fetchMediaSegmentWithDeduplication(1, rendition);
|
|
174
|
+
|
|
175
|
+
// Should detect that segment is being fetched
|
|
176
|
+
expect(engine.isSegmentBeingFetched(1, rendition)).toBe(true);
|
|
177
|
+
expect(engine.getActiveSegmentRequestCount()).toBe(1);
|
|
178
|
+
|
|
179
|
+
// Different segment should not be detected as being fetched
|
|
180
|
+
expect(engine.isSegmentBeingFetched(2, rendition)).toBe(false);
|
|
181
|
+
|
|
182
|
+
await promise.then(() => {
|
|
183
|
+
// After completion, should no longer be detected as being fetched
|
|
184
|
+
expect(engine.isSegmentBeingFetched(1, rendition)).toBe(false);
|
|
185
|
+
expect(engine.getActiveSegmentRequestCount()).toBe(0);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("should cancel all requests correctly", async ({ expect }) => {
|
|
190
|
+
const host = document.createElement("ef-video") as EFMedia;
|
|
191
|
+
const engine = new TestMediaEngine(host);
|
|
192
|
+
const rendition = { trackId: 1, src: "test.mp4" };
|
|
193
|
+
|
|
194
|
+
// Start a request
|
|
195
|
+
engine.fetchMediaSegmentWithDeduplication(1, rendition);
|
|
196
|
+
expect(engine.getActiveSegmentRequestCount()).toBe(1);
|
|
197
|
+
|
|
198
|
+
// Cancel all requests
|
|
199
|
+
engine.cancelAllSegmentRequests();
|
|
200
|
+
expect(engine.getActiveSegmentRequestCount()).toBe(0);
|
|
201
|
+
});
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
describe("BaseMediaEngine caching", () => {
|
|
205
|
+
test("should use shared media cache", async ({ expect }) => {
|
|
206
|
+
// Test that the cache is shared across instances
|
|
207
|
+
expect(mediaCache).toBe(mediaCache);
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
test("should have cache size limit", async ({ expect }) => {
|
|
211
|
+
// Test that the cache has the expected size limit
|
|
212
|
+
expect(mediaCache.maxSize).toBe(100 * 1024 * 1024);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe("BaseMediaEngine abort signal handling", () => {
|
|
217
|
+
test("should handle abort signals independently in BaseMediaEngine", async ({
|
|
218
|
+
expect,
|
|
219
|
+
}) => {
|
|
220
|
+
// Create a test host that actually has a fetch method
|
|
221
|
+
const host = {
|
|
222
|
+
fetch: vi
|
|
223
|
+
.fn()
|
|
224
|
+
.mockImplementation(
|
|
225
|
+
() =>
|
|
226
|
+
new Promise((resolve) =>
|
|
227
|
+
setTimeout(
|
|
228
|
+
() => resolve({ arrayBuffer: () => new ArrayBuffer(1024) }),
|
|
229
|
+
100,
|
|
230
|
+
),
|
|
231
|
+
),
|
|
232
|
+
),
|
|
233
|
+
} as any;
|
|
234
|
+
|
|
235
|
+
const engine = new TestMediaEngine(host);
|
|
236
|
+
|
|
237
|
+
// Create two abort controllers
|
|
238
|
+
const controller1 = new AbortController();
|
|
239
|
+
const controller2 = new AbortController();
|
|
240
|
+
|
|
241
|
+
// Start two requests with different abort signals to the same URL
|
|
242
|
+
const promise1 = engine.fetchMedia("test-url", controller1.signal);
|
|
243
|
+
const promise2 = engine.fetchMedia("test-url", controller2.signal);
|
|
244
|
+
|
|
245
|
+
// Abort only the first request
|
|
246
|
+
controller1.abort();
|
|
247
|
+
|
|
248
|
+
// First request should fail with AbortError
|
|
249
|
+
await expect(promise1).rejects.toThrow("Aborted");
|
|
250
|
+
|
|
251
|
+
// Second request should still succeed
|
|
252
|
+
const result2 = await promise2;
|
|
253
|
+
expect(result2).toBeInstanceOf(ArrayBuffer);
|
|
254
|
+
expect(result2.byteLength).toBe(1024);
|
|
255
|
+
|
|
256
|
+
// The network request should only be made once due to deduplication
|
|
257
|
+
// This validates our fix: deduplication works but signals are independent
|
|
258
|
+
expect(host.fetch).toHaveBeenCalledTimes(1);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
test("should handle immediate abort in BaseMediaEngine", async ({
|
|
262
|
+
expect,
|
|
263
|
+
}) => {
|
|
264
|
+
const host = {
|
|
265
|
+
fetch: vi
|
|
266
|
+
.fn()
|
|
267
|
+
.mockResolvedValue({ arrayBuffer: () => new ArrayBuffer(1024) }),
|
|
268
|
+
} as any;
|
|
269
|
+
|
|
270
|
+
const engine = new TestMediaEngine(host);
|
|
271
|
+
|
|
272
|
+
// Create an already aborted signal
|
|
273
|
+
const controller = new AbortController();
|
|
274
|
+
controller.abort();
|
|
275
|
+
|
|
276
|
+
// Request should fail immediately with AbortError
|
|
277
|
+
await expect(
|
|
278
|
+
engine.fetchMedia("test-url", controller.signal),
|
|
279
|
+
).rejects.toThrow("Aborted");
|
|
280
|
+
|
|
281
|
+
// No network request should be made since abort happens in wrapper method
|
|
282
|
+
expect(host.fetch).not.toHaveBeenCalled();
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
test("should maintain deduplication while respecting individual abort signals", async ({
|
|
286
|
+
expect,
|
|
287
|
+
}) => {
|
|
288
|
+
const host = {
|
|
289
|
+
fetch: vi
|
|
290
|
+
.fn()
|
|
291
|
+
.mockResolvedValue({ arrayBuffer: () => new ArrayBuffer(1024) }),
|
|
292
|
+
} as any;
|
|
293
|
+
|
|
294
|
+
const engine = new TestMediaEngine(host);
|
|
295
|
+
|
|
296
|
+
// Clear any existing cache to ensure clean test
|
|
297
|
+
mediaCache.clear();
|
|
298
|
+
|
|
299
|
+
// Make two requests without signals
|
|
300
|
+
const promise1 = engine.fetchMedia("test-url");
|
|
301
|
+
const promise2 = engine.fetchMedia("test-url");
|
|
302
|
+
|
|
303
|
+
// Both should succeed
|
|
304
|
+
const [result1, result2] = await Promise.all([promise1, promise2]);
|
|
305
|
+
expect(result1).toBeInstanceOf(ArrayBuffer);
|
|
306
|
+
expect(result2).toBeInstanceOf(ArrayBuffer);
|
|
307
|
+
|
|
308
|
+
// Should deduplicate to one network request
|
|
309
|
+
expect(host.fetch).toHaveBeenCalledTimes(1);
|
|
310
|
+
});
|
|
311
|
+
});
|
|
@@ -7,16 +7,19 @@ import type {
|
|
|
7
7
|
import { SizeAwareLRUCache } from "../../utils/LRUCache.js";
|
|
8
8
|
import type { EFMedia } from "../EFMedia.js";
|
|
9
9
|
|
|
10
|
-
//
|
|
11
|
-
const mediaCache = new SizeAwareLRUCache<string>(100 * 1024 * 1024);
|
|
10
|
+
// Global instances shared across all media engines
|
|
11
|
+
export const mediaCache = new SizeAwareLRUCache<string>(100 * 1024 * 1024); // 100MB cache limit
|
|
12
|
+
export const globalRequestDeduplicator = new RequestDeduplicator();
|
|
12
13
|
|
|
13
14
|
export abstract class BaseMediaEngine {
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
protected host: EFMedia;
|
|
16
|
+
|
|
17
|
+
constructor(host: EFMedia) {
|
|
18
|
+
this.host = host;
|
|
19
|
+
}
|
|
16
20
|
|
|
17
21
|
abstract get videoRendition(): VideoRendition | undefined;
|
|
18
22
|
abstract get audioRendition(): AudioRendition | undefined;
|
|
19
|
-
abstract get host(): EFMedia;
|
|
20
23
|
|
|
21
24
|
getVideoRendition(): VideoRendition {
|
|
22
25
|
if (!this.videoRendition) {
|
|
@@ -42,27 +45,179 @@ export abstract class BaseMediaEngine {
|
|
|
42
45
|
return `${rendition.src}-${rendition.id}-${segmentId}-${rendition.trackId}`;
|
|
43
46
|
}
|
|
44
47
|
|
|
48
|
+
/**
|
|
49
|
+
* Unified fetch method with caching and global deduplication
|
|
50
|
+
* All requests (media, manifest, init segments) go through this method
|
|
51
|
+
*/
|
|
52
|
+
protected async fetchWithCache(
|
|
53
|
+
url: string,
|
|
54
|
+
options: {
|
|
55
|
+
responseType: "arrayBuffer" | "json";
|
|
56
|
+
headers?: Record<string, string>;
|
|
57
|
+
signal?: AbortSignal;
|
|
58
|
+
},
|
|
59
|
+
): Promise<any> {
|
|
60
|
+
const { responseType, headers, signal } = options;
|
|
61
|
+
|
|
62
|
+
// Create cache key that includes URL and headers for proper isolation
|
|
63
|
+
// Note: We don't include signal in cache key as it would prevent proper deduplication
|
|
64
|
+
const cacheKey = headers ? `${url}:${JSON.stringify(headers)}` : url;
|
|
65
|
+
|
|
66
|
+
// Check cache first
|
|
67
|
+
const cached = mediaCache.get(cacheKey);
|
|
68
|
+
if (cached) {
|
|
69
|
+
// If we have a cached promise, we need to handle the caller's abort signal
|
|
70
|
+
// without affecting the underlying request that other instances might be using
|
|
71
|
+
if (signal) {
|
|
72
|
+
return this.handleAbortForCachedRequest(cached, signal);
|
|
73
|
+
}
|
|
74
|
+
return cached;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Use global deduplicator to prevent concurrent requests for the same resource
|
|
78
|
+
// Note: We do NOT pass the signal to the deduplicator - each caller manages their own abort
|
|
79
|
+
const promise = globalRequestDeduplicator.executeRequest(
|
|
80
|
+
cacheKey,
|
|
81
|
+
async () => {
|
|
82
|
+
try {
|
|
83
|
+
// Make the fetch request WITHOUT the signal - let each caller handle their own abort
|
|
84
|
+
// This prevents one instance's abort from affecting other instances using the shared cache
|
|
85
|
+
const response = await this.host.fetch(url, { headers });
|
|
86
|
+
|
|
87
|
+
if (responseType === "json") {
|
|
88
|
+
return response.json();
|
|
89
|
+
}
|
|
90
|
+
return response.arrayBuffer();
|
|
91
|
+
} catch (error) {
|
|
92
|
+
// If the request was aborted, don't cache the error
|
|
93
|
+
if (error instanceof DOMException && error.name === "AbortError") {
|
|
94
|
+
// Remove from cache so other requests can retry
|
|
95
|
+
mediaCache.delete(cacheKey);
|
|
96
|
+
}
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
|
|
102
|
+
// Cache the promise (not the result) to handle concurrent requests
|
|
103
|
+
mediaCache.set(cacheKey, promise);
|
|
104
|
+
|
|
105
|
+
// Handle the case where the promise might be aborted
|
|
106
|
+
promise.catch((error) => {
|
|
107
|
+
// If the request was aborted, remove it from cache to prevent corrupted data
|
|
108
|
+
if (error instanceof DOMException && error.name === "AbortError") {
|
|
109
|
+
mediaCache.delete(cacheKey);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
// If the caller has a signal, handle abort logic without affecting the underlying request
|
|
114
|
+
if (signal) {
|
|
115
|
+
return this.handleAbortForCachedRequest(promise, signal);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return promise;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Handles abort logic for a cached request without affecting the underlying fetch
|
|
123
|
+
* This allows multiple instances to share the same cached request while each
|
|
124
|
+
* manages their own abort behavior
|
|
125
|
+
*/
|
|
126
|
+
private handleAbortForCachedRequest<T>(
|
|
127
|
+
promise: Promise<T>,
|
|
128
|
+
signal: AbortSignal,
|
|
129
|
+
): Promise<T> {
|
|
130
|
+
// If signal is already aborted, reject immediately
|
|
131
|
+
if (signal.aborted) {
|
|
132
|
+
throw new DOMException("Aborted", "AbortError");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Return a promise that respects the caller's abort signal
|
|
136
|
+
// but doesn't affect the underlying cached request
|
|
137
|
+
return Promise.race([
|
|
138
|
+
promise,
|
|
139
|
+
new Promise<never>((_, reject) => {
|
|
140
|
+
signal.addEventListener("abort", () => {
|
|
141
|
+
reject(new DOMException("Aborted", "AbortError"));
|
|
142
|
+
});
|
|
143
|
+
}),
|
|
144
|
+
]);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Public wrapper methods that delegate to fetchWithCache
|
|
148
|
+
async fetchMedia(url: string, signal?: AbortSignal): Promise<ArrayBuffer> {
|
|
149
|
+
// Check abort signal immediately before any processing
|
|
150
|
+
if (signal?.aborted) {
|
|
151
|
+
throw new DOMException("Aborted", "AbortError");
|
|
152
|
+
}
|
|
153
|
+
return this.fetchWithCache(url, { responseType: "arrayBuffer", signal });
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async fetchManifest(url: string, signal?: AbortSignal): Promise<any> {
|
|
157
|
+
// Check abort signal immediately before any processing
|
|
158
|
+
if (signal?.aborted) {
|
|
159
|
+
throw new DOMException("Aborted", "AbortError");
|
|
160
|
+
}
|
|
161
|
+
return this.fetchWithCache(url, { responseType: "json", signal });
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async fetchMediaWithHeaders(
|
|
165
|
+
url: string,
|
|
166
|
+
headers: Record<string, string>,
|
|
167
|
+
signal?: AbortSignal,
|
|
168
|
+
): Promise<ArrayBuffer> {
|
|
169
|
+
// Check abort signal immediately before any processing
|
|
170
|
+
if (signal?.aborted) {
|
|
171
|
+
throw new DOMException("Aborted", "AbortError");
|
|
172
|
+
}
|
|
173
|
+
return this.fetchWithCache(url, {
|
|
174
|
+
responseType: "arrayBuffer",
|
|
175
|
+
headers,
|
|
176
|
+
signal,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Legacy methods for backward compatibility
|
|
181
|
+
async fetchMediaCache(
|
|
182
|
+
url: string,
|
|
183
|
+
signal?: AbortSignal,
|
|
184
|
+
): Promise<ArrayBuffer> {
|
|
185
|
+
return this.fetchMedia(url, signal);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async fetchManifestCache(url: string, signal?: AbortSignal): Promise<any> {
|
|
189
|
+
return this.fetchManifest(url, signal);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async fetchMediaCacheWithHeaders(
|
|
193
|
+
url: string,
|
|
194
|
+
headers: Record<string, string>,
|
|
195
|
+
signal?: AbortSignal,
|
|
196
|
+
): Promise<ArrayBuffer> {
|
|
197
|
+
return this.fetchMediaWithHeaders(url, headers, signal);
|
|
198
|
+
}
|
|
199
|
+
|
|
45
200
|
/**
|
|
46
201
|
* Abstract method for actual segment fetching - implemented by subclasses
|
|
47
202
|
*/
|
|
48
|
-
abstract
|
|
203
|
+
abstract fetchMediaSegment(
|
|
49
204
|
segmentId: number,
|
|
50
205
|
rendition: { trackId: number | undefined; src: string },
|
|
51
206
|
): Promise<ArrayBuffer>;
|
|
52
207
|
|
|
53
208
|
/**
|
|
54
209
|
* Fetch media segment with built-in deduplication
|
|
55
|
-
*
|
|
210
|
+
* Now uses global deduplication for all requests
|
|
56
211
|
*/
|
|
57
|
-
async
|
|
212
|
+
async fetchMediaSegmentWithDeduplication(
|
|
58
213
|
segmentId: number,
|
|
59
214
|
rendition: { trackId: number | undefined; src: string },
|
|
60
215
|
_signal?: AbortSignal,
|
|
61
216
|
): Promise<ArrayBuffer> {
|
|
62
217
|
const cacheKey = this.getSegmentCacheKey(segmentId, rendition);
|
|
63
218
|
|
|
64
|
-
return
|
|
65
|
-
return this.
|
|
219
|
+
return globalRequestDeduplicator.executeRequest(cacheKey, async () => {
|
|
220
|
+
return this.fetchMediaSegment(segmentId, rendition);
|
|
66
221
|
});
|
|
67
222
|
}
|
|
68
223
|
|
|
@@ -74,59 +229,21 @@ export abstract class BaseMediaEngine {
|
|
|
74
229
|
rendition: { src: string; trackId: number | undefined },
|
|
75
230
|
): boolean {
|
|
76
231
|
const cacheKey = this.getSegmentCacheKey(segmentId, rendition);
|
|
77
|
-
return
|
|
232
|
+
return globalRequestDeduplicator.isPending(cacheKey);
|
|
78
233
|
}
|
|
79
234
|
|
|
80
235
|
/**
|
|
81
236
|
* Get count of active segment requests (for debugging/monitoring)
|
|
82
237
|
*/
|
|
83
238
|
getActiveSegmentRequestCount(): number {
|
|
84
|
-
return
|
|
239
|
+
return globalRequestDeduplicator.getPendingCount();
|
|
85
240
|
}
|
|
86
241
|
|
|
87
242
|
/**
|
|
88
243
|
* Cancel all active segment requests (for cleanup)
|
|
89
244
|
*/
|
|
90
245
|
cancelAllSegmentRequests(): void {
|
|
91
|
-
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
async fetchMediaCache(mediaUrl: string) {
|
|
95
|
-
const cached = mediaCache.get(mediaUrl);
|
|
96
|
-
if (cached) {
|
|
97
|
-
return cached;
|
|
98
|
-
}
|
|
99
|
-
const promise = this.host
|
|
100
|
-
.fetch(mediaUrl)
|
|
101
|
-
.then((response) => response.arrayBuffer());
|
|
102
|
-
mediaCache.set(mediaUrl, promise);
|
|
103
|
-
return promise;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Enhanced caching method that supports custom headers (e.g., Range requests)
|
|
108
|
-
* Cache key includes both URL and headers for proper cache isolation
|
|
109
|
-
*/
|
|
110
|
-
async fetchMediaCacheWithHeaders(
|
|
111
|
-
mediaUrl: string,
|
|
112
|
-
headers?: Record<string, string>,
|
|
113
|
-
signal?: AbortSignal,
|
|
114
|
-
) {
|
|
115
|
-
// Create a cache key that includes both URL and headers
|
|
116
|
-
const cacheKey = headers
|
|
117
|
-
? `${mediaUrl}:${JSON.stringify(headers)}`
|
|
118
|
-
: mediaUrl;
|
|
119
|
-
|
|
120
|
-
const cached = mediaCache.get(cacheKey);
|
|
121
|
-
if (cached) {
|
|
122
|
-
return cached;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
const promise = this.host
|
|
126
|
-
.fetch(mediaUrl, { headers, signal })
|
|
127
|
-
.then((response) => response.arrayBuffer());
|
|
128
|
-
mediaCache.set(cacheKey, promise);
|
|
129
|
-
return promise;
|
|
246
|
+
globalRequestDeduplicator.clear();
|
|
130
247
|
}
|
|
131
248
|
|
|
132
249
|
/**
|