@editframe/elements 0.19.4-beta.0 → 0.20.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.
- package/dist/elements/ContextProxiesController.d.ts +40 -0
- package/dist/elements/ContextProxiesController.js +69 -0
- package/dist/elements/EFCaptions.d.ts +45 -6
- package/dist/elements/EFCaptions.js +220 -26
- package/dist/elements/EFImage.js +4 -1
- package/dist/elements/EFMedia/AssetIdMediaEngine.d.ts +2 -1
- package/dist/elements/EFMedia/AssetIdMediaEngine.js +9 -0
- package/dist/elements/EFMedia/AssetMediaEngine.d.ts +1 -0
- package/dist/elements/EFMedia/AssetMediaEngine.js +11 -0
- package/dist/elements/EFMedia/BaseMediaEngine.d.ts +13 -1
- package/dist/elements/EFMedia/BaseMediaEngine.js +9 -0
- package/dist/elements/EFMedia/JitMediaEngine.d.ts +7 -1
- package/dist/elements/EFMedia/JitMediaEngine.js +24 -0
- package/dist/elements/EFMedia/shared/GlobalInputCache.d.ts +39 -0
- package/dist/elements/EFMedia/shared/GlobalInputCache.js +57 -0
- package/dist/elements/EFMedia/shared/ThumbnailExtractor.d.ts +27 -0
- package/dist/elements/EFMedia/shared/ThumbnailExtractor.js +106 -0
- package/dist/elements/EFMedia.js +25 -1
- package/dist/elements/EFSurface.browsertest.d.ts +0 -0
- package/dist/elements/EFSurface.d.ts +30 -0
- package/dist/elements/EFSurface.js +96 -0
- package/dist/elements/EFTemporal.js +7 -6
- package/dist/elements/EFThumbnailStrip.browsertest.d.ts +0 -0
- package/dist/elements/EFThumbnailStrip.d.ts +86 -0
- package/dist/elements/EFThumbnailStrip.js +490 -0
- package/dist/elements/EFThumbnailStrip.media-engine.browsertest.d.ts +0 -0
- package/dist/elements/EFTimegroup.d.ts +6 -1
- package/dist/elements/EFTimegroup.js +46 -10
- package/dist/elements/updateAnimations.browsertest.d.ts +13 -0
- package/dist/elements/updateAnimations.d.ts +5 -0
- package/dist/elements/updateAnimations.js +37 -13
- package/dist/getRenderInfo.js +1 -1
- package/dist/gui/ContextMixin.js +27 -14
- package/dist/gui/EFControls.browsertest.d.ts +0 -0
- package/dist/gui/EFControls.d.ts +38 -0
- package/dist/gui/EFControls.js +51 -0
- package/dist/gui/EFFilmstrip.d.ts +40 -1
- package/dist/gui/EFFilmstrip.js +240 -3
- package/dist/gui/EFPreview.js +2 -1
- package/dist/gui/EFScrubber.d.ts +6 -5
- package/dist/gui/EFScrubber.js +31 -21
- package/dist/gui/EFTimeDisplay.browsertest.d.ts +0 -0
- package/dist/gui/EFTimeDisplay.d.ts +2 -6
- package/dist/gui/EFTimeDisplay.js +13 -23
- package/dist/gui/TWMixin.js +1 -1
- package/dist/gui/currentTimeContext.d.ts +3 -0
- package/dist/gui/currentTimeContext.js +3 -0
- package/dist/gui/durationContext.d.ts +3 -0
- package/dist/gui/durationContext.js +3 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +4 -1
- package/dist/style.css +1 -1
- package/dist/transcoding/types/index.d.ts +11 -0
- package/dist/utils/LRUCache.d.ts +46 -0
- package/dist/utils/LRUCache.js +382 -1
- package/dist/utils/LRUCache.test.d.ts +1 -0
- package/package.json +2 -2
- package/src/elements/ContextProxiesController.ts +123 -0
- package/src/elements/EFCaptions.browsertest.ts +1820 -0
- package/src/elements/EFCaptions.ts +373 -36
- package/src/elements/EFImage.ts +4 -1
- package/src/elements/EFMedia/AssetIdMediaEngine.ts +30 -1
- package/src/elements/EFMedia/AssetMediaEngine.ts +33 -0
- package/src/elements/EFMedia/BaseMediaEngine.browsertest.ts +3 -8
- package/src/elements/EFMedia/BaseMediaEngine.ts +35 -0
- package/src/elements/EFMedia/JitMediaEngine.ts +48 -0
- package/src/elements/EFMedia/shared/GlobalInputCache.ts +77 -0
- package/src/elements/EFMedia/shared/ThumbnailExtractor.ts +227 -0
- package/src/elements/EFMedia.ts +38 -1
- package/src/elements/EFSurface.browsertest.ts +155 -0
- package/src/elements/EFSurface.ts +141 -0
- package/src/elements/EFTemporal.ts +14 -8
- package/src/elements/EFThumbnailStrip.browsertest.ts +591 -0
- package/src/elements/EFThumbnailStrip.media-engine.browsertest.ts +713 -0
- package/src/elements/EFThumbnailStrip.ts +905 -0
- package/src/elements/EFTimegroup.browsertest.ts +56 -7
- package/src/elements/EFTimegroup.ts +70 -11
- package/src/elements/updateAnimations.browsertest.ts +333 -11
- package/src/elements/updateAnimations.ts +68 -19
- package/src/gui/ContextMixin.browsertest.ts +0 -25
- package/src/gui/ContextMixin.ts +44 -20
- package/src/gui/EFControls.browsertest.ts +175 -0
- package/src/gui/EFControls.ts +84 -0
- package/src/gui/EFFilmstrip.ts +323 -4
- package/src/gui/EFPreview.ts +2 -1
- package/src/gui/EFScrubber.ts +29 -25
- package/src/gui/EFTimeDisplay.browsertest.ts +237 -0
- package/src/gui/EFTimeDisplay.ts +12 -40
- package/src/gui/currentTimeContext.ts +5 -0
- package/src/gui/durationContext.ts +3 -0
- package/src/transcoding/types/index.ts +13 -0
- package/src/utils/LRUCache.test.ts +272 -0
- package/src/utils/LRUCache.ts +543 -0
- package/types.json +1 -1
- package/dist/transcoding/cache/CacheManager.d.ts +0 -73
- package/src/transcoding/cache/CacheManager.ts +0 -208
|
@@ -7,6 +7,7 @@ import { BaseMediaEngine, mediaCache } from "./BaseMediaEngine.js";
|
|
|
7
7
|
const test = baseTest.extend<{}>({});
|
|
8
8
|
|
|
9
9
|
// Test implementation of BaseMediaEngine for testing
|
|
10
|
+
// @ts-expect-error missing implementations
|
|
10
11
|
class TestMediaEngine extends BaseMediaEngine {
|
|
11
12
|
fetchMediaSegment = vi.fn();
|
|
12
13
|
public host: EFMedia;
|
|
@@ -221,14 +222,8 @@ describe("BaseMediaEngine abort signal handling", () => {
|
|
|
221
222
|
const host = {
|
|
222
223
|
fetch: vi
|
|
223
224
|
.fn()
|
|
224
|
-
.mockImplementation(
|
|
225
|
-
() =>
|
|
226
|
-
new Promise((resolve) =>
|
|
227
|
-
setTimeout(
|
|
228
|
-
() => resolve({ arrayBuffer: () => new ArrayBuffer(1024) }),
|
|
229
|
-
100,
|
|
230
|
-
),
|
|
231
|
-
),
|
|
225
|
+
.mockImplementation(() =>
|
|
226
|
+
Promise.resolve({ arrayBuffer: () => new ArrayBuffer(1024) }),
|
|
232
227
|
),
|
|
233
228
|
} as any;
|
|
234
229
|
|
|
@@ -2,10 +2,12 @@ import { RequestDeduplicator } from "../../transcoding/cache/RequestDeduplicator
|
|
|
2
2
|
import type {
|
|
3
3
|
AudioRendition,
|
|
4
4
|
SegmentTimeRange,
|
|
5
|
+
ThumbnailResult,
|
|
5
6
|
VideoRendition,
|
|
6
7
|
} from "../../transcoding/types";
|
|
7
8
|
import { SizeAwareLRUCache } from "../../utils/LRUCache.js";
|
|
8
9
|
import type { EFMedia } from "../EFMedia.js";
|
|
10
|
+
import type { MediaRendition } from "./shared/MediaTaskUtils.js";
|
|
9
11
|
|
|
10
12
|
// Global instances shared across all media engines
|
|
11
13
|
export const mediaCache = new SizeAwareLRUCache<string>(100 * 1024 * 1024); // 100MB cache limit
|
|
@@ -205,6 +207,16 @@ export abstract class BaseMediaEngine {
|
|
|
205
207
|
rendition: { trackId: number | undefined; src: string },
|
|
206
208
|
): Promise<ArrayBuffer>;
|
|
207
209
|
|
|
210
|
+
abstract fetchInitSegment(
|
|
211
|
+
rendition: { trackId: number | undefined; src: string },
|
|
212
|
+
signal: AbortSignal,
|
|
213
|
+
): Promise<ArrayBuffer>;
|
|
214
|
+
|
|
215
|
+
abstract computeSegmentId(
|
|
216
|
+
desiredSeekTimeMs: number,
|
|
217
|
+
rendition: MediaRendition,
|
|
218
|
+
): number | undefined;
|
|
219
|
+
|
|
208
220
|
/**
|
|
209
221
|
* Fetch media segment with built-in deduplication
|
|
210
222
|
* Now uses global deduplication for all requests
|
|
@@ -387,4 +399,27 @@ export abstract class BaseMediaEngine {
|
|
|
387
399
|
segmentIds.filter((id) => this.isSegmentCached(id, rendition)),
|
|
388
400
|
);
|
|
389
401
|
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Extract thumbnail canvases at multiple timestamps efficiently
|
|
405
|
+
* Default implementation provides helpful error information
|
|
406
|
+
*/
|
|
407
|
+
async extractThumbnails(
|
|
408
|
+
timestamps: number[],
|
|
409
|
+
): Promise<(ThumbnailResult | null)[]> {
|
|
410
|
+
const engineName = this.constructor.name;
|
|
411
|
+
console.warn(
|
|
412
|
+
`${engineName}: extractThumbnails not properly implemented. ` +
|
|
413
|
+
"This MediaEngine type does not support thumbnail generation. " +
|
|
414
|
+
"Supported engines: JitMediaEngine. " +
|
|
415
|
+
`Requested ${timestamps.length} thumbnail${timestamps.length === 1 ? "" : "s"}.`,
|
|
416
|
+
);
|
|
417
|
+
return timestamps.map(() => null);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
abstract convertToSegmentRelativeTimestamps(
|
|
421
|
+
globalTimestamps: number[],
|
|
422
|
+
segmentId: number,
|
|
423
|
+
rendition: VideoRendition,
|
|
424
|
+
): number[];
|
|
390
425
|
}
|
|
@@ -2,16 +2,19 @@ import type {
|
|
|
2
2
|
AudioRendition,
|
|
3
3
|
MediaEngine,
|
|
4
4
|
RenditionId,
|
|
5
|
+
ThumbnailResult,
|
|
5
6
|
VideoRendition,
|
|
6
7
|
} from "../../transcoding/types";
|
|
7
8
|
import type { ManifestResponse } from "../../transcoding/types/index.js";
|
|
8
9
|
import type { UrlGenerator } from "../../transcoding/utils/UrlGenerator";
|
|
9
10
|
import type { EFMedia } from "../EFMedia.js";
|
|
10
11
|
import { BaseMediaEngine } from "./BaseMediaEngine";
|
|
12
|
+
import { ThumbnailExtractor } from "./shared/ThumbnailExtractor.js";
|
|
11
13
|
|
|
12
14
|
export class JitMediaEngine extends BaseMediaEngine implements MediaEngine {
|
|
13
15
|
private urlGenerator: UrlGenerator;
|
|
14
16
|
private data: ManifestResponse = {} as ManifestResponse;
|
|
17
|
+
private thumbnailExtractor: ThumbnailExtractor;
|
|
15
18
|
|
|
16
19
|
static async fetch(host: EFMedia, urlGenerator: UrlGenerator, url: string) {
|
|
17
20
|
const engine = new JitMediaEngine(host, urlGenerator);
|
|
@@ -23,6 +26,7 @@ export class JitMediaEngine extends BaseMediaEngine implements MediaEngine {
|
|
|
23
26
|
constructor(host: EFMedia, urlGenerator: UrlGenerator) {
|
|
24
27
|
super(host);
|
|
25
28
|
this.urlGenerator = urlGenerator;
|
|
29
|
+
this.thumbnailExtractor = new ThumbnailExtractor(this);
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
get durationMs() {
|
|
@@ -204,4 +208,48 @@ export class JitMediaEngine extends BaseMediaEngine implements MediaEngine {
|
|
|
204
208
|
maxAudioBufferFetches: 3,
|
|
205
209
|
};
|
|
206
210
|
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Extract thumbnail canvases using same rendition priority as video playback for frame alignment
|
|
214
|
+
*/
|
|
215
|
+
async extractThumbnails(
|
|
216
|
+
timestamps: number[],
|
|
217
|
+
): Promise<(ThumbnailResult | null)[]> {
|
|
218
|
+
// Use same rendition priority as video: try main rendition first for frame alignment
|
|
219
|
+
let rendition: VideoRendition;
|
|
220
|
+
try {
|
|
221
|
+
const mainRendition = this.getVideoRendition();
|
|
222
|
+
if (mainRendition) {
|
|
223
|
+
rendition = mainRendition;
|
|
224
|
+
} else {
|
|
225
|
+
const scrubRendition = this.getScrubVideoRendition();
|
|
226
|
+
if (scrubRendition) {
|
|
227
|
+
rendition = scrubRendition;
|
|
228
|
+
} else {
|
|
229
|
+
throw new Error("No video rendition available");
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
} catch (error) {
|
|
233
|
+
console.warn(
|
|
234
|
+
"JitMediaEngine: No video rendition available for thumbnails",
|
|
235
|
+
error,
|
|
236
|
+
);
|
|
237
|
+
return timestamps.map(() => null);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Use shared thumbnail extraction logic
|
|
241
|
+
return this.thumbnailExtractor.extractThumbnails(
|
|
242
|
+
timestamps,
|
|
243
|
+
rendition,
|
|
244
|
+
this.durationMs,
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
convertToSegmentRelativeTimestamps(
|
|
249
|
+
globalTimestamps: number[],
|
|
250
|
+
_segmentId: number,
|
|
251
|
+
_rendition: VideoRendition,
|
|
252
|
+
): number[] {
|
|
253
|
+
return globalTimestamps.map((timestamp) => timestamp / 1000);
|
|
254
|
+
}
|
|
207
255
|
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { Input } from "mediabunny";
|
|
2
|
+
import { LRUCache } from "../../../utils/LRUCache.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Global cache for MediaBunny Input instances
|
|
6
|
+
* Shared across all MediaEngine instances to prevent duplicate decoding
|
|
7
|
+
* of the same segment data
|
|
8
|
+
*/
|
|
9
|
+
class GlobalInputCache {
|
|
10
|
+
private cache = new LRUCache<string, Input>(50); // 50 Input instances max
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Generate standardized cache key for Input objects
|
|
14
|
+
* Format: "input:{src}:{segmentId}:{renditionId}"
|
|
15
|
+
*/
|
|
16
|
+
private generateKey(
|
|
17
|
+
src: string,
|
|
18
|
+
segmentId: number,
|
|
19
|
+
renditionId?: string,
|
|
20
|
+
): string {
|
|
21
|
+
return `input:${src}:${segmentId}:${renditionId || "default"}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Get cached Input object
|
|
26
|
+
*/
|
|
27
|
+
get(src: string, segmentId: number, renditionId?: string): Input | undefined {
|
|
28
|
+
const key = this.generateKey(src, segmentId, renditionId);
|
|
29
|
+
return this.cache.get(key);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Cache Input object
|
|
34
|
+
*/
|
|
35
|
+
set(
|
|
36
|
+
src: string,
|
|
37
|
+
segmentId: number,
|
|
38
|
+
input: Input,
|
|
39
|
+
renditionId?: string,
|
|
40
|
+
): void {
|
|
41
|
+
const key = this.generateKey(src, segmentId, renditionId);
|
|
42
|
+
this.cache.set(key, input);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Check if Input is cached
|
|
47
|
+
*/
|
|
48
|
+
has(src: string, segmentId: number, renditionId?: string): boolean {
|
|
49
|
+
const key = this.generateKey(src, segmentId, renditionId);
|
|
50
|
+
return this.cache.has(key);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Clear all cached Input objects
|
|
55
|
+
*/
|
|
56
|
+
clear(): void {
|
|
57
|
+
this.cache.clear();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Get cache statistics for debugging
|
|
62
|
+
*/
|
|
63
|
+
getStats() {
|
|
64
|
+
return {
|
|
65
|
+
size: this.cache.size,
|
|
66
|
+
cachedKeys: Array.from((this.cache as any).cache.keys()),
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Single global instance shared across all MediaEngine instances
|
|
72
|
+
export const globalInputCache = new GlobalInputCache();
|
|
73
|
+
|
|
74
|
+
// Export for debugging (works in both browser and server)
|
|
75
|
+
(
|
|
76
|
+
globalThis as typeof globalThis & { debugInputCache: typeof globalInputCache }
|
|
77
|
+
).debugInputCache = globalInputCache;
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { ALL_FORMATS, BlobSource, CanvasSink, Input } from "mediabunny";
|
|
2
|
+
import type {
|
|
3
|
+
ThumbnailResult,
|
|
4
|
+
VideoRendition,
|
|
5
|
+
} from "../../../transcoding/types/index.js";
|
|
6
|
+
import type { BaseMediaEngine } from "../BaseMediaEngine.js";
|
|
7
|
+
import { globalInputCache } from "./GlobalInputCache.js";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Shared thumbnail extraction logic for all MediaEngine implementations
|
|
11
|
+
* Eliminates code duplication and provides consistent behavior
|
|
12
|
+
*/
|
|
13
|
+
export class ThumbnailExtractor {
|
|
14
|
+
constructor(private mediaEngine: BaseMediaEngine) {}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Extract thumbnails at multiple timestamps efficiently using segment batching
|
|
18
|
+
*/
|
|
19
|
+
async extractThumbnails(
|
|
20
|
+
timestamps: number[],
|
|
21
|
+
rendition: VideoRendition,
|
|
22
|
+
durationMs: number,
|
|
23
|
+
): Promise<(ThumbnailResult | null)[]> {
|
|
24
|
+
if (timestamps.length === 0) {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Validate and filter timestamps within bounds
|
|
29
|
+
const validTimestamps = timestamps.filter(
|
|
30
|
+
(timeMs) => timeMs >= 0 && timeMs <= durationMs,
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
if (validTimestamps.length === 0) {
|
|
34
|
+
console.warn(
|
|
35
|
+
`ThumbnailExtractor: All timestamps out of bounds (0-${durationMs}ms)`,
|
|
36
|
+
);
|
|
37
|
+
return timestamps.map(() => null);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Group timestamps by segment for batch processing
|
|
41
|
+
const segmentGroups = this.groupTimestampsBySegment(
|
|
42
|
+
validTimestamps,
|
|
43
|
+
rendition,
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
// Extract batched by segment using CanvasSink
|
|
47
|
+
const results = new Map<number, ThumbnailResult | null>();
|
|
48
|
+
|
|
49
|
+
for (const [segmentId, segmentTimestamps] of segmentGroups) {
|
|
50
|
+
try {
|
|
51
|
+
const segmentResults = await this.extractSegmentThumbnails(
|
|
52
|
+
segmentId,
|
|
53
|
+
segmentTimestamps,
|
|
54
|
+
rendition,
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
for (const [timestamp, thumbnail] of segmentResults) {
|
|
58
|
+
results.set(timestamp, thumbnail);
|
|
59
|
+
}
|
|
60
|
+
} catch (error) {
|
|
61
|
+
console.warn(
|
|
62
|
+
`ThumbnailExtractor: Failed to extract thumbnails for segment ${segmentId}:`,
|
|
63
|
+
error,
|
|
64
|
+
);
|
|
65
|
+
// Mark all timestamps in this segment as failed
|
|
66
|
+
for (const timestamp of segmentTimestamps) {
|
|
67
|
+
results.set(timestamp, null);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Return in original order, null for any that failed or were out of bounds
|
|
73
|
+
return timestamps.map((t) => {
|
|
74
|
+
// If timestamp was out of bounds, return null
|
|
75
|
+
if (t < 0 || t > durationMs) {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
return results.get(t) || null;
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Group timestamps by segment ID for efficient batch processing
|
|
84
|
+
*/
|
|
85
|
+
private groupTimestampsBySegment(
|
|
86
|
+
timestamps: number[],
|
|
87
|
+
rendition: VideoRendition,
|
|
88
|
+
): Map<number, number[]> {
|
|
89
|
+
const segmentGroups = new Map<number, number[]>();
|
|
90
|
+
|
|
91
|
+
for (const timeMs of timestamps) {
|
|
92
|
+
try {
|
|
93
|
+
const segmentId = this.mediaEngine.computeSegmentId(timeMs, rendition);
|
|
94
|
+
if (segmentId !== undefined) {
|
|
95
|
+
if (!segmentGroups.has(segmentId)) {
|
|
96
|
+
segmentGroups.set(segmentId, []);
|
|
97
|
+
}
|
|
98
|
+
const segmentGroup = segmentGroups.get(segmentId) ?? [];
|
|
99
|
+
if (!segmentGroup) {
|
|
100
|
+
segmentGroups.set(segmentId, []);
|
|
101
|
+
}
|
|
102
|
+
segmentGroup.push(timeMs);
|
|
103
|
+
}
|
|
104
|
+
} catch (error) {
|
|
105
|
+
console.warn(
|
|
106
|
+
`ThumbnailExtractor: Could not compute segment for timestamp ${timeMs}:`,
|
|
107
|
+
error,
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return segmentGroups;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Extract thumbnails for a specific segment using CanvasSink
|
|
117
|
+
*/
|
|
118
|
+
private async extractSegmentThumbnails(
|
|
119
|
+
segmentId: number,
|
|
120
|
+
timestamps: number[],
|
|
121
|
+
rendition: VideoRendition,
|
|
122
|
+
): Promise<Map<number, ThumbnailResult | null>> {
|
|
123
|
+
const results = new Map<number, ThumbnailResult | null>();
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
// Get segment data through existing media engine methods (uses caches)
|
|
127
|
+
const abortController = new AbortController();
|
|
128
|
+
const [initSegment, mediaSegment] = await Promise.all([
|
|
129
|
+
this.mediaEngine.fetchInitSegment(rendition, abortController.signal),
|
|
130
|
+
this.mediaEngine.fetchMediaSegment(segmentId, rendition),
|
|
131
|
+
]);
|
|
132
|
+
|
|
133
|
+
// Create Input for this segment using global shared cache
|
|
134
|
+
const segmentBlob = new Blob([initSegment, mediaSegment]);
|
|
135
|
+
|
|
136
|
+
let input = globalInputCache.get(rendition.src, segmentId, rendition.id);
|
|
137
|
+
if (!input) {
|
|
138
|
+
input = new Input({
|
|
139
|
+
formats: ALL_FORMATS,
|
|
140
|
+
source: new BlobSource(segmentBlob),
|
|
141
|
+
});
|
|
142
|
+
globalInputCache.set(rendition.src, segmentId, input, rendition.id);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Set up CanvasSink for batched extraction
|
|
146
|
+
const videoTrack = await input.getPrimaryVideoTrack();
|
|
147
|
+
if (!videoTrack) {
|
|
148
|
+
// No video track - return nulls for all timestamps
|
|
149
|
+
for (const timestamp of timestamps) {
|
|
150
|
+
results.set(timestamp, null);
|
|
151
|
+
}
|
|
152
|
+
return results;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const sink = new CanvasSink(videoTrack);
|
|
156
|
+
|
|
157
|
+
// Convert global timestamps to segment-relative (in seconds for mediabunny)
|
|
158
|
+
const relativeTimestamps = this.convertToSegmentRelativeTimestamps(
|
|
159
|
+
timestamps,
|
|
160
|
+
segmentId,
|
|
161
|
+
rendition,
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
// Batch extract all thumbnails for this segment
|
|
165
|
+
const timestampResults = [];
|
|
166
|
+
for await (const result of sink.canvasesAtTimestamps(
|
|
167
|
+
relativeTimestamps,
|
|
168
|
+
)) {
|
|
169
|
+
timestampResults.push(result);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Map results back to original timestamps
|
|
173
|
+
for (let i = 0; i < timestamps.length; i++) {
|
|
174
|
+
const globalTimestamp = timestamps[i];
|
|
175
|
+
if (globalTimestamp === undefined) {
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const result = timestampResults[i];
|
|
180
|
+
|
|
181
|
+
if (result?.canvas) {
|
|
182
|
+
const canvas = result.canvas;
|
|
183
|
+
if (
|
|
184
|
+
canvas instanceof HTMLCanvasElement ||
|
|
185
|
+
canvas instanceof OffscreenCanvas
|
|
186
|
+
) {
|
|
187
|
+
results.set(globalTimestamp, {
|
|
188
|
+
timestamp: globalTimestamp,
|
|
189
|
+
thumbnail: canvas,
|
|
190
|
+
});
|
|
191
|
+
} else {
|
|
192
|
+
results.set(globalTimestamp, null);
|
|
193
|
+
}
|
|
194
|
+
} else {
|
|
195
|
+
results.set(globalTimestamp, null);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
} catch (error) {
|
|
199
|
+
console.error(
|
|
200
|
+
`ThumbnailExtractor: Failed to extract thumbnails for segment ${segmentId}:`,
|
|
201
|
+
error,
|
|
202
|
+
);
|
|
203
|
+
// Return nulls for all timestamps on error
|
|
204
|
+
for (const timestamp of timestamps) {
|
|
205
|
+
results.set(timestamp, null);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return results;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Convert global timestamps to segment-relative timestamps for mediabunny
|
|
214
|
+
* This is where the main difference between JIT and Asset engines lies
|
|
215
|
+
*/
|
|
216
|
+
private convertToSegmentRelativeTimestamps(
|
|
217
|
+
globalTimestamps: number[],
|
|
218
|
+
segmentId: number,
|
|
219
|
+
rendition: VideoRendition,
|
|
220
|
+
): number[] {
|
|
221
|
+
return this.mediaEngine.convertToSegmentRelativeTimestamps(
|
|
222
|
+
globalTimestamps,
|
|
223
|
+
segmentId,
|
|
224
|
+
rendition,
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
}
|
package/src/elements/EFMedia.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { css, LitElement, type PropertyValueMap } from "lit";
|
|
2
2
|
import { property, state } from "lit/decorators.js";
|
|
3
|
-
|
|
3
|
+
import { isContextMixin } from "../gui/ContextMixin.js";
|
|
4
4
|
import type { AudioSpan } from "../transcoding/types/index.ts";
|
|
5
5
|
import { UrlGenerator } from "../transcoding/utils/UrlGenerator.ts";
|
|
6
6
|
import { makeAudioBufferTask } from "./EFMedia/audioTasks/makeAudioBufferTask.ts";
|
|
@@ -64,6 +64,8 @@ export class EFMedia extends EFTargetable(
|
|
|
64
64
|
"audio-buffer-duration",
|
|
65
65
|
"max-audio-buffer-fetches",
|
|
66
66
|
"enable-audio-buffering",
|
|
67
|
+
"sourcein",
|
|
68
|
+
"sourceout",
|
|
67
69
|
];
|
|
68
70
|
}
|
|
69
71
|
|
|
@@ -212,6 +214,41 @@ export class EFMedia extends EFTargetable(
|
|
|
212
214
|
if (changedProperties.has("ownCurrentTimeMs")) {
|
|
213
215
|
this.executeSeek(this.currentSourceTimeMs);
|
|
214
216
|
}
|
|
217
|
+
|
|
218
|
+
// Check if trim/source properties changed that affect duration
|
|
219
|
+
const durationAffectingProps = [
|
|
220
|
+
"_trimStartMs",
|
|
221
|
+
"_trimEndMs",
|
|
222
|
+
"_sourceInMs",
|
|
223
|
+
"_sourceOutMs",
|
|
224
|
+
];
|
|
225
|
+
|
|
226
|
+
const hasDurationChange = durationAffectingProps.some((prop) =>
|
|
227
|
+
changedProperties.has(prop),
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
if (hasDurationChange) {
|
|
231
|
+
// Notify parent timegroup to recalculate its duration (same pattern as EFCaptions)
|
|
232
|
+
if (this.parentTimegroup) {
|
|
233
|
+
this.parentTimegroup.requestUpdate("durationMs");
|
|
234
|
+
this.parentTimegroup.requestUpdate("currentTime");
|
|
235
|
+
|
|
236
|
+
// Also find and directly notify any context provider (ContextMixin)
|
|
237
|
+
let parent = this.parentNode;
|
|
238
|
+
while (parent) {
|
|
239
|
+
if (isContextMixin(parent)) {
|
|
240
|
+
parent.dispatchEvent(
|
|
241
|
+
new CustomEvent("child-duration-changed", {
|
|
242
|
+
detail: { source: this },
|
|
243
|
+
}),
|
|
244
|
+
);
|
|
245
|
+
break;
|
|
246
|
+
}
|
|
247
|
+
parent = parent.parentNode;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
215
252
|
// if (
|
|
216
253
|
// changedProperties.has("currentTime") ||
|
|
217
254
|
// changedProperties.has("ownCurrentTimeMs")
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { html, render } from "lit";
|
|
2
|
+
import { beforeEach, describe } from "vitest";
|
|
3
|
+
|
|
4
|
+
import { test as baseTest } from "../../test/useMSW.js";
|
|
5
|
+
|
|
6
|
+
import "./EFVideo.js";
|
|
7
|
+
import "./EFTimegroup.js";
|
|
8
|
+
import "../gui/EFPreview.js";
|
|
9
|
+
import "../gui/EFWorkbench.js";
|
|
10
|
+
import "./EFSurface.js";
|
|
11
|
+
|
|
12
|
+
import type { EFSurface } from "./EFSurface.js";
|
|
13
|
+
import type { EFTimegroup } from "./EFTimegroup.js";
|
|
14
|
+
import type { EFVideo } from "./EFVideo.js";
|
|
15
|
+
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
localStorage.clear();
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
const surfaceTest = baseTest.extend<{
|
|
21
|
+
timegroup: EFTimegroup;
|
|
22
|
+
video: EFVideo;
|
|
23
|
+
surface: EFSurface;
|
|
24
|
+
}>({
|
|
25
|
+
timegroup: async ({}, use) => {
|
|
26
|
+
const container = document.createElement("div");
|
|
27
|
+
render(
|
|
28
|
+
html`
|
|
29
|
+
<ef-configuration api-host="http://localhost:63315">
|
|
30
|
+
<ef-preview>
|
|
31
|
+
<ef-timegroup id="tg" mode="sequence" class="relative h-[360px] w-[640px] overflow-hidden bg-black">
|
|
32
|
+
<ef-video id="vid" src="bars-n-tone.mp4" style="width: 100%; height: 100%;"></ef-video>
|
|
33
|
+
<ef-surface id="surf" target="vid" style="position: absolute; inset: 0;"></ef-surface>
|
|
34
|
+
</ef-timegroup>
|
|
35
|
+
</ef-preview>
|
|
36
|
+
</ef-configuration>
|
|
37
|
+
`,
|
|
38
|
+
container,
|
|
39
|
+
);
|
|
40
|
+
document.body.appendChild(container);
|
|
41
|
+
const configuration = container.querySelector("ef-configuration") as any;
|
|
42
|
+
configuration.signingURL = "";
|
|
43
|
+
const tg = container.querySelector("#tg") as EFTimegroup;
|
|
44
|
+
await tg.updateComplete;
|
|
45
|
+
await use(tg);
|
|
46
|
+
container.remove();
|
|
47
|
+
},
|
|
48
|
+
video: async ({ timegroup }, use) => {
|
|
49
|
+
const video = timegroup.querySelector("#vid") as EFVideo;
|
|
50
|
+
await video.updateComplete;
|
|
51
|
+
await use(video);
|
|
52
|
+
},
|
|
53
|
+
surface: async ({ timegroup }, use) => {
|
|
54
|
+
const surface = timegroup.querySelector("#surf") as unknown as EFSurface;
|
|
55
|
+
await surface.updateComplete;
|
|
56
|
+
await use(surface);
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe("EFSurface", () => {
|
|
61
|
+
surfaceTest("defines and renders a canvas", async ({ expect }) => {
|
|
62
|
+
const el = document.createElement("ef-surface");
|
|
63
|
+
document.body.appendChild(el);
|
|
64
|
+
await (el as any).updateComplete;
|
|
65
|
+
const canvas = el.shadowRoot?.querySelector("canvas");
|
|
66
|
+
expect(canvas).toBeTruthy();
|
|
67
|
+
expect((canvas as HTMLCanvasElement).tagName).toBe("CANVAS");
|
|
68
|
+
el.remove();
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
surfaceTest(
|
|
72
|
+
"mirrors video canvas after a seek via EFTimegroup",
|
|
73
|
+
async ({ timegroup, video, surface, expect }) => {
|
|
74
|
+
// Ensure media engine initialized
|
|
75
|
+
await video.mediaEngineTask.run();
|
|
76
|
+
|
|
77
|
+
// Seek to a known time through timegroup (triggers frame tasks)
|
|
78
|
+
timegroup.currentTimeMs = 3000;
|
|
79
|
+
await timegroup.seekTask.taskComplete;
|
|
80
|
+
|
|
81
|
+
// After scheduling, surface should have mirrored pixel dimensions
|
|
82
|
+
const videoCanvas = (video as any).canvasElement as
|
|
83
|
+
| HTMLCanvasElement
|
|
84
|
+
| undefined;
|
|
85
|
+
const surfaceCanvas =
|
|
86
|
+
(surface.shadowRoot?.querySelector("canvas") as HTMLCanvasElement) ??
|
|
87
|
+
undefined;
|
|
88
|
+
|
|
89
|
+
expect(videoCanvas).toBeTruthy();
|
|
90
|
+
expect(surfaceCanvas).toBeTruthy();
|
|
91
|
+
expect(videoCanvas!.width).toBeGreaterThan(0);
|
|
92
|
+
expect(videoCanvas!.height).toBeGreaterThan(0);
|
|
93
|
+
|
|
94
|
+
// Surface copies pixel dimensions
|
|
95
|
+
expect(surfaceCanvas!.width).toBe(videoCanvas!.width);
|
|
96
|
+
expect(surfaceCanvas!.height).toBe(videoCanvas!.height);
|
|
97
|
+
},
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
surfaceTest(
|
|
101
|
+
"supports multiple surfaces mirroring the same source",
|
|
102
|
+
async ({ expect }) => {
|
|
103
|
+
const container = document.createElement("div");
|
|
104
|
+
render(
|
|
105
|
+
html`
|
|
106
|
+
<ef-configuration api-host="http://localhost:63315">
|
|
107
|
+
<ef-preview>
|
|
108
|
+
<ef-timegroup mode="sequence" class="relative h-[360px] w-[640px] overflow-hidden bg-black">
|
|
109
|
+
<ef-video id="v" src="bars-n-tone.mp4" style="width: 100%; height: 100%;"></ef-video>
|
|
110
|
+
<ef-surface id="s1" target="v" style="position: absolute; inset: 0;"></ef-surface>
|
|
111
|
+
<ef-surface id="s2" target="v" style="position: absolute; inset: 0;"></ef-surface>
|
|
112
|
+
</ef-timegroup>
|
|
113
|
+
</ef-preview>
|
|
114
|
+
</ef-configuration>
|
|
115
|
+
`,
|
|
116
|
+
container,
|
|
117
|
+
);
|
|
118
|
+
document.body.appendChild(container);
|
|
119
|
+
const configuration = container.querySelector("ef-configuration") as any;
|
|
120
|
+
configuration.signingURL = "";
|
|
121
|
+
const timegroup = container.querySelector("ef-timegroup") as EFTimegroup;
|
|
122
|
+
const video = container.querySelector("ef-video") as EFVideo;
|
|
123
|
+
const s1 = container.querySelector("#s1") as unknown as EFSurface;
|
|
124
|
+
const s2 = container.querySelector("#s2") as unknown as EFSurface;
|
|
125
|
+
await timegroup.updateComplete;
|
|
126
|
+
await video.mediaEngineTask.run();
|
|
127
|
+
|
|
128
|
+
timegroup.currentTimeMs = 1000;
|
|
129
|
+
await timegroup.seekTask.taskComplete;
|
|
130
|
+
|
|
131
|
+
const vCanvas = (video as any).canvasElement as HTMLCanvasElement;
|
|
132
|
+
const c1 = s1.shadowRoot!.querySelector("canvas") as HTMLCanvasElement;
|
|
133
|
+
const c2 = s2.shadowRoot!.querySelector("canvas") as HTMLCanvasElement;
|
|
134
|
+
|
|
135
|
+
expect(vCanvas.width).toBeGreaterThan(0);
|
|
136
|
+
expect(c1.width).toBe(vCanvas.width);
|
|
137
|
+
expect(c2.width).toBe(vCanvas.width);
|
|
138
|
+
expect(c1.height).toBe(vCanvas.height);
|
|
139
|
+
expect(c2.height).toBe(vCanvas.height);
|
|
140
|
+
|
|
141
|
+
container.remove();
|
|
142
|
+
},
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
surfaceTest(
|
|
146
|
+
"handles missing video gracefully (no throw)",
|
|
147
|
+
async ({ expect }) => {
|
|
148
|
+
const el = document.createElement("ef-surface") as any;
|
|
149
|
+
document.body.appendChild(el);
|
|
150
|
+
await el.updateComplete;
|
|
151
|
+
await expect(el.frameTask.run()).resolves.toBeUndefined();
|
|
152
|
+
el.remove();
|
|
153
|
+
},
|
|
154
|
+
);
|
|
155
|
+
});
|