@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.
Files changed (96) hide show
  1. package/dist/elements/ContextProxiesController.d.ts +40 -0
  2. package/dist/elements/ContextProxiesController.js +69 -0
  3. package/dist/elements/EFCaptions.d.ts +45 -6
  4. package/dist/elements/EFCaptions.js +220 -26
  5. package/dist/elements/EFImage.js +4 -1
  6. package/dist/elements/EFMedia/AssetIdMediaEngine.d.ts +2 -1
  7. package/dist/elements/EFMedia/AssetIdMediaEngine.js +9 -0
  8. package/dist/elements/EFMedia/AssetMediaEngine.d.ts +1 -0
  9. package/dist/elements/EFMedia/AssetMediaEngine.js +11 -0
  10. package/dist/elements/EFMedia/BaseMediaEngine.d.ts +13 -1
  11. package/dist/elements/EFMedia/BaseMediaEngine.js +9 -0
  12. package/dist/elements/EFMedia/JitMediaEngine.d.ts +7 -1
  13. package/dist/elements/EFMedia/JitMediaEngine.js +24 -0
  14. package/dist/elements/EFMedia/shared/GlobalInputCache.d.ts +39 -0
  15. package/dist/elements/EFMedia/shared/GlobalInputCache.js +57 -0
  16. package/dist/elements/EFMedia/shared/ThumbnailExtractor.d.ts +27 -0
  17. package/dist/elements/EFMedia/shared/ThumbnailExtractor.js +106 -0
  18. package/dist/elements/EFMedia.js +25 -1
  19. package/dist/elements/EFSurface.browsertest.d.ts +0 -0
  20. package/dist/elements/EFSurface.d.ts +30 -0
  21. package/dist/elements/EFSurface.js +96 -0
  22. package/dist/elements/EFTemporal.js +7 -6
  23. package/dist/elements/EFThumbnailStrip.browsertest.d.ts +0 -0
  24. package/dist/elements/EFThumbnailStrip.d.ts +86 -0
  25. package/dist/elements/EFThumbnailStrip.js +490 -0
  26. package/dist/elements/EFThumbnailStrip.media-engine.browsertest.d.ts +0 -0
  27. package/dist/elements/EFTimegroup.d.ts +6 -1
  28. package/dist/elements/EFTimegroup.js +46 -10
  29. package/dist/elements/updateAnimations.browsertest.d.ts +13 -0
  30. package/dist/elements/updateAnimations.d.ts +5 -0
  31. package/dist/elements/updateAnimations.js +37 -13
  32. package/dist/getRenderInfo.js +1 -1
  33. package/dist/gui/ContextMixin.js +27 -14
  34. package/dist/gui/EFControls.browsertest.d.ts +0 -0
  35. package/dist/gui/EFControls.d.ts +38 -0
  36. package/dist/gui/EFControls.js +51 -0
  37. package/dist/gui/EFFilmstrip.d.ts +40 -1
  38. package/dist/gui/EFFilmstrip.js +240 -3
  39. package/dist/gui/EFPreview.js +2 -1
  40. package/dist/gui/EFScrubber.d.ts +6 -5
  41. package/dist/gui/EFScrubber.js +31 -21
  42. package/dist/gui/EFTimeDisplay.browsertest.d.ts +0 -0
  43. package/dist/gui/EFTimeDisplay.d.ts +2 -6
  44. package/dist/gui/EFTimeDisplay.js +13 -23
  45. package/dist/gui/TWMixin.js +1 -1
  46. package/dist/gui/currentTimeContext.d.ts +3 -0
  47. package/dist/gui/currentTimeContext.js +3 -0
  48. package/dist/gui/durationContext.d.ts +3 -0
  49. package/dist/gui/durationContext.js +3 -0
  50. package/dist/index.d.ts +3 -0
  51. package/dist/index.js +4 -1
  52. package/dist/style.css +1 -1
  53. package/dist/transcoding/types/index.d.ts +11 -0
  54. package/dist/utils/LRUCache.d.ts +46 -0
  55. package/dist/utils/LRUCache.js +382 -1
  56. package/dist/utils/LRUCache.test.d.ts +1 -0
  57. package/package.json +2 -2
  58. package/src/elements/ContextProxiesController.ts +123 -0
  59. package/src/elements/EFCaptions.browsertest.ts +1820 -0
  60. package/src/elements/EFCaptions.ts +373 -36
  61. package/src/elements/EFImage.ts +4 -1
  62. package/src/elements/EFMedia/AssetIdMediaEngine.ts +30 -1
  63. package/src/elements/EFMedia/AssetMediaEngine.ts +33 -0
  64. package/src/elements/EFMedia/BaseMediaEngine.browsertest.ts +3 -8
  65. package/src/elements/EFMedia/BaseMediaEngine.ts +35 -0
  66. package/src/elements/EFMedia/JitMediaEngine.ts +48 -0
  67. package/src/elements/EFMedia/shared/GlobalInputCache.ts +77 -0
  68. package/src/elements/EFMedia/shared/ThumbnailExtractor.ts +227 -0
  69. package/src/elements/EFMedia.ts +38 -1
  70. package/src/elements/EFSurface.browsertest.ts +155 -0
  71. package/src/elements/EFSurface.ts +141 -0
  72. package/src/elements/EFTemporal.ts +14 -8
  73. package/src/elements/EFThumbnailStrip.browsertest.ts +591 -0
  74. package/src/elements/EFThumbnailStrip.media-engine.browsertest.ts +713 -0
  75. package/src/elements/EFThumbnailStrip.ts +905 -0
  76. package/src/elements/EFTimegroup.browsertest.ts +56 -7
  77. package/src/elements/EFTimegroup.ts +70 -11
  78. package/src/elements/updateAnimations.browsertest.ts +333 -11
  79. package/src/elements/updateAnimations.ts +68 -19
  80. package/src/gui/ContextMixin.browsertest.ts +0 -25
  81. package/src/gui/ContextMixin.ts +44 -20
  82. package/src/gui/EFControls.browsertest.ts +175 -0
  83. package/src/gui/EFControls.ts +84 -0
  84. package/src/gui/EFFilmstrip.ts +323 -4
  85. package/src/gui/EFPreview.ts +2 -1
  86. package/src/gui/EFScrubber.ts +29 -25
  87. package/src/gui/EFTimeDisplay.browsertest.ts +237 -0
  88. package/src/gui/EFTimeDisplay.ts +12 -40
  89. package/src/gui/currentTimeContext.ts +5 -0
  90. package/src/gui/durationContext.ts +3 -0
  91. package/src/transcoding/types/index.ts +13 -0
  92. package/src/utils/LRUCache.test.ts +272 -0
  93. package/src/utils/LRUCache.ts +543 -0
  94. package/types.json +1 -1
  95. package/dist/transcoding/cache/CacheManager.d.ts +0 -73
  96. 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
+ }
@@ -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
+ });