@editframe/elements 0.16.7-beta.0 → 0.17.6-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 (101) hide show
  1. package/README.md +30 -0
  2. package/dist/DecoderResetFrequency.test.d.ts +1 -0
  3. package/dist/DecoderResetRecovery.test.d.ts +1 -0
  4. package/dist/DelayedLoadingState.d.ts +48 -0
  5. package/dist/DelayedLoadingState.integration.test.d.ts +1 -0
  6. package/dist/DelayedLoadingState.js +113 -0
  7. package/dist/DelayedLoadingState.test.d.ts +1 -0
  8. package/dist/EF_FRAMEGEN.d.ts +10 -1
  9. package/dist/EF_FRAMEGEN.js +199 -179
  10. package/dist/EF_INTERACTIVE.js +2 -6
  11. package/dist/EF_RENDERING.js +1 -3
  12. package/dist/JitTranscodingClient.browsertest.d.ts +1 -0
  13. package/dist/JitTranscodingClient.d.ts +167 -0
  14. package/dist/JitTranscodingClient.js +373 -0
  15. package/dist/JitTranscodingClient.test.d.ts +1 -0
  16. package/dist/LoadingDebounce.test.d.ts +1 -0
  17. package/dist/LoadingIndicator.browsertest.d.ts +0 -0
  18. package/dist/ManualScrubTest.test.d.ts +1 -0
  19. package/dist/ScrubResolvedFlashing.test.d.ts +1 -0
  20. package/dist/ScrubTrackIntegration.test.d.ts +1 -0
  21. package/dist/ScrubTrackManager.d.ts +96 -0
  22. package/dist/ScrubTrackManager.js +216 -0
  23. package/dist/ScrubTrackManager.test.d.ts +1 -0
  24. package/dist/SegmentSwitchLoading.test.d.ts +1 -0
  25. package/dist/VideoSeekFlashing.browsertest.d.ts +0 -0
  26. package/dist/VideoStuckDiagnostic.test.d.ts +1 -0
  27. package/dist/elements/CrossUpdateController.js +13 -15
  28. package/dist/elements/EFAudio.browsertest.d.ts +0 -0
  29. package/dist/elements/EFAudio.d.ts +1 -1
  30. package/dist/elements/EFAudio.js +30 -43
  31. package/dist/elements/EFCaptions.js +337 -373
  32. package/dist/elements/EFImage.js +64 -90
  33. package/dist/elements/EFMedia.d.ts +98 -33
  34. package/dist/elements/EFMedia.js +1169 -678
  35. package/dist/elements/EFSourceMixin.js +31 -48
  36. package/dist/elements/EFTemporal.d.ts +1 -0
  37. package/dist/elements/EFTemporal.js +266 -360
  38. package/dist/elements/EFTimegroup.d.ts +3 -1
  39. package/dist/elements/EFTimegroup.js +262 -323
  40. package/dist/elements/EFVideo.browsertest.d.ts +0 -0
  41. package/dist/elements/EFVideo.d.ts +90 -2
  42. package/dist/elements/EFVideo.js +408 -111
  43. package/dist/elements/EFWaveform.js +375 -411
  44. package/dist/elements/FetchMixin.js +14 -24
  45. package/dist/elements/MediaController.d.ts +30 -0
  46. package/dist/elements/TargetController.js +130 -156
  47. package/dist/elements/TimegroupController.js +17 -19
  48. package/dist/elements/durationConverter.js +15 -4
  49. package/dist/elements/parseTimeToMs.js +4 -10
  50. package/dist/elements/printTaskStatus.d.ts +2 -0
  51. package/dist/elements/printTaskStatus.js +11 -0
  52. package/dist/elements/updateAnimations.js +39 -59
  53. package/dist/getRenderInfo.js +58 -67
  54. package/dist/gui/ContextMixin.js +203 -288
  55. package/dist/gui/EFConfiguration.js +27 -43
  56. package/dist/gui/EFFilmstrip.js +440 -620
  57. package/dist/gui/EFFitScale.js +112 -135
  58. package/dist/gui/EFFocusOverlay.js +45 -61
  59. package/dist/gui/EFPreview.js +30 -49
  60. package/dist/gui/EFScrubber.js +78 -99
  61. package/dist/gui/EFTimeDisplay.js +49 -70
  62. package/dist/gui/EFToggleLoop.js +17 -34
  63. package/dist/gui/EFTogglePlay.js +37 -58
  64. package/dist/gui/EFWorkbench.js +66 -88
  65. package/dist/gui/TWMixin.js +2 -48
  66. package/dist/gui/TWMixin2.js +31 -0
  67. package/dist/gui/efContext.js +2 -6
  68. package/dist/gui/fetchContext.js +1 -3
  69. package/dist/gui/focusContext.js +1 -3
  70. package/dist/gui/focusedElementContext.js +2 -6
  71. package/dist/gui/playingContext.js +1 -4
  72. package/dist/index.js +5 -30
  73. package/dist/msToTimeCode.js +11 -13
  74. package/dist/style.css +2 -1
  75. package/package.json +3 -3
  76. package/src/elements/EFAudio.browsertest.ts +569 -0
  77. package/src/elements/EFAudio.ts +4 -6
  78. package/src/elements/EFCaptions.browsertest.ts +0 -1
  79. package/src/elements/EFImage.browsertest.ts +0 -1
  80. package/src/elements/EFMedia.browsertest.ts +147 -115
  81. package/src/elements/EFMedia.ts +1339 -307
  82. package/src/elements/EFTemporal.browsertest.ts +0 -1
  83. package/src/elements/EFTemporal.ts +11 -0
  84. package/src/elements/EFTimegroup.ts +73 -10
  85. package/src/elements/EFVideo.browsertest.ts +680 -0
  86. package/src/elements/EFVideo.ts +729 -50
  87. package/src/elements/EFWaveform.ts +4 -4
  88. package/src/elements/MediaController.ts +108 -0
  89. package/src/elements/__screenshots__/EFMedia.browsertest.ts/EFMedia-JIT-audio-playback-audioBufferTask-should-work-in-JIT-mode-without-URL-errors-1.png +0 -0
  90. package/src/elements/printTaskStatus.ts +16 -0
  91. package/src/elements/updateAnimations.ts +6 -0
  92. package/src/gui/TWMixin.ts +10 -3
  93. package/test/EFVideo.frame-tasks.browsertest.ts +524 -0
  94. package/test/EFVideo.framegen.browsertest.ts +118 -0
  95. package/test/createJitTestClips.ts +293 -0
  96. package/test/useAssetMSW.ts +49 -0
  97. package/test/useMSW.ts +31 -0
  98. package/types.json +1 -1
  99. package/dist/gui/TWMixin.css.js +0 -4
  100. /package/dist/elements/{TargetController.test.d.ts → TargetController.browsertest.d.ts} +0 -0
  101. /package/src/elements/{TargetController.test.ts → TargetController.browsertest.ts} +0 -0
