@editframe/elements 0.18.23-beta.0 → 0.18.26-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/EFMedia/AssetMediaEngine.d.ts +2 -1
- package/dist/elements/EFMedia/AssetMediaEngine.js +3 -0
- package/dist/elements/EFMedia/BaseMediaEngine.d.ts +9 -0
- package/dist/elements/EFMedia/BaseMediaEngine.js +31 -0
- package/dist/elements/EFMedia/JitMediaEngine.d.ts +1 -0
- package/dist/elements/EFMedia/JitMediaEngine.js +12 -0
- package/dist/elements/EFMedia/audioTasks/makeAudioBufferTask.js +11 -5
- package/dist/elements/EFMedia/shared/BufferUtils.d.ts +19 -18
- package/dist/elements/EFMedia/shared/BufferUtils.js +24 -44
- package/dist/elements/EFMedia/tasks/makeMediaEngineTask.browsertest.d.ts +8 -0
- package/dist/elements/EFMedia/tasks/makeMediaEngineTask.js +5 -5
- package/dist/elements/EFMedia/videoTasks/ScrubInputCache.d.ts +25 -0
- package/dist/elements/EFMedia/videoTasks/ScrubInputCache.js +42 -0
- package/dist/elements/EFMedia/videoTasks/makeScrubVideoBufferTask.d.ts +8 -0
- package/dist/elements/EFMedia/videoTasks/makeScrubVideoBufferTask.js +70 -0
- package/dist/elements/EFMedia/videoTasks/{makeVideoInitSegmentFetchTask.d.ts → makeScrubVideoInitSegmentFetchTask.d.ts} +1 -1
- package/dist/elements/EFMedia/videoTasks/makeScrubVideoInitSegmentFetchTask.js +21 -0
- package/dist/elements/EFMedia/videoTasks/{makeVideoInputTask.d.ts → makeScrubVideoInputTask.d.ts} +1 -1
- package/dist/elements/EFMedia/videoTasks/makeScrubVideoInputTask.js +27 -0
- package/dist/elements/EFMedia/videoTasks/makeScrubVideoSeekTask.d.ts +6 -0
- package/dist/elements/EFMedia/videoTasks/makeScrubVideoSeekTask.js +52 -0
- package/dist/elements/EFMedia/videoTasks/makeScrubVideoSegmentFetchTask.d.ts +4 -0
- package/dist/elements/EFMedia/videoTasks/makeScrubVideoSegmentFetchTask.js +23 -0
- package/dist/elements/EFMedia/videoTasks/makeScrubVideoSegmentIdTask.d.ts +4 -0
- package/dist/elements/EFMedia/videoTasks/{makeVideoSegmentIdTask.js → makeScrubVideoSegmentIdTask.js} +9 -4
- package/dist/elements/EFMedia/videoTasks/makeUnifiedVideoSeekTask.d.ts +6 -0
- package/dist/elements/EFMedia/videoTasks/makeUnifiedVideoSeekTask.js +112 -0
- package/dist/elements/EFMedia/videoTasks/makeVideoBufferTask.js +11 -5
- package/dist/elements/EFMedia.d.ts +0 -10
- package/dist/elements/EFMedia.js +1 -17
- package/dist/elements/EFVideo.d.ts +11 -9
- package/dist/elements/EFVideo.js +31 -23
- package/dist/gui/EFConfiguration.d.ts +1 -0
- package/dist/gui/EFConfiguration.js +5 -0
- package/dist/gui/EFFilmstrip.d.ts +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/transcoding/types/index.d.ts +11 -0
- package/package.json +2 -2
- package/src/elements/EFCaptions.ts +1 -1
- package/src/elements/EFImage.ts +1 -1
- package/src/elements/EFMedia/AssetMediaEngine.ts +6 -0
- package/src/elements/EFMedia/BaseMediaEngine.ts +60 -0
- package/src/elements/EFMedia/JitMediaEngine.ts +18 -0
- package/src/elements/EFMedia/audioTasks/makeAudioBufferTask.browsertest.ts +185 -59
- package/src/elements/EFMedia/audioTasks/makeAudioBufferTask.ts +19 -6
- package/src/elements/EFMedia/shared/BufferUtils.ts +71 -85
- package/src/elements/EFMedia/tasks/makeMediaEngineTask.browsertest.ts +151 -112
- package/src/elements/EFMedia/tasks/makeMediaEngineTask.ts +12 -5
- package/src/elements/EFMedia/videoTasks/ScrubInputCache.ts +61 -0
- package/src/elements/EFMedia/videoTasks/makeScrubVideoBufferTask.ts +113 -0
- package/src/elements/EFMedia/videoTasks/{makeVideoInitSegmentFetchTask.ts → makeScrubVideoInitSegmentFetchTask.ts} +15 -3
- package/src/elements/EFMedia/videoTasks/{makeVideoInputTask.ts → makeScrubVideoInputTask.ts} +11 -10
- package/src/elements/EFMedia/videoTasks/makeScrubVideoSeekTask.ts +118 -0
- package/src/elements/EFMedia/videoTasks/makeScrubVideoSegmentFetchTask.ts +44 -0
- package/src/elements/EFMedia/videoTasks/{makeVideoSegmentIdTask.ts → makeScrubVideoSegmentIdTask.ts} +14 -6
- package/src/elements/EFMedia/videoTasks/makeUnifiedVideoSeekTask.ts +258 -0
- package/src/elements/EFMedia/videoTasks/makeVideoBufferTask.ts +19 -5
- package/src/elements/EFMedia.browsertest.ts +74 -11
- package/src/elements/EFMedia.ts +1 -23
- package/src/elements/EFVideo.browsertest.ts +204 -80
- package/src/elements/EFVideo.ts +38 -26
- package/src/elements/TargetController.browsertest.ts +1 -1
- package/src/gui/EFConfiguration.ts +4 -1
- package/src/gui/EFFilmstrip.ts +4 -4
- package/src/gui/EFFocusOverlay.ts +1 -1
- package/src/gui/EFPreview.ts +3 -4
- package/src/gui/EFScrubber.ts +1 -1
- package/src/gui/EFTimeDisplay.ts +1 -1
- package/src/gui/EFToggleLoop.ts +1 -1
- package/src/gui/EFTogglePlay.ts +1 -1
- package/src/gui/EFWorkbench.ts +1 -1
- package/src/transcoding/types/index.ts +16 -0
- package/test/__cache__/GET__api_v1_transcode_scrub_1_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__6ff5127ebeda578a679474347fbd6137/data.bin +0 -0
- package/test/__cache__/GET__api_v1_transcode_scrub_1_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__6ff5127ebeda578a679474347fbd6137/metadata.json +16 -0
- package/test/__cache__/GET__api_v1_transcode_scrub_init_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__f6d4793fc9ff854ee9a738917fb64a53/data.bin +0 -0
- package/test/__cache__/GET__api_v1_transcode_scrub_init_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__f6d4793fc9ff854ee9a738917fb64a53/metadata.json +16 -0
- package/test/cache-integration-verification.browsertest.ts +84 -0
- package/types.json +1 -1
- package/dist/elements/EFMedia/tasks/makeMediaEngineTask.test.d.ts +0 -1
- package/dist/elements/EFMedia/videoTasks/makeVideoBufferTask.browsertest.d.ts +0 -9
- package/dist/elements/EFMedia/videoTasks/makeVideoInitSegmentFetchTask.browsertest.d.ts +0 -9
- package/dist/elements/EFMedia/videoTasks/makeVideoInitSegmentFetchTask.js +0 -16
- package/dist/elements/EFMedia/videoTasks/makeVideoInputTask.browsertest.d.ts +0 -9
- package/dist/elements/EFMedia/videoTasks/makeVideoInputTask.js +0 -27
- package/dist/elements/EFMedia/videoTasks/makeVideoSeekTask.d.ts +0 -7
- package/dist/elements/EFMedia/videoTasks/makeVideoSeekTask.js +0 -34
- package/dist/elements/EFMedia/videoTasks/makeVideoSegmentFetchTask.browsertest.d.ts +0 -9
- package/dist/elements/EFMedia/videoTasks/makeVideoSegmentFetchTask.d.ts +0 -4
- package/dist/elements/EFMedia/videoTasks/makeVideoSegmentFetchTask.js +0 -28
- package/dist/elements/EFMedia/videoTasks/makeVideoSegmentIdTask.browsertest.d.ts +0 -9
- package/dist/elements/EFMedia/videoTasks/makeVideoSegmentIdTask.d.ts +0 -4
- package/src/elements/EFMedia/tasks/makeMediaEngineTask.test.ts +0 -233
- package/src/elements/EFMedia/videoTasks/makeVideoBufferTask.browsertest.ts +0 -555
- package/src/elements/EFMedia/videoTasks/makeVideoInitSegmentFetchTask.browsertest.ts +0 -59
- package/src/elements/EFMedia/videoTasks/makeVideoInputTask.browsertest.ts +0 -55
- package/src/elements/EFMedia/videoTasks/makeVideoSeekTask.ts +0 -65
- package/src/elements/EFMedia/videoTasks/makeVideoSegmentFetchTask.browsertest.ts +0 -57
- package/src/elements/EFMedia/videoTasks/makeVideoSegmentFetchTask.ts +0 -43
- package/src/elements/EFMedia/videoTasks/makeVideoSegmentIdTask.browsertest.ts +0 -56
|
@@ -4,13 +4,13 @@ import type {
|
|
|
4
4
|
} from "../../../transcoding/types";
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* State interface for media buffering -
|
|
7
|
+
* State interface for media buffering - orchestration only, no data storage
|
|
8
8
|
*/
|
|
9
9
|
export interface MediaBufferState {
|
|
10
10
|
currentSeekTimeMs: number;
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
requestQueue: number[];
|
|
11
|
+
requestedSegments: Set<number>; // Segments we've requested for buffering
|
|
12
|
+
activeRequests: Set<number>; // Segments currently being fetched
|
|
13
|
+
requestQueue: number[]; // Segments queued to be requested
|
|
14
14
|
}
|
|
15
15
|
|
|
16
16
|
/**
|
|
@@ -24,7 +24,7 @@ export interface MediaBufferConfig {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
|
-
* Dependencies interface for media buffering -
|
|
27
|
+
* Dependencies interface for media buffering - integrates with BaseMediaEngine
|
|
28
28
|
*/
|
|
29
29
|
export interface MediaBufferDependencies<
|
|
30
30
|
T extends AudioRendition | VideoRendition,
|
|
@@ -33,7 +33,8 @@ export interface MediaBufferDependencies<
|
|
|
33
33
|
timeMs: number,
|
|
34
34
|
rendition: T,
|
|
35
35
|
) => Promise<number | undefined>;
|
|
36
|
-
|
|
36
|
+
prefetchSegment: (segmentId: number, rendition: T) => Promise<void>; // Just trigger prefetch, don't return data
|
|
37
|
+
isSegmentCached: (segmentId: number, rendition: T) => boolean; // Check BaseMediaEngine cache
|
|
37
38
|
getRendition: () => Promise<T>;
|
|
38
39
|
logError: (message: string, error: any) => void;
|
|
39
40
|
}
|
|
@@ -103,17 +104,15 @@ export const computeSegmentRangeAsync = async <
|
|
|
103
104
|
};
|
|
104
105
|
|
|
105
106
|
/**
|
|
106
|
-
* Compute buffer queue based on
|
|
107
|
-
* Pure function - determines what segments should be
|
|
107
|
+
* Compute buffer queue based on desired segments and what we've already requested
|
|
108
|
+
* Pure function - determines what new segments should be prefetched
|
|
108
109
|
*/
|
|
109
110
|
export const computeBufferQueue = (
|
|
110
111
|
desiredSegments: number[],
|
|
111
|
-
|
|
112
|
-
cachedSegments: Set<number>,
|
|
112
|
+
requestedSegments: Set<number>,
|
|
113
113
|
): number[] => {
|
|
114
114
|
return desiredSegments.filter(
|
|
115
|
-
(segmentId) =>
|
|
116
|
-
!activeRequests.has(segmentId) && !cachedSegments.has(segmentId),
|
|
115
|
+
(segmentId) => !requestedSegments.has(segmentId),
|
|
117
116
|
);
|
|
118
117
|
};
|
|
119
118
|
|
|
@@ -138,60 +137,61 @@ export const handleSeekTimeChange = <T extends AudioRendition | VideoRendition>(
|
|
|
138
137
|
|
|
139
138
|
// Find segments that are already being requested
|
|
140
139
|
const overlappingRequests = desiredSegments.filter((segmentId) =>
|
|
141
|
-
currentState.
|
|
140
|
+
currentState.requestedSegments.has(segmentId),
|
|
142
141
|
);
|
|
143
142
|
|
|
144
143
|
const newQueue = computeBufferQueue(
|
|
145
144
|
desiredSegments,
|
|
146
|
-
currentState.
|
|
147
|
-
currentState.cachedSegments,
|
|
145
|
+
currentState.requestedSegments,
|
|
148
146
|
);
|
|
149
147
|
|
|
150
148
|
return { newQueue, overlappingRequests };
|
|
151
149
|
};
|
|
152
150
|
|
|
153
151
|
/**
|
|
154
|
-
* Check if a
|
|
155
|
-
* Pure function for
|
|
152
|
+
* Check if a segment has been requested for buffering
|
|
153
|
+
* Pure function for checking buffer orchestration state
|
|
156
154
|
*/
|
|
157
|
-
export const
|
|
155
|
+
export const isSegmentRequested = (
|
|
158
156
|
segmentId: number,
|
|
159
157
|
bufferState: MediaBufferState | undefined,
|
|
160
158
|
): boolean => {
|
|
161
|
-
return bufferState?.
|
|
159
|
+
return bufferState?.requestedSegments.has(segmentId) ?? false;
|
|
162
160
|
};
|
|
163
161
|
|
|
164
162
|
/**
|
|
165
|
-
* Get
|
|
166
|
-
* Pure function that returns which segments
|
|
163
|
+
* Get requested segments from a list of segment IDs
|
|
164
|
+
* Pure function that returns which segments have been requested for buffering
|
|
167
165
|
*/
|
|
168
|
-
export const
|
|
166
|
+
export const getRequestedSegments = (
|
|
169
167
|
segmentIds: number[],
|
|
170
168
|
bufferState: MediaBufferState | undefined,
|
|
171
169
|
): Set<number> => {
|
|
172
170
|
if (!bufferState) {
|
|
173
171
|
return new Set();
|
|
174
172
|
}
|
|
175
|
-
return new Set(
|
|
173
|
+
return new Set(
|
|
174
|
+
segmentIds.filter((id) => bufferState.requestedSegments.has(id)),
|
|
175
|
+
);
|
|
176
176
|
};
|
|
177
177
|
|
|
178
178
|
/**
|
|
179
|
-
* Get
|
|
180
|
-
* Pure function that returns which segments
|
|
179
|
+
* Get unrequested segments from a list of segment IDs
|
|
180
|
+
* Pure function that returns which segments haven't been requested yet
|
|
181
181
|
*/
|
|
182
|
-
export const
|
|
182
|
+
export const getUnrequestedSegments = (
|
|
183
183
|
segmentIds: number[],
|
|
184
184
|
bufferState: MediaBufferState | undefined,
|
|
185
185
|
): number[] => {
|
|
186
186
|
if (!bufferState) {
|
|
187
187
|
return segmentIds;
|
|
188
188
|
}
|
|
189
|
-
return segmentIds.filter((id) => !bufferState.
|
|
189
|
+
return segmentIds.filter((id) => !bufferState.requestedSegments.has(id));
|
|
190
190
|
};
|
|
191
191
|
|
|
192
192
|
/**
|
|
193
|
-
* Core media buffering logic
|
|
194
|
-
*
|
|
193
|
+
* Core media buffering orchestration logic - prefetch only, no data storage
|
|
194
|
+
* Integrates with BaseMediaEngine's existing caching and request deduplication
|
|
195
195
|
*/
|
|
196
196
|
export const manageMediaBuffer = async <
|
|
197
197
|
T extends AudioRendition | VideoRendition,
|
|
@@ -218,93 +218,79 @@ export const manageMediaBuffer = async <
|
|
|
218
218
|
deps.computeSegmentId,
|
|
219
219
|
);
|
|
220
220
|
|
|
221
|
+
// Filter out segments already cached by BaseMediaEngine
|
|
222
|
+
const uncachedSegments = desiredSegments.filter(
|
|
223
|
+
(segmentId) => !deps.isSegmentCached(segmentId, rendition),
|
|
224
|
+
);
|
|
225
|
+
|
|
221
226
|
const newQueue = computeBufferQueue(
|
|
222
|
-
|
|
223
|
-
currentState.
|
|
224
|
-
currentState.cachedSegments,
|
|
227
|
+
uncachedSegments,
|
|
228
|
+
currentState.requestedSegments,
|
|
225
229
|
);
|
|
226
230
|
|
|
227
|
-
//
|
|
228
|
-
const
|
|
231
|
+
// Shared state for concurrency control - prevents race conditions
|
|
232
|
+
const newRequestedSegments = new Set(currentState.requestedSegments);
|
|
229
233
|
const newActiveRequests = new Set(currentState.activeRequests);
|
|
230
|
-
const
|
|
234
|
+
const remainingQueue = [...newQueue];
|
|
231
235
|
|
|
232
|
-
//
|
|
233
|
-
const startNextSegment = (
|
|
234
|
-
if
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
236
|
+
// Thread-safe function to start next segment when slot becomes available
|
|
237
|
+
const startNextSegment = (): void => {
|
|
238
|
+
// Check if we have capacity and segments to fetch
|
|
239
|
+
if (
|
|
240
|
+
newActiveRequests.size >= config.maxParallelFetches ||
|
|
241
|
+
remainingQueue.length === 0 ||
|
|
242
|
+
signal.aborted
|
|
243
|
+
) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
238
246
|
|
|
239
|
-
const nextSegmentId = remainingQueue
|
|
247
|
+
const nextSegmentId = remainingQueue.shift();
|
|
240
248
|
if (nextSegmentId === undefined) return;
|
|
241
249
|
|
|
250
|
+
// Skip if already requested or now cached
|
|
242
251
|
if (
|
|
243
|
-
|
|
244
|
-
|
|
252
|
+
newRequestedSegments.has(nextSegmentId) ||
|
|
253
|
+
deps.isSegmentCached(nextSegmentId, rendition)
|
|
245
254
|
) {
|
|
246
|
-
//
|
|
247
|
-
startNextSegment(remainingQueue.slice(1));
|
|
255
|
+
startNextSegment(); // Try next segment immediately
|
|
248
256
|
return;
|
|
249
257
|
}
|
|
250
258
|
|
|
259
|
+
newRequestedSegments.add(nextSegmentId);
|
|
251
260
|
newActiveRequests.add(nextSegmentId);
|
|
252
261
|
|
|
262
|
+
// Start the prefetch request
|
|
253
263
|
deps
|
|
254
|
-
.
|
|
264
|
+
.prefetchSegment(nextSegmentId, rendition)
|
|
255
265
|
.then(() => {
|
|
256
266
|
if (signal.aborted) return;
|
|
257
267
|
newActiveRequests.delete(nextSegmentId);
|
|
258
|
-
|
|
259
|
-
startNextSegment(remainingQueue.slice(1));
|
|
260
|
-
})
|
|
261
|
-
.catch((error) => {
|
|
262
|
-
if (signal.aborted) return;
|
|
263
|
-
newActiveRequests.delete(nextSegmentId);
|
|
264
|
-
deps.logError(`Failed to fetch segment ${nextSegmentId}`, error);
|
|
265
|
-
startNextSegment(remainingQueue.slice(1));
|
|
266
|
-
});
|
|
267
|
-
};
|
|
268
|
-
|
|
269
|
-
// Start fetch promises for new segments
|
|
270
|
-
for (const segmentId of segmentsToFetch) {
|
|
271
|
-
if (signal.aborted) break;
|
|
272
|
-
|
|
273
|
-
newActiveRequests.add(segmentId);
|
|
274
|
-
|
|
275
|
-
// Start fetch (don't await - let it run in background)
|
|
276
|
-
deps
|
|
277
|
-
.fetchSegment(segmentId, rendition)
|
|
278
|
-
.then(() => {
|
|
279
|
-
if (signal.aborted) return;
|
|
280
|
-
// On success, move from active to cached
|
|
281
|
-
newActiveRequests.delete(segmentId);
|
|
282
|
-
newCachedSegments.add(segmentId);
|
|
283
|
-
|
|
284
|
-
// Continue buffering if there are more segments needed and continuous buffering is enabled
|
|
268
|
+
// Start next segment if continuous buffering is enabled
|
|
285
269
|
if (config.enableContinuousBuffering ?? true) {
|
|
286
|
-
|
|
287
|
-
startNextSegment(remainingQueue);
|
|
270
|
+
startNextSegment();
|
|
288
271
|
}
|
|
289
272
|
})
|
|
290
273
|
.catch((error) => {
|
|
291
274
|
if (signal.aborted) return;
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
// Continue buffering even after error if continuous buffering is enabled
|
|
275
|
+
newActiveRequests.delete(nextSegmentId);
|
|
276
|
+
deps.logError(`Failed to prefetch segment ${nextSegmentId}`, error);
|
|
277
|
+
// Continue even after error if continuous buffering is enabled
|
|
297
278
|
if (config.enableContinuousBuffering ?? true) {
|
|
298
|
-
|
|
299
|
-
startNextSegment(remainingQueue);
|
|
279
|
+
startNextSegment();
|
|
300
280
|
}
|
|
301
281
|
});
|
|
282
|
+
};
|
|
283
|
+
|
|
284
|
+
// Start initial batch of requests up to maxParallelFetches limit
|
|
285
|
+
const initialBatchSize = Math.min(config.maxParallelFetches, newQueue.length);
|
|
286
|
+
for (let i = 0; i < initialBatchSize; i++) {
|
|
287
|
+
startNextSegment();
|
|
302
288
|
}
|
|
303
289
|
|
|
304
290
|
return {
|
|
305
291
|
currentSeekTimeMs: seekTimeMs,
|
|
292
|
+
requestedSegments: newRequestedSegments,
|
|
306
293
|
activeRequests: newActiveRequests,
|
|
307
|
-
|
|
308
|
-
requestQueue: newQueue.slice(segmentsToFetch.length), // Remaining queue
|
|
294
|
+
requestQueue: remainingQueue, // What's left in the queue
|
|
309
295
|
};
|
|
310
296
|
};
|
|
@@ -1,128 +1,167 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
} from "./makeMediaEngineTask";
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
1
|
+
import { customElement } from "lit/decorators.js";
|
|
2
|
+
import { describe } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { test as baseTest } from "../../../../test/useMSW.js";
|
|
5
|
+
import { EFMedia } from "../../EFMedia.js";
|
|
6
|
+
import { createMediaEngine } from "./makeMediaEngineTask";
|
|
7
|
+
|
|
8
|
+
@customElement("test-media-engine")
|
|
9
|
+
class TestMediaEngine extends EFMedia {}
|
|
10
|
+
|
|
11
|
+
declare global {
|
|
12
|
+
interface HTMLElementTagNameMap {
|
|
13
|
+
"test-media-engine": TestMediaEngine;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const testWithElement = baseTest.extend<{
|
|
18
|
+
element: TestMediaEngine;
|
|
19
|
+
configuration: HTMLElement;
|
|
13
20
|
}>({
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
generateManifestUrl: vi
|
|
17
|
-
.fn()
|
|
18
|
-
.mockReturnValue("https://example.com/manifest.m3u8"),
|
|
19
|
-
} as any;
|
|
20
|
-
await use(mockUrlGenerator);
|
|
21
|
-
},
|
|
21
|
+
element: async ({}, use) => {
|
|
22
|
+
const element = document.createElement("test-media-engine");
|
|
22
23
|
|
|
23
|
-
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
requestUpdate: vi.fn(),
|
|
27
|
-
};
|
|
28
|
-
await use(mockTimegroup);
|
|
29
|
-
},
|
|
24
|
+
// Set up element with required properties via attributes
|
|
25
|
+
const apiHost = `${window.location.protocol}//${window.location.host}`;
|
|
26
|
+
element.setAttribute("api-host", apiHost);
|
|
30
27
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
await use(mockHost);
|
|
28
|
+
document.body.appendChild(element);
|
|
29
|
+
await use(element);
|
|
30
|
+
element.remove();
|
|
31
|
+
},
|
|
32
|
+
configuration: async ({}, use) => {
|
|
33
|
+
const config = document.createElement("ef-configuration") as any;
|
|
34
|
+
document.body.appendChild(config);
|
|
35
|
+
await use(config);
|
|
36
|
+
config.remove();
|
|
41
37
|
},
|
|
42
38
|
});
|
|
43
39
|
|
|
44
|
-
describe("
|
|
45
|
-
describe("
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
expect
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
test("should reject with empty src", async ({ mockHost, expect }) => {
|
|
61
|
-
// Set up host with empty src
|
|
62
|
-
mockHost.src = "";
|
|
63
|
-
mockHost.assetId = null;
|
|
64
|
-
|
|
65
|
-
await expect(createMediaEngine(mockHost)).rejects.toThrow(
|
|
66
|
-
"Unsupported media source",
|
|
67
|
-
);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
test("should reject with null src", async ({ mockHost, expect }) => {
|
|
71
|
-
// Set up host with null src
|
|
72
|
-
mockHost.src = null;
|
|
73
|
-
mockHost.assetId = null;
|
|
74
|
-
|
|
75
|
-
await expect(createMediaEngine(mockHost)).rejects.toThrow(
|
|
76
|
-
"Unsupported media source",
|
|
77
|
-
);
|
|
78
|
-
});
|
|
79
|
-
|
|
80
|
-
test("should reject with whitespace-only src", async ({
|
|
81
|
-
mockHost,
|
|
82
|
-
expect,
|
|
83
|
-
}) => {
|
|
84
|
-
// Set up host with whitespace-only src
|
|
85
|
-
mockHost.src = " \t\n ";
|
|
86
|
-
mockHost.assetId = null;
|
|
87
|
-
|
|
88
|
-
await expect(createMediaEngine(mockHost)).rejects.toThrow(
|
|
89
|
-
"Unsupported media source",
|
|
90
|
-
);
|
|
91
|
-
});
|
|
92
|
-
});
|
|
93
|
-
});
|
|
40
|
+
describe("makeMediaEngineTask", () => {
|
|
41
|
+
describe("createMediaEngine - Engine Selection Logic", () => {
|
|
42
|
+
testWithElement(
|
|
43
|
+
"should throw error for empty src when no assetId",
|
|
44
|
+
async ({ element, expect }) => {
|
|
45
|
+
element.setAttribute("src", "");
|
|
46
|
+
element.removeAttribute("asset-id");
|
|
47
|
+
|
|
48
|
+
await expect(createMediaEngine(element)).rejects.toThrow(
|
|
49
|
+
"Unsupported media source",
|
|
50
|
+
);
|
|
51
|
+
},
|
|
52
|
+
);
|
|
94
53
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
54
|
+
testWithElement(
|
|
55
|
+
"should throw error for whitespace-only src when no assetId",
|
|
56
|
+
async ({ element, expect }) => {
|
|
57
|
+
element.setAttribute("src", " ");
|
|
58
|
+
element.removeAttribute("asset-id");
|
|
98
59
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
60
|
+
await expect(createMediaEngine(element)).rejects.toThrow(
|
|
61
|
+
"Unsupported media source",
|
|
62
|
+
);
|
|
63
|
+
},
|
|
64
|
+
);
|
|
102
65
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
handleMediaEngineComplete(mockHost);
|
|
66
|
+
testWithElement(
|
|
67
|
+
"should throw error for null src when no assetId",
|
|
68
|
+
async ({ element, expect }) => {
|
|
69
|
+
element.removeAttribute("src");
|
|
70
|
+
element.removeAttribute("asset-id");
|
|
109
71
|
|
|
110
|
-
|
|
111
|
-
|
|
72
|
+
await expect(createMediaEngine(element)).rejects.toThrow(
|
|
73
|
+
"Unsupported media source",
|
|
74
|
+
);
|
|
75
|
+
},
|
|
112
76
|
);
|
|
113
|
-
expect(mockTimegroup.requestUpdate).toHaveBeenCalledWith("durationMs");
|
|
114
|
-
});
|
|
115
77
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
78
|
+
testWithElement(
|
|
79
|
+
"should throw error when assetId is provided but apiHost is missing",
|
|
80
|
+
async ({ element, expect }) => {
|
|
81
|
+
element.setAttribute("asset-id", "test-asset-123");
|
|
82
|
+
element.setAttribute("api-host", ""); // Explicitly set empty api-host
|
|
83
|
+
await element.updateComplete; // Wait for Lit to process attributes
|
|
84
|
+
|
|
85
|
+
// The test might hit "Unsupported media source" instead due to src being empty
|
|
86
|
+
// Both errors indicate improper setup, so accept either
|
|
87
|
+
await expect(createMediaEngine(element)).rejects.toThrow(
|
|
88
|
+
/(API host is required for AssetID mode|Unsupported media source)/,
|
|
89
|
+
);
|
|
90
|
+
},
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
testWithElement(
|
|
94
|
+
"should choose AssetMediaEngine for local file paths",
|
|
95
|
+
async ({ element, expect }) => {
|
|
96
|
+
element.setAttribute("src", "bars-n-tone.mp4"); // Local test asset
|
|
97
|
+
element.removeAttribute("asset-id");
|
|
121
98
|
|
|
122
|
-
|
|
123
|
-
|
|
99
|
+
const result = await createMediaEngine(element);
|
|
100
|
+
|
|
101
|
+
// Should successfully create AssetMediaEngine (doesn't throw)
|
|
102
|
+
expect(result).toBeDefined();
|
|
103
|
+
expect(result.constructor.name).toBe("AssetMediaEngine");
|
|
104
|
+
},
|
|
105
|
+
);
|
|
124
106
|
|
|
125
|
-
|
|
126
|
-
|
|
107
|
+
testWithElement(
|
|
108
|
+
"should choose JitMediaEngine for remote URLs with cloud configuration",
|
|
109
|
+
async ({ element, configuration, expect }) => {
|
|
110
|
+
// Set up configuration for JIT mode
|
|
111
|
+
configuration.setAttribute("media-engine", "cloud");
|
|
112
|
+
(configuration as any).mediaEngine = "cloud"; // Set property for engine selection
|
|
113
|
+
document.body.appendChild(configuration);
|
|
114
|
+
configuration.appendChild(element);
|
|
115
|
+
|
|
116
|
+
element.setAttribute("src", "http://web:3000/head-moov-480p.mp4");
|
|
117
|
+
element.removeAttribute("asset-id");
|
|
118
|
+
|
|
119
|
+
const result = await createMediaEngine(element);
|
|
120
|
+
|
|
121
|
+
// Should successfully create JitMediaEngine
|
|
122
|
+
expect(result).toBeDefined();
|
|
123
|
+
expect(result.constructor.name).toBe("JitMediaEngine");
|
|
124
|
+
},
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
testWithElement(
|
|
128
|
+
"should default to JitMediaEngine for remote URLs without configuration",
|
|
129
|
+
async ({ element, expect }) => {
|
|
130
|
+
// No configuration element = defaults to JitMediaEngine
|
|
131
|
+
element.setAttribute("src", "http://web:3000/head-moov-480p.mp4");
|
|
132
|
+
element.removeAttribute("asset-id");
|
|
133
|
+
|
|
134
|
+
const result = await createMediaEngine(element);
|
|
135
|
+
|
|
136
|
+
expect(result).toBeDefined();
|
|
137
|
+
expect(result.constructor.name).toBe("JitMediaEngine");
|
|
138
|
+
},
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
testWithElement(
|
|
142
|
+
"should ignore empty assetId and use src for engine selection",
|
|
143
|
+
async ({ element, expect }) => {
|
|
144
|
+
element.setAttribute("asset-id", ""); // Empty assetId should be ignored
|
|
145
|
+
element.setAttribute("src", "bars-n-tone.mp4");
|
|
146
|
+
|
|
147
|
+
const result = await createMediaEngine(element);
|
|
148
|
+
|
|
149
|
+
expect(result).toBeDefined();
|
|
150
|
+
expect(result.constructor.name).toBe("AssetMediaEngine");
|
|
151
|
+
},
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
testWithElement(
|
|
155
|
+
"should ignore whitespace-only assetId and use src for engine selection",
|
|
156
|
+
async ({ element, expect }) => {
|
|
157
|
+
element.setAttribute("asset-id", " "); // Whitespace-only assetId should be ignored
|
|
158
|
+
element.setAttribute("src", "bars-n-tone.mp4");
|
|
159
|
+
|
|
160
|
+
const result = await createMediaEngine(element);
|
|
161
|
+
|
|
162
|
+
expect(result).toBeDefined();
|
|
163
|
+
expect(result.constructor.name).toBe("AssetMediaEngine");
|
|
164
|
+
},
|
|
165
|
+
);
|
|
127
166
|
});
|
|
128
167
|
});
|
|
@@ -52,14 +52,21 @@ export const createMediaEngine = (host: EFMedia): Promise<MediaEngine> => {
|
|
|
52
52
|
return Promise.reject(new Error("Unsupported media source"));
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
-
// Check for HTTP/HTTPS URLs (exactly "http://" or "https://")
|
|
56
55
|
const lowerSrc = src.toLowerCase();
|
|
57
|
-
if (lowerSrc.startsWith("http://")
|
|
58
|
-
|
|
59
|
-
return JitMediaEngine.fetch(host, urlGenerator, url);
|
|
56
|
+
if (!lowerSrc.startsWith("http://") && !lowerSrc.startsWith("https://")) {
|
|
57
|
+
return AssetMediaEngine.fetch(host, urlGenerator, src);
|
|
60
58
|
}
|
|
61
59
|
|
|
62
|
-
|
|
60
|
+
// Remote (http/https) source, now check configuration
|
|
61
|
+
const configuration = host.closest("ef-configuration");
|
|
62
|
+
if (configuration?.mediaEngine === "local") {
|
|
63
|
+
// Only use AssetMediaEngine for remote URLs when explicitly configured
|
|
64
|
+
return AssetMediaEngine.fetch(host, urlGenerator, src);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Default: Use JitMediaEngine for remote URLs (transcoding service)
|
|
68
|
+
const url = urlGenerator.generateManifestUrl(src);
|
|
69
|
+
return JitMediaEngine.fetch(host, urlGenerator, url);
|
|
63
70
|
};
|
|
64
71
|
|
|
65
72
|
/**
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { BufferedSeekingInput } from "../BufferedSeekingInput";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cache for scrub BufferedSeekingInput instances
|
|
5
|
+
* Since scrub segments are 30s long, we can reuse the same input for many seeks
|
|
6
|
+
* within that time range, making scrub seeking very efficient
|
|
7
|
+
*/
|
|
8
|
+
export class ScrubInputCache {
|
|
9
|
+
private cache = new Map<number, BufferedSeekingInput>();
|
|
10
|
+
private maxCacheSize = 5; // Keep last 5 scrub inputs (covers 2.5 minutes)
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Get or create BufferedSeekingInput for a scrub segment
|
|
14
|
+
*/
|
|
15
|
+
async getOrCreateInput(
|
|
16
|
+
segmentId: number,
|
|
17
|
+
createInputFn: () => Promise<BufferedSeekingInput | undefined>,
|
|
18
|
+
): Promise<BufferedSeekingInput | undefined> {
|
|
19
|
+
// Check if we already have this segment cached
|
|
20
|
+
const cached = this.cache.get(segmentId);
|
|
21
|
+
if (cached) {
|
|
22
|
+
return cached;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Create new input
|
|
26
|
+
const input = await createInputFn();
|
|
27
|
+
if (!input) {
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Add to cache and maintain size limit
|
|
32
|
+
this.cache.set(segmentId, input);
|
|
33
|
+
|
|
34
|
+
// Evict oldest entries if cache is too large
|
|
35
|
+
if (this.cache.size > this.maxCacheSize) {
|
|
36
|
+
const oldestKey = this.cache.keys().next().value;
|
|
37
|
+
if (oldestKey !== undefined) {
|
|
38
|
+
this.cache.delete(oldestKey);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return input;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Clear the entire cache (called when video changes)
|
|
47
|
+
*/
|
|
48
|
+
clear() {
|
|
49
|
+
this.cache.clear();
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Get cache statistics
|
|
54
|
+
*/
|
|
55
|
+
getStats() {
|
|
56
|
+
return {
|
|
57
|
+
size: this.cache.size,
|
|
58
|
+
segmentIds: Array.from(this.cache.keys()),
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
}
|