@editframe/elements 0.19.4-beta.0 → 0.20.1-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 (132) 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 +15 -0
  14. package/dist/elements/EFMedia/audioTasks/makeAudioBufferTask.js +2 -1
  15. package/dist/elements/EFMedia/audioTasks/makeAudioFrequencyAnalysisTask.js +2 -0
  16. package/dist/elements/EFMedia/audioTasks/makeAudioInitSegmentFetchTask.d.ts +1 -1
  17. package/dist/elements/EFMedia/audioTasks/makeAudioInitSegmentFetchTask.js +3 -1
  18. package/dist/elements/EFMedia/audioTasks/makeAudioInputTask.js +1 -1
  19. package/dist/elements/EFMedia/audioTasks/makeAudioSegmentFetchTask.d.ts +1 -1
  20. package/dist/elements/EFMedia/audioTasks/makeAudioSegmentFetchTask.js +6 -5
  21. package/dist/elements/EFMedia/audioTasks/makeAudioSegmentIdTask.js +3 -1
  22. package/dist/elements/EFMedia/audioTasks/makeAudioTimeDomainAnalysisTask.js +2 -0
  23. package/dist/elements/EFMedia/shared/AudioSpanUtils.js +2 -2
  24. package/dist/elements/EFMedia/shared/GlobalInputCache.d.ts +39 -0
  25. package/dist/elements/EFMedia/shared/GlobalInputCache.js +57 -0
  26. package/dist/elements/EFMedia/shared/ThumbnailExtractor.d.ts +27 -0
  27. package/dist/elements/EFMedia/shared/ThumbnailExtractor.js +106 -0
  28. package/dist/elements/EFMedia/tasks/makeMediaEngineTask.js +1 -1
  29. package/dist/elements/EFMedia.d.ts +2 -2
  30. package/dist/elements/EFMedia.js +25 -1
  31. package/dist/elements/EFSurface.browsertest.d.ts +0 -0
  32. package/dist/elements/EFSurface.d.ts +30 -0
  33. package/dist/elements/EFSurface.js +96 -0
  34. package/dist/elements/EFTemporal.js +7 -6
  35. package/dist/elements/EFThumbnailStrip.browsertest.d.ts +0 -0
  36. package/dist/elements/EFThumbnailStrip.d.ts +86 -0
  37. package/dist/elements/EFThumbnailStrip.js +490 -0
  38. package/dist/elements/EFThumbnailStrip.media-engine.browsertest.d.ts +0 -0
  39. package/dist/elements/EFTimegroup.d.ts +6 -1
  40. package/dist/elements/EFTimegroup.js +53 -11
  41. package/dist/elements/updateAnimations.browsertest.d.ts +13 -0
  42. package/dist/elements/updateAnimations.d.ts +5 -0
  43. package/dist/elements/updateAnimations.js +37 -13
  44. package/dist/getRenderInfo.js +1 -1
  45. package/dist/gui/ContextMixin.js +27 -14
  46. package/dist/gui/EFControls.browsertest.d.ts +0 -0
  47. package/dist/gui/EFControls.d.ts +38 -0
  48. package/dist/gui/EFControls.js +51 -0
  49. package/dist/gui/EFFilmstrip.d.ts +40 -1
  50. package/dist/gui/EFFilmstrip.js +240 -3
  51. package/dist/gui/EFPreview.js +2 -1
  52. package/dist/gui/EFScrubber.d.ts +6 -5
  53. package/dist/gui/EFScrubber.js +31 -21
  54. package/dist/gui/EFTimeDisplay.browsertest.d.ts +0 -0
  55. package/dist/gui/EFTimeDisplay.d.ts +2 -6
  56. package/dist/gui/EFTimeDisplay.js +13 -23
  57. package/dist/gui/TWMixin.js +1 -1
  58. package/dist/gui/currentTimeContext.d.ts +3 -0
  59. package/dist/gui/currentTimeContext.js +3 -0
  60. package/dist/gui/durationContext.d.ts +3 -0
  61. package/dist/gui/durationContext.js +3 -0
  62. package/dist/index.d.ts +3 -0
  63. package/dist/index.js +4 -1
  64. package/dist/style.css +1 -1
  65. package/dist/transcoding/types/index.d.ts +11 -0
  66. package/dist/utils/LRUCache.d.ts +46 -0
  67. package/dist/utils/LRUCache.js +382 -1
  68. package/dist/utils/LRUCache.test.d.ts +1 -0
  69. package/package.json +2 -2
  70. package/src/elements/ContextProxiesController.ts +124 -0
  71. package/src/elements/EFCaptions.browsertest.ts +1820 -0
  72. package/src/elements/EFCaptions.ts +373 -36
  73. package/src/elements/EFImage.ts +4 -1
  74. package/src/elements/EFMedia/AssetIdMediaEngine.ts +30 -1
  75. package/src/elements/EFMedia/AssetMediaEngine.ts +33 -0
  76. package/src/elements/EFMedia/BaseMediaEngine.browsertest.ts +3 -8
  77. package/src/elements/EFMedia/BaseMediaEngine.ts +35 -0
  78. package/src/elements/EFMedia/JitMediaEngine.ts +34 -0
  79. package/src/elements/EFMedia/audioTasks/makeAudioBufferTask.ts +6 -5
  80. package/src/elements/EFMedia/audioTasks/makeAudioFrequencyAnalysisTask.ts +5 -0
  81. package/src/elements/EFMedia/audioTasks/makeAudioInitSegmentFetchTask.ts +8 -5
  82. package/src/elements/EFMedia/audioTasks/makeAudioInputTask.ts +5 -5
  83. package/src/elements/EFMedia/audioTasks/makeAudioSegmentFetchTask.ts +11 -12
  84. package/src/elements/EFMedia/audioTasks/makeAudioSegmentIdTask.ts +7 -4
  85. package/src/elements/EFMedia/audioTasks/makeAudioTimeDomainAnalysisTask.ts +5 -0
  86. package/src/elements/EFMedia/shared/AudioSpanUtils.ts +2 -2
  87. package/src/elements/EFMedia/shared/GlobalInputCache.ts +77 -0
  88. package/src/elements/EFMedia/shared/RenditionHelpers.browsertest.ts +2 -2
  89. package/src/elements/EFMedia/shared/RenditionHelpers.ts +2 -2
  90. package/src/elements/EFMedia/shared/ThumbnailExtractor.ts +227 -0
  91. package/src/elements/EFMedia/tasks/makeMediaEngineTask.ts +1 -1
  92. package/src/elements/EFMedia.ts +38 -1
  93. package/src/elements/EFSurface.browsertest.ts +155 -0
  94. package/src/elements/EFSurface.ts +141 -0
  95. package/src/elements/EFTemporal.ts +14 -8
  96. package/src/elements/EFThumbnailStrip.browsertest.ts +591 -0
  97. package/src/elements/EFThumbnailStrip.media-engine.browsertest.ts +713 -0
  98. package/src/elements/EFThumbnailStrip.ts +905 -0
  99. package/src/elements/EFTimegroup.browsertest.ts +56 -7
  100. package/src/elements/EFTimegroup.ts +88 -16
  101. package/src/elements/updateAnimations.browsertest.ts +333 -11
  102. package/src/elements/updateAnimations.ts +68 -19
  103. package/src/gui/ContextMixin.browsertest.ts +0 -25
  104. package/src/gui/ContextMixin.ts +44 -20
  105. package/src/gui/EFControls.browsertest.ts +175 -0
  106. package/src/gui/EFControls.ts +84 -0
  107. package/src/gui/EFFilmstrip.ts +323 -4
  108. package/src/gui/EFPreview.ts +2 -1
  109. package/src/gui/EFScrubber.ts +29 -25
  110. package/src/gui/EFTimeDisplay.browsertest.ts +237 -0
  111. package/src/gui/EFTimeDisplay.ts +12 -40
  112. package/src/gui/currentTimeContext.ts +5 -0
  113. package/src/gui/durationContext.ts +3 -0
  114. package/src/transcoding/types/index.ts +13 -0
  115. package/src/utils/LRUCache.test.ts +272 -0
  116. package/src/utils/LRUCache.ts +543 -0
  117. package/test/__cache__/GET__api_v1_transcode_high_1_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__26197f6f7c46cacb0a71134131c3f775/data.bin +0 -0
  118. package/test/__cache__/GET__api_v1_transcode_high_1_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__26197f6f7c46cacb0a71134131c3f775/metadata.json +1 -1
  119. package/test/__cache__/GET__api_v1_transcode_high_2_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__4cb6774cd3650ccf59c8f8dc6678c0b9/data.bin +0 -0
  120. package/test/__cache__/GET__api_v1_transcode_high_2_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__4cb6774cd3650ccf59c8f8dc6678c0b9/metadata.json +1 -1
  121. package/test/__cache__/GET__api_v1_transcode_high_3_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__0b3b2b1c8933f7fcf8a9ecaa88d58b41/data.bin +0 -0
  122. package/test/__cache__/GET__api_v1_transcode_high_3_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__0b3b2b1c8933f7fcf8a9ecaa88d58b41/metadata.json +1 -1
  123. package/test/__cache__/GET__api_v1_transcode_high_4_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__a6fb05a22b18d850f7f2950bbcdbdeed/data.bin +0 -0
  124. package/test/__cache__/GET__api_v1_transcode_high_4_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__a6fb05a22b18d850f7f2950bbcdbdeed/metadata.json +1 -1
  125. package/test/__cache__/GET__api_v1_transcode_high_5_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__a50058c7c3602e90879fe3428ed891f4/data.bin +0 -0
  126. package/test/__cache__/GET__api_v1_transcode_high_5_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__a50058c7c3602e90879fe3428ed891f4/metadata.json +1 -1
  127. package/test/__cache__/GET__api_v1_transcode_high_init_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__0798c479b44aaeef850609a430f6e613/data.bin +0 -0
  128. package/test/__cache__/GET__api_v1_transcode_manifest_json_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__3be92a0437de726b431ed5af2369158a/data.bin +1 -1
  129. package/test/__cache__/GET__api_v1_transcode_manifest_json_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__3be92a0437de726b431ed5af2369158a/metadata.json +1 -1
  130. package/types.json +1 -1
  131. package/dist/transcoding/cache/CacheManager.d.ts +0 -73
  132. package/src/transcoding/cache/CacheManager.ts +0 -208