@@ -0,0 +1,680 @@
1
+ import { html, render } from "lit";
2
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
3
+ import { assetMSWHandlers } from "../../test/useAssetMSW.js";
4
+ import { useMSW } from "../../test/useMSW.js";
5
+ import type { EFVideo } from "./EFVideo.js";
6
+ import "./EFVideo.js";
7
+ import "../gui/EFWorkbench.js";
8
+ import "../gui/EFPreview.js";
9
+ import "./EFTimegroup.js";
10
+
11
+ describe("EFVideo", () => {
12
+ const worker = useMSW();
13
+
14
+ beforeEach(() => {
15
+ // Clean up DOM and localStorage
16
+ while (document.body.children.length) {
17
+ document.body.children[0]?.remove();
18
+ }
19
+ localStorage.clear();
20
+
21
+ // Set up centralized MSW handlers to proxy requests to test assets
22
+ worker.use(...assetMSWHandlers);
23
+ });
24
+
25
+ afterEach(() => {
26
+ // Clean up any remaining elements
27
+ const videos = document.querySelectorAll("ef-video");
28
+ for (const video of videos) {
29
+ video.remove();
30
+ }
31
+ });
32
+
33
+ describe("basic rendering", () => {
34
+ test("should be defined and render canvas", async () => {
35
+ const element = document.createElement("ef-video");
36
+ document.body.appendChild(element);
37
+
38
+ // Wait for element to render
39
+ await element.updateComplete;
40
+
41
+ expect(element.tagName).toBe("EF-VIDEO");
42
+ expect(element.canvasElement).toBeDefined();
43
+ expect(element.canvasElement?.tagName).toBe("CANVAS");
44
+ });
45
+
46
+ test("canvas has correct default properties", async () => {
47
+ const container = document.createElement("div");
48
+ render(html`<ef-video></ef-video>`, container);
49
+ document.body.appendChild(container);
50
+
51
+ const video = container.querySelector("ef-video") as EFVideo;
52
+
53
+ // Wait for element to render
54
+ await video.updateComplete;
55
+
56
+ const canvas = video.canvasElement;
57
+
58
+ expect(canvas).toBeDefined();
59
+ expect(canvas?.width).toBeGreaterThan(0);
60
+ expect(canvas?.height).toBeGreaterThan(0);
61
+ });
62
+
63
+ test("canvas inherits styling correctly", async () => {
64
+ const container = document.createElement("div");
65
+ render(
66
+ html`
67
+ <ef-video style="width: 640px; height: 360px;"></ef-video>
68
+ `,
69
+ container,
70
+ );
71
+ document.body.appendChild(container);
72
+
73
+ const video = container.querySelector("ef-video") as EFVideo;
74
+
75
+ // Wait for element to render
76
+ await video.updateComplete;
77
+
78
+ const canvas = video.canvasElement;
79
+
80
+ expect(canvas).toBeDefined();
81
+ // Canvas should inherit the styling
82
+ const computedStyle = window.getComputedStyle(canvas!);
83
+ expect(computedStyle.width).toBe("640px");
84
+ expect(computedStyle.height).toBe("360px");
85
+ });
86
+ });
87
+
88
+ describe("video asset integration", () => {
89
+ test("integrates with video asset loading", async () => {
90
+ const container = document.createElement("div");
91
+ render(
92
+ html`
93
+ <ef-preview>
94
+ <ef-video src="/test-assets/media/bars-n-tone2.mp4" mode="asset"></ef-video>
95
+ </ef-preview>
96
+ `,
97
+ container,
98
+ );
99
+ document.body.appendChild(container);
100
+
101
+ const video = container.querySelector("ef-video") as EFVideo;
102
+ await video.updateComplete;
103
+
104
+ // Wait for fragment index to load
105
+ await new Promise((resolve) => setTimeout(resolve, 300));
106
+
107
+ expect(video.src).toBe("/test-assets/media/bars-n-tone2.mp4");
108
+
109
+ // The video should have loaded successfully and have a duration > 0
110
+ // We don't test for specific duration since real assets may vary
111
+ expect(video.intrinsicDurationMs).toBeGreaterThan(0);
112
+ });
113
+
114
+ test("handles missing video asset gracefully", async () => {
115
+ const container = document.createElement("div");
116
+ render(
117
+ html`
118
+ <ef-preview>
119
+ <ef-video src="/nonexistent.mp4"></ef-video>
120
+ </ef-preview>
121
+ `,
122
+ container,
123
+ );
124
+ document.body.appendChild(container);
125
+
126
+ const video = container.querySelector("ef-video") as EFVideo;
127
+
128
+ // Should not throw when video asset is missing
129
+ expect(() => {
130
+ video.paintTask.run();
131
+ }).not.toThrow();
132
+ });
133
+ });
134
+
135
+ describe("frame painting and canvas updates", () => {
136
+ test("canvas dimensions update when frame dimensions change", async () => {
137
+ const container = document.createElement("div");
138
+ render(html`<ef-video></ef-video>`, container);
139
+ document.body.appendChild(container);
140
+
141
+ const video = container.querySelector("ef-video") as EFVideo;
142
+
143
+ // Wait for element to render
144
+ await video.updateComplete;
145
+
146
+ const canvas = video.canvasElement!;
147
+
148
+ // Mock a video frame with specific dimensions
149
+ const mockFrame = {
150
+ codedWidth: 1920,
151
+ codedHeight: 1080,
152
+ format: "RGBA",
153
+ timestamp: 0,
154
+ close: vi.fn(),
155
+ } as unknown as VideoFrame;
156
+
157
+ // Simulate frame painting (this would normally happen through paintTask)
158
+ const ctx = canvas.getContext("2d");
159
+ if (ctx && mockFrame.codedWidth && mockFrame.codedHeight) {
160
+ canvas.width = mockFrame.codedWidth;
161
+ canvas.height = mockFrame.codedHeight;
162
+ // Mock drawing the frame
163
+ ctx.fillStyle = "red";
164
+ ctx.fillRect(0, 0, canvas.width, canvas.height);
165
+ }
166
+
167
+ expect(canvas.width).toBe(1920);
168
+ expect(canvas.height).toBe(1080);
169
+ });
170
+
171
+ test("handles frame painting with null format gracefully", async () => {
172
+ const container = document.createElement("div");
173
+ render(html`<ef-video></ef-video>`, container);
174
+ document.body.appendChild(container);
175
+
176
+ const video = container.querySelector("ef-video") as EFVideo;
177
+
178
+ // Wait for element to render
179
+ await video.updateComplete;
180
+
181
+ const canvas = video.canvasElement!;
182
+
183
+ // Mock a frame with null format (edge case)
184
+ const mockFrame = {
185
+ codedWidth: 640,
186
+ codedHeight: 480,
187
+ format: null,
188
+ timestamp: 0,
189
+ close: vi.fn(),
190
+ } as unknown as VideoFrame;
191
+
192
+ const ctx = canvas.getContext("2d");
193
+
194
+ // Should handle null format gracefully
195
+ expect(() => {
196
+ if (ctx && mockFrame.format === null) {
197
+ console.warn("Frame format is null", mockFrame);
198
+ return;
199
+ }
200
+ }).not.toThrow();
201
+ });
202
+
203
+ test("canvas context is available for drawing", async () => {
204
+ const container = document.createElement("div");
205
+ render(html`<ef-video></ef-video>`, container);
206
+ document.body.appendChild(container);
207
+
208
+ const video = container.querySelector("ef-video") as EFVideo;
209
+
210
+ // Wait for element to render
211
+ await video.updateComplete;
212
+
213
+ const canvas = video.canvasElement!;
214
+ const ctx = canvas.getContext("2d");
215
+
216
+ expect(ctx).toBeDefined();
217
+ expect(ctx).toBeInstanceOf(CanvasRenderingContext2D);
218
+
219
+ // Test that we can draw on the canvas
220
+ expect(() => {
221
+ ctx!.fillStyle = "blue";
222
+ ctx!.fillRect(0, 0, 100, 100);
223
+ }).not.toThrow();
224
+ });
225
+ });
226
+
227
+ describe("decoder lock scenarios", () => {
228
+ test("handles concurrent paint attempts safely", async () => {
229
+ const container = document.createElement("div");
230
+ render(html`<ef-video></ef-video>`, container);
231
+ document.body.appendChild(container);
232
+
233
+ const video = container.querySelector("ef-video") as EFVideo;
234
+
235
+ // Access the private decoder lock through reflection for testing
236
+ const decoderLockDescriptor = Object.getOwnPropertyDescriptor(
237
+ Object.getPrototypeOf(video),
238
+ "#decoderLock",
239
+ );
240
+
241
+ // Simulate the decoder being in use
242
+ if (decoderLockDescriptor) {
243
+ // We can't directly access private fields in tests, but we can test
244
+ // that multiple paint calls don't cause issues
245
+ const paintPromise1 = video.paintTask.run();
246
+ const paintPromise2 = video.paintTask.run();
247
+ const paintPromise3 = video.paintTask.run();
248
+
249
+ // All should complete without throwing
250
+ await expect(
251
+ Promise.allSettled([paintPromise1, paintPromise2, paintPromise3]),
252
+ ).resolves.toBeDefined();
253
+ }
254
+ });
255
+
256
+ test("paintTask handles missing canvas gracefully", () => {
257
+ const container = document.createElement("div");
258
+ render(html`<ef-video></ef-video>`, container);
259
+ document.body.appendChild(container);
260
+
261
+ const video = container.querySelector("ef-video") as EFVideo;
262
+
263
+ // Remove canvas to test edge case
264
+ const canvas = video.canvasElement;
265
+ canvas?.remove();
266
+
267
+ // Paint task should handle missing canvas
268
+ expect(() => {
269
+ video.paintTask.run();
270
+ }).not.toThrow();
271
+ });
272
+
273
+ test("handles paint task with no video asset", () => {
274
+ const container = document.createElement("div");
275
+ render(html`<ef-video></ef-video>`, container);
276
+ document.body.appendChild(container);
277
+
278
+ const video = container.querySelector("ef-video") as EFVideo;
279
+
280
+ // Paint task should handle missing video asset gracefully
281
+ expect(() => {
282
+ video.paintTask.run();
283
+ }).not.toThrow();
284
+ });
285
+ });
286
+
287
+ describe("frame task integration", () => {
288
+ test("frameTask coordinates all required tasks", async () => {
289
+ const container = document.createElement("div");
290
+ render(
291
+ html`
292
+ <ef-preview>
293
+ <ef-video src="/test-video.mp4"></ef-video>
294
+ </ef-preview>
295
+ `,
296
+ container,
297
+ );
298
+ document.body.appendChild(container);
299
+
300
+ const video = container.querySelector("ef-video") as EFVideo;
301
+
302
+ // frameTask should complete without errors even when other tasks fail
303
+ expect(() => {
304
+ video.frameTask.run();
305
+ }).not.toThrow();
306
+ });
307
+
308
+ test("frameTask handles missing dependencies", () => {
309
+ const container = document.createElement("div");
310
+ render(html`<ef-video></ef-video>`, container);
311
+ document.body.appendChild(container);
312
+
313
+ const video = container.querySelector("ef-video") as EFVideo;
314
+
315
+ // Should handle missing dependencies gracefully
316
+ expect(() => {
317
+ video.frameTask.run();
318
+ }).not.toThrow();
319
+ });
320
+ });
321
+
322
+ describe("error handling and edge cases", () => {
323
+ test("handles seek to invalid time", () => {
324
+ const container = document.createElement("div");
325
+ render(html`<ef-video></ef-video>`, container);
326
+ document.body.appendChild(container);
327
+
328
+ const video = container.querySelector("ef-video") as EFVideo;
329
+
330
+ // Should handle invalid seek times gracefully
331
+ expect(() => {
332
+ video.desiredSeekTimeMs = -1000; // Invalid negative time
333
+ video.paintTask.run();
334
+ }).not.toThrow();
335
+
336
+ expect(() => {
337
+ video.desiredSeekTimeMs = Number.POSITIVE_INFINITY;
338
+ video.paintTask.run();
339
+ }).not.toThrow();
340
+ });
341
+
342
+ test("handles video element removal during playback", () => {
343
+ const container = document.createElement("div");
344
+ render(html`<ef-video></ef-video>`, container);
345
+ document.body.appendChild(container);
346
+
347
+ const video = container.querySelector("ef-video") as EFVideo;
348
+
349
+ // Start some operations
350
+ video.paintTask.run();
351
+
352
+ // Remove element
353
+ video.remove();
354
+
355
+ // Should not cause errors
356
+ expect(() => {
357
+ video.paintTask.run();
358
+ }).not.toThrow();
359
+ });
360
+
361
+ test("handles canvas context loss gracefully", async () => {
362
+ const container = document.createElement("div");
363
+ render(html`<ef-video></ef-video>`, container);
364
+ document.body.appendChild(container);
365
+
366
+ const video = container.querySelector("ef-video") as EFVideo;
367
+
368
+ // Wait for element to render
369
+ await video.updateComplete;
370
+
371
+ const canvas = video.canvasElement!;
372
+
373
+ // Simulate context loss by making getContext return null
374
+ const originalGetContext = canvas.getContext;
375
+ canvas.getContext = vi.fn().mockReturnValue(null);
376
+
377
+ // Should handle context loss gracefully
378
+ expect(() => {
379
+ video.paintTask.run();
380
+ }).not.toThrow();
381
+
382
+ // Restore original method
383
+ canvas.getContext = originalGetContext;
384
+ });
385
+ });
386
+
387
+ describe("integration with timegroups", () => {
388
+ test("integrates correctly within timegroup structure", async () => {
389
+ const container = document.createElement("div");
390
+ render(
391
+ html`
392
+ <ef-preview>
393
+ <ef-timegroup mode="sequence">
394
+ <ef-video src="/test-assets/media/bars-n-tone2.mp4" mode="asset"></ef-video>
395
+ </ef-timegroup>
396
+ </ef-preview>
397
+ `,
398
+ container,
399
+ );
400
+ document.body.appendChild(container);
401
+
402
+ const video = container.querySelector("ef-video") as EFVideo;
403
+ const timegroup = container.querySelector("ef-timegroup");
404
+ await video.updateComplete;
405
+
406
+ // Wait for fragment index to load with longer timeout
407
+ await new Promise((resolve) => setTimeout(resolve, 500));
408
+
409
+ expect(timegroup).toBeDefined();
410
+
411
+ // The video should have loaded successfully within the timegroup
412
+ // We test that it has a valid duration instead of a specific value
413
+ // Allow for race conditions in test environment
414
+ if (video.intrinsicDurationMs === 0) {
415
+ // If not loaded yet, wait a bit more
416
+ await new Promise((resolve) => setTimeout(resolve, 300));
417
+ }
418
+ expect(video.intrinsicDurationMs).toBeGreaterThan(0);
419
+ });
420
+ });
421
+
422
+ describe("scrub track integration", () => {
423
+ test("should initialize scrub track manager for JIT transcode mode", async () => {
424
+ const container = document.createElement("div");
425
+ render(
426
+ html`
427
+ <ef-preview>
428
+ <ef-video src="http://example.com/video.mp4"></ef-video>
429
+ </ef-preview>
430
+ `,
431
+ container,
432
+ );
433
+ document.body.appendChild(container);
434
+
435
+ const video = container.querySelector("ef-video") as EFVideo;
436
+ await video.updateComplete;
437
+
438
+ // Give the async initialization time to complete
439
+ await new Promise((resolve) => setTimeout(resolve, 100));
440
+
441
+ // For JIT transcode mode, scrub track manager should be initialized
442
+ expect(video.scrubTrackManager).toBeDefined();
443
+ });
444
+
445
+ test("should not initialize scrub track manager for asset mode", async () => {
446
+ const container = document.createElement("div");
447
+ render(
448
+ html`
449
+ <ef-preview>
450
+ <ef-video src="/@ef-abc123/video.mp4" mode="asset"></ef-video>
451
+ </ef-preview>
452
+ `,
453
+ container,
454
+ );
455
+ document.body.appendChild(container);
456
+
457
+ const video = container.querySelector("ef-video") as EFVideo;
458
+ await video.updateComplete;
459
+ await new Promise((resolve) => setTimeout(resolve, 100));
460
+
461
+ // For asset mode, scrub track manager should not be initialized
462
+ expect(video.scrubTrackManager).toBeUndefined();
463
+ });
464
+
465
+ test("should expose scrub track performance metrics", async () => {
466
+ const container = document.createElement("div");
467
+ render(
468
+ html`
469
+ <ef-preview>
470
+ <ef-video src="http://example.com/video.mp4"></ef-video>
471
+ </ef-preview>
472
+ `,
473
+ container,
474
+ );
475
+ document.body.appendChild(container);
476
+
477
+ const video = container.querySelector("ef-video") as EFVideo;
478
+ await video.updateComplete;
479
+ await new Promise((resolve) => setTimeout(resolve, 100));
480
+
481
+ const stats = video.getScrubTrackStats();
482
+
483
+ if (video.scrubTrackManager) {
484
+ expect(stats).not.toBeNull();
485
+ expect(typeof stats?.hits).toBe("number");
486
+ expect(typeof stats?.misses).toBe("number");
487
+ expect(typeof stats?.hitRate).toBe("number");
488
+ } else {
489
+ expect(stats).toBeNull();
490
+ }
491
+ });
492
+
493
+ test("should return null stats when no scrub track manager exists", async () => {
494
+ const container = document.createElement("div");
495
+ render(
496
+ html`
497
+ <ef-preview>
498
+ <ef-video src="/@ef-abc123/video.mp4" mode="asset"></ef-video>
499
+ </ef-preview>
500
+ `,
501
+ container,
502
+ );
503
+ document.body.appendChild(container);
504
+
505
+ const video = container.querySelector("ef-video") as EFVideo;
506
+ await video.updateComplete;
507
+
508
+ const stats = video.getScrubTrackStats();
509
+ expect(stats).toBeNull();
510
+ });
511
+
512
+ test("should have canvas element available", async () => {
513
+ const container = document.createElement("div");
514
+ render(html`<ef-video></ef-video>`, container);
515
+ document.body.appendChild(container);
516
+
517
+ const video = container.querySelector("ef-video") as EFVideo;
518
+ await video.updateComplete;
519
+
520
+ const canvas = video.canvasElement;
521
+ expect(canvas).toBeDefined();
522
+ expect(canvas?.tagName).toBe("CANVAS");
523
+ });
524
+
525
+ test("should clean up scrub track manager on disconnect", async () => {
526
+ const container = document.createElement("div");
527
+ render(
528
+ html`
529
+ <ef-preview>
530
+ <ef-video src="http://example.com/video.mp4"></ef-video>
531
+ </ef-preview>
532
+ `,
533
+ container,
534
+ );
535
+ document.body.appendChild(container);
536
+
537
+ const video = container.querySelector("ef-video") as EFVideo;
538
+ await video.updateComplete;
539
+ await new Promise((resolve) => setTimeout(resolve, 100));
540
+
541
+ const hadScrubManager = !!video.scrubTrackManager;
542
+
543
+ // Simulate disconnect
544
+ video.remove();
545
+
546
+ // If there was a scrub manager, it should have been cleaned up
547
+ // We can't directly test the cleanup call, but we can verify the element is disconnected
548
+ expect(video.isConnected).toBe(false);
549
+
550
+ // The scrub manager should still exist but be cleaned up internally
551
+ if (hadScrubManager) {
552
+ expect(video.scrubTrackManager).toBeDefined();
553
+ }
554
+ });
555
+ });
556
+
557
+ describe("loading indicator", () => {
558
+ test("should not show loading indicator for operations completing under 250ms", async () => {
559
+ const container = document.createElement("div");
560
+ render(html`<ef-video></ef-video>`, container);
561
+ document.body.appendChild(container);
562
+
563
+ const video = container.querySelector("ef-video") as EFVideo;
564
+ await video.updateComplete;
565
+
566
+ // Start a fast operation
567
+ video.startDelayedLoading("test-fast", "Fast operation");
568
+
569
+ // Clear it quickly (under 250ms)
570
+ setTimeout(() => {
571
+ video.clearDelayedLoading("test-fast");
572
+ }, 100);
573
+
574
+ // Wait past the delay threshold
575
+ await new Promise((resolve) => setTimeout(resolve, 300));
576
+
577
+ expect(video.loadingState.isLoading).toBe(false);
578
+ });
579
+
580
+ test("should show loading indicator only after 250ms for slow operations", async () => {
581
+ const container = document.createElement("div");
582
+ render(html`<ef-video></ef-video>`, container);
583
+ document.body.appendChild(container);
584
+
585
+ const video = container.querySelector("ef-video") as EFVideo;
586
+ await video.updateComplete;
587
+
588
+ // Start a slow operation
589
+ video.startDelayedLoading("test-slow", "Slow operation");
590
+
591
+ // Should not be loading immediately
592
+ expect(video.loadingState.isLoading).toBe(false);
593
+
594
+ // Wait past the delay threshold
595
+ await new Promise((resolve) => setTimeout(resolve, 300));
596
+
597
+ // Should now be loading
598
+ expect(video.loadingState.isLoading).toBe(true);
599
+ expect(video.loadingState.message).toBe("Slow operation");
600
+
601
+ // Clear the loading
602
+ video.clearDelayedLoading("test-slow");
603
+
604
+ // Should stop loading
605
+ expect(video.loadingState.isLoading).toBe(false);
606
+ });
607
+
608
+ test("should handle multiple concurrent loading operations", async () => {
609
+ const container = document.createElement("div");
610
+ render(html`<ef-video></ef-video>`, container);
611
+ document.body.appendChild(container);
612
+
613
+ const video = container.querySelector("ef-video") as EFVideo;
614
+ await video.updateComplete;
615
+
616
+ // Start multiple operations
617
+ video.startDelayedLoading("op1", "Operation 1");
618
+ video.startDelayedLoading("op2", "Operation 2");
619
+
620
+ // Wait past delay threshold
621
+ await new Promise((resolve) => setTimeout(resolve, 300));
622
+
623
+ // Should be loading
624
+ expect(video.loadingState.isLoading).toBe(true);
625
+
626
+ // Clear one operation
627
+ video.clearDelayedLoading("op1");
628
+
629
+ // Should still be loading (op2 still active)
630
+ expect(video.loadingState.isLoading).toBe(true);
631
+
632
+ // Clear second operation
633
+ video.clearDelayedLoading("op2");
634
+
635
+ // Should stop loading
636
+ expect(video.loadingState.isLoading).toBe(false);
637
+ });
638
+
639
+ test("should not show loading for background operations", async () => {
640
+ const container = document.createElement("div");
641
+ render(html`<ef-video></ef-video>`, container);
642
+ document.body.appendChild(container);
643
+
644
+ const video = container.querySelector("ef-video") as EFVideo;
645
+ await video.updateComplete;
646
+
647
+ // Start a background operation
648
+ video.startDelayedLoading("bg-op", "Background operation", {
649
+ background: true,
650
+ });
651
+
652
+ // Wait past delay threshold
653
+ await new Promise((resolve) => setTimeout(resolve, 300));
654
+
655
+ // Should not show loading UI for background operations
656
+ expect(video.loadingState.isLoading).toBe(false);
657
+
658
+ // Clear the operation
659
+ video.clearDelayedLoading("bg-op");
660
+ });
661
+
662
+ test("should properly clean up loading state on disconnect", async () => {
663
+ const container = document.createElement("div");
664
+ render(html`<ef-video></ef-video>`, container);
665
+ document.body.appendChild(container);
666
+
667
+ const video = container.querySelector("ef-video") as EFVideo;
668
+ await video.updateComplete;
669
+
670
+ // Start an operation
671
+ video.startDelayedLoading("cleanup-test", "Test operation");
672
+
673
+ // Disconnect the element
674
+ video.remove();
675
+
676
+ // Loading should be cleared
677
+ expect(video.loadingState.isLoading).toBe(false);
678
+ });
679
+ });
680
+ });