@@ -0,0 +1,237 @@
1
+ import { html, render as litRender, type TemplateResult } from "lit";
2
+ import { assert, beforeEach, describe, test, vi } from "vitest";
3
+ import { EFTimeDisplay } from "./EFTimeDisplay.js";
4
+ import "./EFTimeDisplay.js";
5
+ import type { EFTimegroup } from "../elements/EFTimegroup.js";
6
+ import "../elements/EFTimegroup.js";
7
+ import "./EFPreview.js";
8
+
9
+ beforeEach(() => {
10
+ for (let i = 0; i < localStorage.length; i++) {
11
+ const key = localStorage.key(i);
12
+ if (typeof key !== "string") continue;
13
+ localStorage.removeItem(key);
14
+ }
15
+ while (document.body.children.length) {
16
+ document.body.children[0]?.remove();
17
+ }
18
+ });
19
+
20
+ const renderTimeDisplay = (result: TemplateResult) => {
21
+ const container = document.createElement("div");
22
+ litRender(result, container);
23
+ const timeDisplay = container.querySelector("ef-time-display");
24
+ if (!timeDisplay) {
25
+ throw new Error("No ef-time-display found");
26
+ }
27
+ if (!(timeDisplay instanceof EFTimeDisplay)) {
28
+ throw new Error("Element is not an EFTimeDisplay");
29
+ }
30
+ document.body.appendChild(container);
31
+ return { timeDisplay, container };
32
+ };
33
+
34
+ describe("EFTimeDisplay", () => {
35
+ test("shows 0:00 / 0:00 when context is null", async () => {
36
+ const { timeDisplay } = renderTimeDisplay(
37
+ html`<ef-time-display></ef-time-display>`,
38
+ );
39
+ await timeDisplay.updateComplete;
40
+
41
+ const timeText = timeDisplay.shadowRoot?.textContent?.trim();
42
+ assert.equal(timeText, "0:00 / 0:00");
43
+ });
44
+
45
+ test("works correctly with real EFTimegroup context", async () => {
46
+ const { timeDisplay } = renderTimeDisplay(
47
+ html`<ef-preview>
48
+ <ef-timegroup mode="fixed" duration="5s">
49
+ <ef-time-display></ef-time-display>
50
+ </ef-timegroup>
51
+ </ef-preview>`,
52
+ );
53
+
54
+ await timeDisplay.updateComplete;
55
+
56
+ const timeText = timeDisplay.shadowRoot?.textContent?.trim();
57
+ assert.equal(
58
+ timeText,
59
+ "0:00 / 0:05",
60
+ "Should work with real timegroup context",
61
+ );
62
+ });
63
+
64
+ test("handles undefined currentTimeMs gracefully", async () => {
65
+ const { timeDisplay } = renderTimeDisplay(
66
+ html`<ef-preview>
67
+ <ef-timegroup mode="fixed" duration="5s"></ef-timegroup>
68
+ <ef-time-display></ef-time-display>
69
+ </ef-preview>`,
70
+ );
71
+
72
+ // const context = timeDisplay.closest("ef-preview") ;
73
+ await timeDisplay.updateComplete;
74
+
75
+ const timeText = timeDisplay.shadowRoot?.textContent?.trim();
76
+ assert.equal(
77
+ timeText,
78
+ "0:00 / 0:05",
79
+ "Should show 0:00 when currentTimeMs is undefined",
80
+ );
81
+ });
82
+
83
+ test("handles NaN currentTimeMs gracefully", async () => {
84
+ const { timeDisplay } = renderTimeDisplay(
85
+ html`<ef-preview>
86
+ <ef-timegroup mode="fixed" duration="5s"></ef-timegroup>
87
+ <ef-time-display></ef-time-display>
88
+ </ef-preview>`,
89
+ );
90
+
91
+ const context = timeDisplay.closest("ef-preview")!;
92
+ context.currentTimeMs = Number.NaN;
93
+ await timeDisplay.updateComplete;
94
+
95
+ const timeText = timeDisplay.shadowRoot?.textContent?.trim();
96
+ assert.equal(
97
+ timeText,
98
+ "0:00 / 0:05",
99
+ "Should show 0:00 when currentTimeMs is NaN",
100
+ );
101
+ });
102
+
103
+ test("formats time correctly with valid values", async () => {
104
+ const { timeDisplay } = renderTimeDisplay(
105
+ html`<ef-preview>
106
+ <ef-timegroup mode="fixed" duration="5s"></ef-timegroup>
107
+ <ef-time-display></ef-time-display>
108
+ </ef-preview>`,
109
+ );
110
+
111
+ const context = timeDisplay.closest("ef-preview")!;
112
+
113
+ // Create a mock timegroup with duration
114
+ const mockTimegroup = document.createElement("ef-timegroup") as EFTimegroup;
115
+ mockTimegroup.setAttribute("mode", "fixed");
116
+ mockTimegroup.setAttribute("duration", "3s");
117
+
118
+ context.targetTimegroup = mockTimegroup;
119
+ context.currentTimeMs = 1500; // 1.5 seconds = 0:01
120
+ await timeDisplay.updateComplete;
121
+
122
+ await vi.waitUntil(
123
+ () => timeDisplay.shadowRoot?.textContent?.trim() === "0:01 / 0:03",
124
+ );
125
+ });
126
+
127
+ test("formats minutes correctly", async () => {
128
+ const { timeDisplay } = renderTimeDisplay(
129
+ html`<ef-preview>
130
+ <ef-timegroup mode="fixed" duration="125s"></ef-timegroup>
131
+ <ef-time-display></ef-time-display>
132
+ </ef-preview>`,
133
+ );
134
+
135
+ const context = timeDisplay.closest("ef-preview")!;
136
+
137
+ // Create a mock timegroup with longer duration
138
+ const mockTimegroup = document.createElement("ef-timegroup") as EFTimegroup;
139
+ mockTimegroup.setAttribute("mode", "fixed");
140
+ mockTimegroup.setAttribute("duration", "125s"); // 2:05
141
+
142
+ context.currentTimeMs = 75000; // 75 seconds = 1:15
143
+ await vi.waitUntil(
144
+ () => timeDisplay.shadowRoot?.textContent?.trim() === "1:15 / 2:05",
145
+ );
146
+ });
147
+
148
+ test("updates display when time changes", async () => {
149
+ const { timeDisplay } = renderTimeDisplay(
150
+ html`<ef-preview>
151
+ <ef-timegroup mode="fixed" duration="10s"></ef-timegroup>
152
+ <ef-time-display></ef-time-display>
153
+ </ef-preview>`,
154
+ );
155
+
156
+ const context = timeDisplay.closest("ef-preview")!;
157
+
158
+ const mockTimegroup = document.createElement("ef-timegroup") as EFTimegroup;
159
+ mockTimegroup.setAttribute("mode", "fixed");
160
+ mockTimegroup.setAttribute("duration", "10s");
161
+
162
+ context.targetTimegroup = mockTimegroup;
163
+
164
+ // Initial time
165
+ context.currentTimeMs = 2000; // 2 seconds = 0:02
166
+ await timeDisplay.updateComplete;
167
+
168
+ let timeText = timeDisplay.shadowRoot?.textContent?.trim();
169
+ assert.equal(timeText, "0:02 / 0:10");
170
+
171
+ // Update time
172
+ context.currentTimeMs = 7000; // 7 seconds = 0:07
173
+ await timeDisplay.updateComplete;
174
+
175
+ timeText = timeDisplay.shadowRoot?.textContent?.trim();
176
+ assert.equal(timeText, "0:07 / 0:10", "Should update when time changes");
177
+ });
178
+
179
+ test("handles zero duration", async () => {
180
+ const { timeDisplay } = renderTimeDisplay(
181
+ html`<ef-preview>
182
+ <ef-timegroup mode="fixed" duration="10s"></ef-timegroup>
183
+ <ef-time-display></ef-time-display>
184
+ </ef-preview>`,
185
+ );
186
+
187
+ const context = timeDisplay.closest("ef-preview")!;
188
+ const timegroup = context.targetTimegroup!;
189
+
190
+ timegroup.setAttribute("duration", "0s");
191
+ assert.equal(timegroup.durationMs, 0);
192
+
193
+ await timegroup.updateComplete;
194
+ await timeDisplay.updateComplete;
195
+ await vi.waitUntil(
196
+ () => timeDisplay.shadowRoot?.textContent?.trim() === "0:00 / 0:00",
197
+ );
198
+ });
199
+
200
+ test("handles context changes correctly", async () => {
201
+ const { timeDisplay } = renderTimeDisplay(
202
+ html`<ef-preview>
203
+ <ef-timegroup mode="fixed" duration="10s"></ef-timegroup>
204
+ <ef-time-display></ef-time-display>
205
+ </ef-preview>`,
206
+ );
207
+
208
+ const context = timeDisplay.closest("ef-preview")!;
209
+
210
+ // Set initial values
211
+ context.currentTimeMs = 1000;
212
+ await timeDisplay.updateComplete;
213
+
214
+ let timeText = timeDisplay.shadowRoot?.textContent?.trim();
215
+ assert.equal(timeText, "0:01 / 0:10");
216
+
217
+ // Move to a different context (using renderTimeDisplay to get proper TestContext)
218
+ const { timeDisplay: newTimeDisplay, container: newContainer } =
219
+ renderTimeDisplay(
220
+ html`<ef-preview>
221
+ <ef-timegroup mode="fixed" duration="10s"></ef-timegroup>
222
+ <ef-time-display></ef-time-display>
223
+ </ef-preview>`,
224
+ );
225
+
226
+ const newContext = newTimeDisplay.closest("ef-preview")!;
227
+
228
+ // Set different values in new context
229
+ newContext.currentTimeMs = 3000;
230
+ await newTimeDisplay.updateComplete;
231
+
232
+ timeText = newTimeDisplay.shadowRoot?.textContent?.trim();
233
+ assert.equal(timeText, "0:03 / 0:10", "Should work with new context");
234
+
235
+ newContainer.remove();
236
+ });
237
+ });
@@ -1,8 +1,8 @@
1
1
  import { consume } from "@lit/context";
2
2
  import { css, html, LitElement } from "lit";
3
3
  import { customElement } from "lit/decorators.js";
4
- import type { ContextMixinInterface } from "./ContextMixin.js";
5
- import { efContext } from "./efContext.js";
4
+ import { currentTimeContext } from "./currentTimeContext.js";
5
+ import { durationContext } from "./durationContext.js";
6
6
 
7
7
  @customElement("ef-time-display")
8
8
  export class EFTimeDisplay extends LitElement {
@@ -16,46 +16,18 @@ export class EFTimeDisplay extends LitElement {
16
16
  }
17
17
  `;
18
18
 
19
- @consume({ context: efContext, subscribe: true })
20
- context?: ContextMixinInterface | null;
19
+ @consume({ context: currentTimeContext, subscribe: true })
20
+ currentTimeMs = Number.NaN;
21
21
 
22
- private _onTimeUpdate = () => {
23
- this.requestUpdate();
24
- };
22
+ @consume({ context: durationContext, subscribe: true })
23
+ durationMs = 0;
25
24
 
26
- connectedCallback(): void {
27
- super.connectedCallback();
28
- this.context?.addEventListener(
29
- "timeupdate",
30
- this._onTimeUpdate as EventListener,
31
- );
32
- }
33
-
34
- protected updated(changedProperties: Map<PropertyKey, unknown>): void {
35
- if (changedProperties.has("context")) {
36
- // Clean up old listener
37
- const oldContext = changedProperties.get(
38
- "context",
39
- ) as ContextMixinInterface | null;
40
- oldContext?.removeEventListener(
41
- "timeupdate",
42
- this._onTimeUpdate as EventListener,
43
- );
44
-
45
- // Add new listener
46
- this.context?.addEventListener(
47
- "timeupdate",
48
- this._onTimeUpdate as EventListener,
49
- );
25
+ private formatTime(ms: number): string {
26
+ // Handle NaN, undefined, null, or negative values
27
+ if (!Number.isFinite(ms) || ms < 0) {
28
+ return "0:00";
50
29
  }
51
- }
52
-
53
- disconnectedCallback(): void {
54
- this.context?.removeEventListener("timeupdate", this._onTimeUpdate);
55
- super.disconnectedCallback();
56
- }
57
30
 
58
- private formatTime(ms: number): string {
59
31
  const totalSeconds = Math.floor(ms / 1000);
60
32
  const minutes = Math.floor(totalSeconds / 60);
61
33
  const seconds = totalSeconds % 60;
@@ -63,8 +35,8 @@ export class EFTimeDisplay extends LitElement {
63
35
  }
64
36
 
65
37
  render() {
66
- const currentTime = this.context?.currentTimeMs ?? 0;
67
- const totalTime = this.context?.targetTimegroup?.durationMs ?? 0;
38
+ const currentTime = this.currentTimeMs;
39
+ const totalTime = this.durationMs;
68
40
 
69
41
  return html`
70
42
  <span part="time">
@@ -0,0 +1,5 @@
1
+ import { createContext } from "@lit/context";
2
+
3
+ export const currentTimeContext = createContext<number>(
4
+ Symbol("currentTimeMs"),
5
+ );
@@ -0,0 +1,3 @@
1
+ import { createContext } from "@lit/context";
2
+
3
+ export const durationContext = createContext<number>(Symbol("durationMs"));
@@ -274,6 +274,19 @@ export interface MediaEngine {
274
274
  maxVideoBufferFetches: number;
275
275
  maxAudioBufferFetches: number;
276
276
  };
277
+
278
+ /**
279
+ * Extract thumbnail canvases at multiple timestamps efficiently
280
+ * Uses scrub rendition and batches by segment for optimal performance
281
+ * Returns thumbnail objects in same order as input timestamps
282
+ * Returns null for any timestamps that fail to extract
283
+ */
284
+ extractThumbnails(timestamps: number[]): Promise<(ThumbnailResult | null)[]>;
285
+ }
286
+
287
+ export interface ThumbnailResult {
288
+ timestamp: number;
289
+ thumbnail: HTMLCanvasElement | OffscreenCanvas;
277
290
  }
278
291
  interface InitSegmentPath {
279
292
  path: string;
@@ -0,0 +1,272 @@
1
+ import { describe, expect, test } from "vitest";
2
+ import { LRUCache, OrderedLRUCache } from "./LRUCache";
3
+
4
+ describe("LRUCache", () => {
5
+ test("basic LRU functionality", () => {
6
+ const cache = new LRUCache<string, number>(3);
7
+
8
+ cache.set("a", 1);
9
+ cache.set("b", 2);
10
+ cache.set("c", 3);
11
+
12
+ expect(cache.get("a")).toBe(1);
13
+ expect(cache.get("b")).toBe(2);
14
+ expect(cache.get("c")).toBe(3);
15
+ expect(cache.size).toBe(3);
16
+
17
+ // Should evict oldest when adding fourth item
18
+ cache.set("d", 4);
19
+ expect(cache.size).toBe(3);
20
+ expect(cache.has("a")).toBe(false); // 'a' was least recently used
21
+ expect(cache.get("d")).toBe(4);
22
+ });
23
+ });
24
+
25
+ describe("OrderedLRUCache", () => {
26
+ test("basic LRU functionality with ordered keys", () => {
27
+ const cache = new OrderedLRUCache<number, string>(3);
28
+
29
+ cache.set(10, "ten");
30
+ cache.set(5, "five");
31
+ cache.set(15, "fifteen");
32
+
33
+ expect(cache.get(10)).toBe("ten");
34
+ expect(cache.get(5)).toBe("five");
35
+ expect(cache.get(15)).toBe("fifteen");
36
+ expect(cache.size).toBe(3);
37
+ });
38
+
39
+ test("maintains sorted key order", () => {
40
+ const cache = new OrderedLRUCache<number, string>(5);
41
+
42
+ cache.set(30, "thirty");
43
+ cache.set(10, "ten");
44
+ cache.set(20, "twenty");
45
+ cache.set(40, "forty");
46
+ cache.set(15, "fifteen");
47
+
48
+ const sortedKeys = cache.getSortedKeys();
49
+ expect(sortedKeys).toEqual([10, 15, 20, 30, 40]);
50
+ });
51
+
52
+ test("binary search exact match", () => {
53
+ const cache = new OrderedLRUCache<number, string>(5);
54
+
55
+ cache.set(10, "ten");
56
+ cache.set(20, "twenty");
57
+ cache.set(30, "thirty");
58
+
59
+ expect(cache.findExact(20)).toBe("twenty");
60
+ expect(cache.findExact(25)).toBe(undefined);
61
+ });
62
+
63
+ test("find nearest in range", () => {
64
+ const cache = new OrderedLRUCache<number, string>(5);
65
+
66
+ cache.set(10, "ten");
67
+ cache.set(20, "twenty");
68
+ cache.set(30, "thirty");
69
+ cache.set(40, "forty");
70
+
71
+ // Test various range scenarios
72
+ const range12_5 = cache.findNearestInRange(12, 5);
73
+ expect(range12_5.map((item) => item.key)).toEqual([10]); // 12±5 = [7,17] contains only 10
74
+
75
+ const range25_8 = cache.findNearestInRange(25, 8);
76
+ expect(range25_8.map((item) => item.key)).toEqual([20, 30]); // 25±8 = [17,33] contains 20,30
77
+
78
+ const range35_3 = cache.findNearestInRange(35, 3);
79
+ expect(range35_3.map((item) => item.key)).toEqual([]); // 35±3 = [32,38] contains nothing
80
+
81
+ const range50_15 = cache.findNearestInRange(50, 15);
82
+ expect(range50_15.map((item) => item.key)).toEqual([40]); // 50±15 = [35,65] contains only 40
83
+
84
+ const range25_15 = cache.findNearestInRange(25, 15);
85
+ expect(range25_15.map((item) => item.key)).toEqual([10, 20, 30, 40]); // 25±15 = [10,40] contains all
86
+ });
87
+
88
+ test("range search", () => {
89
+ const cache = new OrderedLRUCache<number, string>(10);
90
+
91
+ cache.set(10, "ten");
92
+ cache.set(15, "fifteen");
93
+ cache.set(20, "twenty");
94
+ cache.set(25, "twenty-five");
95
+ cache.set(30, "thirty");
96
+ cache.set(35, "thirty-five");
97
+
98
+ const range1 = cache.findRange(15, 30);
99
+ expect(range1.map((item) => item.key)).toEqual([15, 20, 25, 30]);
100
+
101
+ const range2 = cache.findRange(12, 23);
102
+ expect(range2.map((item) => item.key)).toEqual([15, 20]);
103
+
104
+ const range3 = cache.findRange(40, 50);
105
+ expect(range3).toEqual([]); // No keys in range
106
+ });
107
+
108
+ test("LRU eviction maintains sorted order", () => {
109
+ const cache = new OrderedLRUCache<number, string>(3);
110
+
111
+ cache.set(30, "thirty");
112
+ cache.set(10, "ten");
113
+ cache.set(20, "twenty");
114
+
115
+ // Access 10 to make it most recent
116
+ cache.get(10);
117
+
118
+ // Add new item, should evict 30 (least recently used)
119
+ cache.set(40, "forty");
120
+
121
+ expect(cache.has(30)).toBe(false);
122
+ expect(cache.getSortedKeys()).toEqual([10, 20, 40]);
123
+ });
124
+
125
+ test("works with string keys and custom comparator", () => {
126
+ const cache = new OrderedLRUCache<string, number>(5, (a, b) =>
127
+ a.localeCompare(b),
128
+ );
129
+
130
+ cache.set("banana", 2);
131
+ cache.set("apple", 1);
132
+ cache.set("cherry", 3);
133
+ cache.set("date", 4);
134
+
135
+ expect(cache.getSortedKeys()).toEqual([
136
+ "apple",
137
+ "banana",
138
+ "cherry",
139
+ "date",
140
+ ]);
141
+
142
+ // For strings, findNearestInRange only works for exact matches since we can't calculate string distance
143
+ const nearestExact = cache.findNearestInRange("cherry", "cherry");
144
+ expect(nearestExact.map((item) => item.key)).toEqual(["cherry"]);
145
+
146
+ // But range searches work normally
147
+ const range = cache.findRange("banana", "date");
148
+ expect(range.map((item) => item.key)).toEqual(["banana", "cherry", "date"]);
149
+ });
150
+
151
+ test("handles empty cache gracefully", () => {
152
+ const cache = new OrderedLRUCache<number, string>(5);
153
+
154
+ expect(cache.findExact(10)).toBe(undefined);
155
+ expect(cache.findNearestInRange(10, 5)).toEqual([]);
156
+ expect(cache.findRange(1, 10)).toEqual([]);
157
+ expect(cache.getSortedKeys()).toEqual([]);
158
+ });
159
+
160
+ test("handles single item cache", () => {
161
+ const cache = new OrderedLRUCache<number, string>(5);
162
+ cache.set(10, "ten");
163
+
164
+ expect(cache.findNearestInRange(5, 10)).toEqual([
165
+ { key: 10, value: "ten" },
166
+ ]); // 5±10 = [-5,15] contains 10
167
+ expect(cache.findNearestInRange(15, 10)).toEqual([
168
+ { key: 10, value: "ten" },
169
+ ]); // 15±10 = [5,25] contains 10
170
+ expect(cache.findRange(1, 20)).toEqual([{ key: 10, value: "ten" }]);
171
+ });
172
+
173
+ test("updates existing keys without duplicating in sorted index", () => {
174
+ const cache = new OrderedLRUCache<number, string>(5);
175
+
176
+ cache.set(10, "ten");
177
+ cache.set(20, "twenty");
178
+ cache.set(10, "TEN"); // Update existing key
179
+
180
+ expect(cache.getSortedKeys()).toEqual([10, 20]); // No duplicates
181
+ expect(cache.get(10)).toBe("TEN");
182
+ expect(cache.size).toBe(2);
183
+ });
184
+
185
+ // Example use case: video segment caching by timestamp
186
+ test("video segment caching use case", () => {
187
+ interface VideoSegment {
188
+ url: string;
189
+ duration: number;
190
+ bitrate: number;
191
+ }
192
+
193
+ const segmentCache = new OrderedLRUCache<number, VideoSegment>(10);
194
+
195
+ // Add segments with timestamps as keys
196
+ segmentCache.set(0, { url: "/seg0.mp4", duration: 5000, bitrate: 1000 });
197
+ segmentCache.set(5000, { url: "/seg1.mp4", duration: 5000, bitrate: 1000 });
198
+ segmentCache.set(10000, {
199
+ url: "/seg2.mp4",
200
+ duration: 5000,
201
+ bitrate: 1000,
202
+ });
203
+ segmentCache.set(15000, {
204
+ url: "/seg3.mp4",
205
+ duration: 5000,
206
+ bitrate: 1000,
207
+ });
208
+
209
+ // Find segment at specific time
210
+ const segment = segmentCache.findExact(10000);
211
+ expect(segment?.url).toBe("/seg2.mp4");
212
+
213
+ // Find segments near seeking position 7500ms (within 3 seconds)
214
+ const nearSeekPoint = segmentCache.findNearestInRange(7500, 3000);
215
+ expect(nearSeekPoint.map((s) => s.key)).toEqual([5000, 10000]); // 7500±3000 = [4500,10500] contains these
216
+
217
+ // Get all segments in time range 5s-15s
218
+ const range = segmentCache.findRange(5000, 15000);
219
+ expect(range.length).toBe(3);
220
+ expect(range.map((s) => s.key)).toEqual([5000, 10000, 15000]);
221
+ });
222
+
223
+ test("findNearestInRange edge cases", () => {
224
+ const cache = new OrderedLRUCache<number, string>(10);
225
+
226
+ cache.set(100, "hundred");
227
+ cache.set(200, "two-hundred");
228
+ cache.set(300, "three-hundred");
229
+
230
+ // Test that it can return empty array when no items in range
231
+ const emptyResult = cache.findNearestInRange(50, 10);
232
+ expect(emptyResult).toEqual([]); // 50±10 = [40,60] contains nothing
233
+
234
+ // Test exact center match
235
+ const exactCenter = cache.findNearestInRange(200, 0);
236
+ expect(exactCenter.map((item) => item.key)).toEqual([200]); // 200±0 = [200,200] exact match
237
+
238
+ // Test asymmetric range (center closer to one side)
239
+ const asymmetric = cache.findNearestInRange(150, 60);
240
+ expect(asymmetric.map((item) => item.key)).toEqual([100, 200]); // 150±60 = [90,210] contains 100,200
241
+
242
+ // Test very large range containing all items
243
+ const allItems = cache.findNearestInRange(200, 500);
244
+ expect(allItems.map((item) => item.key)).toEqual([100, 200, 300]); // 200±500 = [-300,700] contains all
245
+ });
246
+
247
+ test("performance characteristics with large dataset", () => {
248
+ const cache = new OrderedLRUCache<number, string>(1000);
249
+
250
+ // Insert many items
251
+ const insertStart = performance.now();
252
+ for (let i = 0; i < 1000; i++) {
253
+ cache.set(Math.random() * 10000, `value-${i}`);
254
+ }
255
+ const insertTime = performance.now() - insertStart;
256
+
257
+ // Perform searches
258
+ const searchStart = performance.now();
259
+ for (let i = 0; i < 100; i++) {
260
+ cache.findNearestInRange(Math.random() * 10000, 500);
261
+ cache.findRange(Math.random() * 5000, Math.random() * 5000 + 1000);
262
+ }
263
+ const searchTime = performance.now() - searchStart;
264
+
265
+ // These should complete quickly for O(log n) operations
266
+ expect(insertTime).toBeLessThan(100); // Should be very fast
267
+ expect(searchTime).toBeLessThan(50); // Searches should be even faster
268
+
269
+ expect(cache.size).toBe(1000);
270
+ expect(cache.getSortedKeys().length).toBe(1000);
271
+ });
272
+ });