@editframe/elements 0.26.3-beta.0 → 0.26.4-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/package.json +2 -2
  2. package/scripts/build-css.js +3 -3
  3. package/tsdown.config.ts +1 -1
  4. package/src/elements/ContextProxiesController.ts +0 -124
  5. package/src/elements/CrossUpdateController.ts +0 -22
  6. package/src/elements/EFAudio.browsertest.ts +0 -706
  7. package/src/elements/EFAudio.ts +0 -56
  8. package/src/elements/EFCaptions.browsertest.ts +0 -1960
  9. package/src/elements/EFCaptions.ts +0 -823
  10. package/src/elements/EFImage.browsertest.ts +0 -120
  11. package/src/elements/EFImage.ts +0 -113
  12. package/src/elements/EFMedia/AssetIdMediaEngine.test.ts +0 -224
  13. package/src/elements/EFMedia/AssetIdMediaEngine.ts +0 -110
  14. package/src/elements/EFMedia/AssetMediaEngine.browsertest.ts +0 -140
  15. package/src/elements/EFMedia/AssetMediaEngine.ts +0 -385
  16. package/src/elements/EFMedia/BaseMediaEngine.browsertest.ts +0 -400
  17. package/src/elements/EFMedia/BaseMediaEngine.ts +0 -505
  18. package/src/elements/EFMedia/BufferedSeekingInput.browsertest.ts +0 -386
  19. package/src/elements/EFMedia/BufferedSeekingInput.ts +0 -430
  20. package/src/elements/EFMedia/JitMediaEngine.browsertest.ts +0 -226
  21. package/src/elements/EFMedia/JitMediaEngine.ts +0 -256
  22. package/src/elements/EFMedia/audioTasks/makeAudioBufferTask.browsertest.ts +0 -679
  23. package/src/elements/EFMedia/audioTasks/makeAudioBufferTask.ts +0 -117
  24. package/src/elements/EFMedia/audioTasks/makeAudioFrequencyAnalysisTask.ts +0 -246
  25. package/src/elements/EFMedia/audioTasks/makeAudioInitSegmentFetchTask.browsertest.ts +0 -59
  26. package/src/elements/EFMedia/audioTasks/makeAudioInitSegmentFetchTask.ts +0 -27
  27. package/src/elements/EFMedia/audioTasks/makeAudioInputTask.browsertest.ts +0 -55
  28. package/src/elements/EFMedia/audioTasks/makeAudioInputTask.ts +0 -53
  29. package/src/elements/EFMedia/audioTasks/makeAudioSeekTask.chunkboundary.regression.browsertest.ts +0 -207
  30. package/src/elements/EFMedia/audioTasks/makeAudioSeekTask.ts +0 -72
  31. package/src/elements/EFMedia/audioTasks/makeAudioSegmentFetchTask.ts +0 -32
  32. package/src/elements/EFMedia/audioTasks/makeAudioSegmentIdTask.ts +0 -29
  33. package/src/elements/EFMedia/audioTasks/makeAudioTasksVideoOnly.browsertest.ts +0 -95
  34. package/src/elements/EFMedia/audioTasks/makeAudioTimeDomainAnalysisTask.ts +0 -184
  35. package/src/elements/EFMedia/shared/AudioSpanUtils.ts +0 -129
  36. package/src/elements/EFMedia/shared/BufferUtils.ts +0 -342
  37. package/src/elements/EFMedia/shared/GlobalInputCache.ts +0 -77
  38. package/src/elements/EFMedia/shared/MediaTaskUtils.ts +0 -44
  39. package/src/elements/EFMedia/shared/PrecisionUtils.ts +0 -46
  40. package/src/elements/EFMedia/shared/RenditionHelpers.browsertest.ts +0 -246
  41. package/src/elements/EFMedia/shared/RenditionHelpers.ts +0 -56
  42. package/src/elements/EFMedia/shared/ThumbnailExtractor.ts +0 -227
  43. package/src/elements/EFMedia/tasks/makeMediaEngineTask.browsertest.ts +0 -167
  44. package/src/elements/EFMedia/tasks/makeMediaEngineTask.ts +0 -88
  45. package/src/elements/EFMedia/videoTasks/MainVideoInputCache.ts +0 -76
  46. package/src/elements/EFMedia/videoTasks/ScrubInputCache.ts +0 -61
  47. package/src/elements/EFMedia/videoTasks/makeScrubVideoBufferTask.ts +0 -114
  48. package/src/elements/EFMedia/videoTasks/makeScrubVideoInitSegmentFetchTask.ts +0 -35
  49. package/src/elements/EFMedia/videoTasks/makeScrubVideoInputTask.ts +0 -52
  50. package/src/elements/EFMedia/videoTasks/makeScrubVideoSeekTask.ts +0 -124
  51. package/src/elements/EFMedia/videoTasks/makeScrubVideoSegmentFetchTask.ts +0 -44
  52. package/src/elements/EFMedia/videoTasks/makeScrubVideoSegmentIdTask.ts +0 -32
  53. package/src/elements/EFMedia/videoTasks/makeUnifiedVideoSeekTask.ts +0 -370
  54. package/src/elements/EFMedia/videoTasks/makeVideoBufferTask.ts +0 -109
  55. package/src/elements/EFMedia.browsertest.ts +0 -872
  56. package/src/elements/EFMedia.ts +0 -341
  57. package/src/elements/EFSourceMixin.ts +0 -60
  58. package/src/elements/EFSurface.browsertest.ts +0 -151
  59. package/src/elements/EFSurface.ts +0 -142
  60. package/src/elements/EFTemporal.browsertest.ts +0 -215
  61. package/src/elements/EFTemporal.ts +0 -800
  62. package/src/elements/EFThumbnailStrip.browsertest.ts +0 -585
  63. package/src/elements/EFThumbnailStrip.media-engine.browsertest.ts +0 -714
  64. package/src/elements/EFThumbnailStrip.ts +0 -906
  65. package/src/elements/EFTimegroup.browsertest.ts +0 -934
  66. package/src/elements/EFTimegroup.ts +0 -882
  67. package/src/elements/EFVideo.browsertest.ts +0 -1482
  68. package/src/elements/EFVideo.ts +0 -564
  69. package/src/elements/EFWaveform.ts +0 -547
  70. package/src/elements/FetchContext.browsertest.ts +0 -401
  71. package/src/elements/FetchMixin.ts +0 -38
  72. package/src/elements/SampleBuffer.ts +0 -94
  73. package/src/elements/TargetController.browsertest.ts +0 -230
  74. package/src/elements/TargetController.ts +0 -224
  75. package/src/elements/TimegroupController.ts +0 -26
  76. package/src/elements/durationConverter.ts +0 -35
  77. package/src/elements/parseTimeToMs.ts +0 -9
  78. package/src/elements/printTaskStatus.ts +0 -16
  79. package/src/elements/renderTemporalAudio.ts +0 -108
  80. package/src/elements/updateAnimations.browsertest.ts +0 -1884
  81. package/src/elements/updateAnimations.ts +0 -217
  82. package/src/elements/util.ts +0 -24
  83. package/src/gui/ContextMixin.browsertest.ts +0 -860
  84. package/src/gui/ContextMixin.ts +0 -562
  85. package/src/gui/Controllable.browsertest.ts +0 -258
  86. package/src/gui/Controllable.ts +0 -41
  87. package/src/gui/EFConfiguration.ts +0 -40
  88. package/src/gui/EFControls.browsertest.ts +0 -389
  89. package/src/gui/EFControls.ts +0 -195
  90. package/src/gui/EFDial.browsertest.ts +0 -84
  91. package/src/gui/EFDial.ts +0 -172
  92. package/src/gui/EFFilmstrip.browsertest.ts +0 -712
  93. package/src/gui/EFFilmstrip.ts +0 -1349
  94. package/src/gui/EFFitScale.ts +0 -152
  95. package/src/gui/EFFocusOverlay.ts +0 -79
  96. package/src/gui/EFPause.browsertest.ts +0 -202
  97. package/src/gui/EFPause.ts +0 -73
  98. package/src/gui/EFPlay.browsertest.ts +0 -202
  99. package/src/gui/EFPlay.ts +0 -73
  100. package/src/gui/EFPreview.ts +0 -74
  101. package/src/gui/EFResizableBox.browsertest.ts +0 -79
  102. package/src/gui/EFResizableBox.ts +0 -898
  103. package/src/gui/EFScrubber.ts +0 -151
  104. package/src/gui/EFTimeDisplay.browsertest.ts +0 -237
  105. package/src/gui/EFTimeDisplay.ts +0 -55
  106. package/src/gui/EFToggleLoop.ts +0 -35
  107. package/src/gui/EFTogglePlay.ts +0 -70
  108. package/src/gui/EFWorkbench.ts +0 -115
  109. package/src/gui/PlaybackController.ts +0 -527
  110. package/src/gui/TWMixin.css +0 -6
  111. package/src/gui/TWMixin.ts +0 -61
  112. package/src/gui/TargetOrContextMixin.ts +0 -185
  113. package/src/gui/currentTimeContext.ts +0 -5
  114. package/src/gui/durationContext.ts +0 -3
  115. package/src/gui/efContext.ts +0 -6
  116. package/src/gui/fetchContext.ts +0 -5
  117. package/src/gui/focusContext.ts +0 -7
  118. package/src/gui/focusedElementContext.ts +0 -5
  119. package/src/gui/playingContext.ts +0 -5
  120. package/src/otel/BridgeSpanExporter.ts +0 -150
  121. package/src/otel/setupBrowserTracing.ts +0 -73
  122. package/src/otel/tracingHelpers.ts +0 -251
  123. package/src/transcoding/cache/RequestDeduplicator.test.ts +0 -170
  124. package/src/transcoding/cache/RequestDeduplicator.ts +0 -65
  125. package/src/transcoding/cache/URLTokenDeduplicator.test.ts +0 -182
  126. package/src/transcoding/cache/URLTokenDeduplicator.ts +0 -101
  127. package/src/transcoding/types/index.ts +0 -312
  128. package/src/transcoding/utils/MediaUtils.ts +0 -63
  129. package/src/transcoding/utils/UrlGenerator.ts +0 -68
  130. package/src/transcoding/utils/constants.ts +0 -36
  131. package/src/utils/LRUCache.test.ts +0 -274
  132. package/src/utils/LRUCache.ts +0 -696
@@ -1,906 +0,0 @@
1
- import { Task } from "@lit/task";
2
- import { css, html, LitElement } from "lit";
3
- import { customElement, property, state } from "lit/decorators.js";
4
- import { createRef, ref } from "lit/directives/ref.js";
5
- import type { MediaEngine as ImportedMediaEngine } from "../transcoding/types/index.js";
6
- import { OrderedLRUCache } from "../utils/LRUCache.js";
7
- import type { EFVideo } from "./EFVideo.js";
8
- import { TargetController } from "./TargetController.ts";
9
-
10
- /**
11
- * Global thumbnail image cache for smooth resize performance
12
- * Shared across all thumbnail strip instances
13
- * Uses OrderedLRUCache for efficient timestamp-based searching
14
- */
15
- const thumbnailImageCache = new OrderedLRUCache<string, ImageData>(
16
- 200,
17
- (a, b) => {
18
- // Extract timestamp from cache key for ordered searching (take last part after splitting on ':')
19
- const partsA = a.split(":");
20
- const partsB = b.split(":");
21
- const timeA = Number.parseFloat(partsA[partsA.length - 1] || "0");
22
- const timeB = Number.parseFloat(partsB[partsB.length - 1] || "0");
23
- return timeA - timeB;
24
- },
25
- );
26
-
27
- // Export for debugging (works in both browser and server)
28
- (
29
- globalThis as typeof globalThis & {
30
- debugThumbnailCache: typeof thumbnailImageCache;
31
- }
32
- ).debugThumbnailCache = thumbnailImageCache;
33
-
34
- /**
35
- * Quantize timestamp to 30fps frame boundaries for consistent caching
36
- * This eliminates cache misses from floating point precision differences
37
- */
38
- function quantizeTimestamp(timeMs: number): number {
39
- const frameIntervalMs = 1000 / 30; // 33.33ms at 30fps
40
- return Math.round(timeMs / frameIntervalMs) * frameIntervalMs;
41
- }
42
-
43
- /**
44
- * Generate cache key for thumbnail image data (dimension-independent, quantized)
45
- */
46
- function getThumbnailCacheKey(videoSrc: string, timeMs: number): string {
47
- const quantizedTimeMs = quantizeTimestamp(timeMs);
48
- return `${videoSrc}:${quantizedTimeMs}`;
49
- }
50
-
51
- // Constants for consistent thumbnail layout
52
- const THUMBNAIL_GAP = 1; // 1px gap between thumbnails
53
- const STRIP_BORDER_PADDING = 4; // Account for border/padding in available height
54
-
55
- interface ThumbnailSegment {
56
- segmentId: number;
57
- thumbnails: Array<{
58
- timeMs: number;
59
- }>;
60
- }
61
-
62
- interface ThumbnailLayout {
63
- count: number;
64
- segments: readonly ThumbnailSegment[];
65
- }
66
-
67
- // Use the imported MediaEngine type and mediabunny types
68
-
69
- interface ThumbnailRenderInfo {
70
- timeMs: number;
71
- segmentId: number;
72
- x: number;
73
- width: number;
74
- height: number;
75
- status: "exact-hit" | "near-hit" | "missing" | "loading";
76
- imageData?: ImageData;
77
- nearHitKey?: string;
78
- }
79
-
80
- /**
81
- * Calculate optimal thumbnail count and timestamps for the strip
82
- * Groups thumbnails by scrub segment ID for efficient caching
83
- */
84
- function calculateThumbnailLayout(
85
- stripWidth: number,
86
- thumbnailWidth: number,
87
- startTimeMs: number,
88
- endTimeMs: number,
89
- scrubSegmentDurationMs?: number,
90
- ): ThumbnailLayout {
91
- // Must have positive width and valid time range
92
- if (stripWidth <= 0 || thumbnailWidth <= 0 || endTimeMs <= startTimeMs) {
93
- return { count: 0, segments: [] };
94
- }
95
-
96
- // Simple calculation: how many full thumbnails fit, plus one more to fill the width
97
- const thumbnailPitch = thumbnailWidth + THUMBNAIL_GAP;
98
- const baseFitCount = Math.floor(stripWidth / thumbnailPitch);
99
- const count = Math.max(1, baseFitCount + 1); // Always one extra to fill width
100
-
101
- // Generate timestamps evenly distributed across time range
102
- const timestamps: number[] = [];
103
- const timeRange = endTimeMs - startTimeMs;
104
-
105
- for (let i = 0; i < count; i++) {
106
- const timeMs =
107
- count === 1
108
- ? (startTimeMs + endTimeMs) / 2
109
- : startTimeMs + (i * timeRange) / (count - 1);
110
- timestamps.push(timeMs);
111
- }
112
-
113
- // Group by segment ID
114
- const segmentMap = new Map<number, Array<{ timeMs: number }>>();
115
- for (const timeMs of timestamps) {
116
- const segmentId = scrubSegmentDurationMs
117
- ? Math.floor(timeMs / scrubSegmentDurationMs)
118
- : 0;
119
- if (!segmentMap.has(segmentId)) {
120
- segmentMap.set(segmentId, []);
121
- }
122
- // biome-ignore lint/style/noNonNullAssertion: Set in line above
123
- segmentMap.get(segmentId)!.push({ timeMs });
124
- }
125
-
126
- const segments = Array.from(segmentMap.entries())
127
- .sort(([a], [b]) => a - b)
128
- .map(([segmentId, thumbnails]) => ({ segmentId, thumbnails }));
129
-
130
- return { count, segments };
131
- }
132
-
133
- @customElement("ef-thumbnail-strip")
134
- export class EFThumbnailStrip extends LitElement {
135
- static styles = [
136
- css`
137
- :host {
138
- display: block;
139
- position: relative;
140
- width: 100%;
141
- height: 48px; /* Default filmstrip height */
142
- background: #2a2a2a;
143
- border: 2px solid #333;
144
- border-radius: 6px;
145
- overflow: hidden;
146
- box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.3);
147
- }
148
- canvas {
149
- display: block;
150
- position: absolute;
151
- top: 0;
152
- left: 0;
153
- image-rendering: pixelated; /* Keep thumbnails crisp */
154
- /* Width and height set programmatically to prevent CSS scaling */
155
- }
156
- .loading-overlay {
157
- position: absolute;
158
- top: 0;
159
- left: 0;
160
- right: 0;
161
- bottom: 0;
162
- background: rgba(42, 42, 42, 0.9);
163
- display: flex;
164
- align-items: center;
165
- justify-content: center;
166
- font-size: 11px;
167
- color: #ccc;
168
- font-weight: 500;
169
- }
170
- `,
171
- ];
172
-
173
- canvasRef = createRef<HTMLCanvasElement>();
174
-
175
- // Target video element using the same pattern as EFSurface
176
- // @ts-expect-error controller is intentionally not referenced directly to prevent GC
177
- // biome-ignore lint/correctness/noUnusedPrivateClassMembers: Used for side effects
178
- private _targetController: TargetController = new TargetController(
179
- this as any,
180
- );
181
-
182
- private _targetElement: EFVideo | null = null;
183
-
184
- @state()
185
- get targetElement(): EFVideo | null {
186
- return this._targetElement;
187
- }
188
-
189
- set targetElement(value: EFVideo | null) {
190
- const oldValue = this._targetElement;
191
- this._targetElement = value;
192
-
193
- // Clean up previous video property observer
194
- this._videoPropertyObserver?.disconnect();
195
-
196
- // When target element changes, set up property watching and media engine listener
197
- if (value && value !== oldValue) {
198
- // Watch for video property changes that affect thumbnails
199
- this._videoPropertyObserver = new MutationObserver((mutations) => {
200
- let shouldUpdate = false;
201
- for (const mutation of mutations) {
202
- if (mutation.type === "attributes" && mutation.attributeName) {
203
- const attr = mutation.attributeName;
204
- if (
205
- attr === "trimstart" ||
206
- attr === "trimend" ||
207
- attr === "sourcein" ||
208
- attr === "sourceout" ||
209
- attr === "src"
210
- ) {
211
- shouldUpdate = true;
212
- break;
213
- }
214
- }
215
- }
216
- if (shouldUpdate) {
217
- this.runThumbnailUpdate();
218
- }
219
- });
220
-
221
- this._videoPropertyObserver.observe(value, {
222
- attributes: true,
223
- attributeFilter: [
224
- "trimstart",
225
- "trimend",
226
- "sourcein",
227
- "sourceout",
228
- "src",
229
- ],
230
- });
231
-
232
- // Listen for media engine ready
233
- if (value.mediaEngineTask) {
234
- value.mediaEngineTask.taskComplete
235
- .then(() => {
236
- // When media engine is ready, retrigger thumbnails if we have width
237
- if (this._stripWidth > 0) {
238
- this.thumbnailLayoutTask.run();
239
- }
240
- })
241
- .catch(() => {
242
- // Ignore media engine errors
243
- });
244
- }
245
- }
246
-
247
- this.requestUpdate("targetElement", oldValue);
248
- }
249
-
250
- @property({ type: String })
251
- target = "";
252
-
253
- /**
254
- * Desired thumbnail width in pixels (height is determined by aspect ratio)
255
- * Number of thumbnails is derived from this and available strip width
256
- */
257
- @property({ type: Number, attribute: "thumbnail-width" })
258
- thumbnailWidth = 80;
259
-
260
- /**
261
- * Custom start time in milliseconds relative to trimmed timeline (0 = start of trimmed portion)
262
- * In trimmed mode: 0ms = sourceStartMs, 1000ms = sourceStartMs + 1000ms
263
- * In intrinsic mode: 0ms = 0ms in source media
264
- */
265
- @property({ type: Number, attribute: "start-time-ms" })
266
- startTimeMs?: number;
267
-
268
- /**
269
- * Custom end time in milliseconds relative to trimmed timeline
270
- * In trimmed mode: relative to sourceStartMs
271
- * In intrinsic mode: relative to source media start (0ms)
272
- */
273
- @property({ type: Number, attribute: "end-time-ms" })
274
- endTimeMs?: number;
275
-
276
- /**
277
- * Use intrinsic duration instead of trimmed duration
278
- * Accepts "true"/"false" string values or boolean
279
- */
280
- @property({
281
- type: Boolean,
282
- attribute: "use-intrinsic-duration",
283
- reflect: true,
284
- converter: {
285
- fromAttribute: (value: string | null) => {
286
- if (value === null) return false;
287
- return value === "true";
288
- },
289
- toAttribute: (value: boolean) => (value ? "true" : null),
290
- },
291
- })
292
- useIntrinsicDuration = false;
293
-
294
- private _stripWidth = 0;
295
- private _stripHeight = 48; // Default height, updated by ResizeObserver
296
- private _pendingStripWidth: number | undefined;
297
- private _thumbnailLayoutTask: Promise<ThumbnailRenderInfo[]> | undefined;
298
- @state()
299
- private set stripWidth(value: number) {
300
- if (this._thumbnailLayoutTask) {
301
- this._pendingStripWidth = value;
302
- return;
303
- }
304
- this._stripWidth = value;
305
-
306
- if (value > 0) {
307
- this._thumbnailLayoutTask = this.thumbnailLayoutTask
308
- .run()
309
- .then(async () => {
310
- // Use taskComplete and .value instead of promise return value
311
- await this.thumbnailLayoutTask.taskComplete;
312
- const layout = this.thumbnailLayoutTask.value;
313
- return layout ? this.runThumbnailRenderTask(layout) : [];
314
- })
315
- .finally(() => {
316
- this._thumbnailLayoutTask = undefined;
317
- if (this._pendingStripWidth) {
318
- this.stripWidth = this._pendingStripWidth;
319
- this._pendingStripWidth = undefined;
320
- }
321
- });
322
- }
323
- }
324
- private get stripWidth() {
325
- return this._stripWidth;
326
- }
327
-
328
- /**
329
- * Run thumbnail render task directly with provided layout (bypasses task args dependency)
330
- */
331
- private async runThumbnailRenderTask(
332
- layout: ThumbnailLayout,
333
- ): Promise<ThumbnailRenderInfo[]> {
334
- if (!layout || !this.targetElement || layout.count === 0) {
335
- return [];
336
- }
337
-
338
- // Run the thumbnail render task logic directly
339
- return this.renderThumbnails(
340
- layout,
341
- this.targetElement,
342
- this.thumbnailWidth,
343
- );
344
- }
345
-
346
- private resizeObserver?: ResizeObserver;
347
- private _thumbnailUpdateInProgress = false;
348
- private _pendingThumbnailUpdate = false;
349
- private _videoPropertyObserver?: MutationObserver;
350
-
351
- updated(changedProperties: Map<string | number | symbol, unknown>) {
352
- super.updated(changedProperties);
353
-
354
- // IMPLEMENTATION GUIDELINES: Fix for initial loading bug - ensure width is detected
355
- if (this._stripWidth === 0) {
356
- const width = this.clientWidth;
357
- if (width > 0) {
358
- this.stripWidth = width;
359
- }
360
- }
361
-
362
- // IMPLEMENTATION GUIDELINES: Responsive debouncing for thumbnail property changes using EFTimegroup pattern
363
- if (
364
- changedProperties.has("thumbnailWidth") ||
365
- changedProperties.has("startTimeMs") ||
366
- changedProperties.has("endTimeMs") ||
367
- changedProperties.has("useIntrinsicDuration")
368
- ) {
369
- this.runThumbnailUpdate();
370
- }
371
- }
372
-
373
- /**
374
- * Run thumbnail update with responsive debouncing (based on EFTimegroup currentTime pattern)
375
- */
376
- private runThumbnailUpdate() {
377
- // If update already in progress, just flag that another update is needed
378
- if (this._thumbnailUpdateInProgress) {
379
- this._pendingThumbnailUpdate = true;
380
- return;
381
- }
382
-
383
- this._thumbnailUpdateInProgress = true;
384
-
385
- // Trigger full layout→render pipeline immediately for responsiveness
386
- this.thumbnailLayoutTask
387
- .run()
388
- .then(async () => {
389
- await this.thumbnailLayoutTask.taskComplete;
390
- const layout = this.thumbnailLayoutTask.value;
391
- if (layout) {
392
- await this.runThumbnailRenderTask(layout);
393
- }
394
- })
395
- .catch(() => {
396
- // Ignore errors - thumbnails will show as placeholders
397
- })
398
- .finally(() => {
399
- this._thumbnailUpdateInProgress = false;
400
-
401
- // If more property changes came in while we were processing, run another update
402
- if (this._pendingThumbnailUpdate) {
403
- this._pendingThumbnailUpdate = false;
404
- this.runThumbnailUpdate();
405
- }
406
- });
407
- }
408
-
409
- private thumbnailLayoutTask = new Task(this, {
410
- autoRun: false,
411
- task: async ([
412
- stripWidth,
413
- thumbnailWidth,
414
- targetElement,
415
- startTimeMs,
416
- endTimeMs,
417
- useIntrinsicDuration,
418
- mediaEngine,
419
- ]: readonly [
420
- number,
421
- number,
422
- EFVideo | null,
423
- number | undefined,
424
- number | undefined,
425
- boolean,
426
- ImportedMediaEngine | null | undefined,
427
- ]) => {
428
- // Need valid dimensions and target element
429
- if (stripWidth <= 0 || !targetElement) {
430
- return { count: 0, segments: [] };
431
- }
432
-
433
- // IMPLEMENTATION GUIDELINES: Wait for media engine to be ready before generating thumbnails
434
- if (!mediaEngine) {
435
- // If no media engine yet, wait for it to be ready
436
- if (targetElement.mediaEngineTask) {
437
- await targetElement.mediaEngineTask.taskComplete;
438
- // Get the media engine after it's ready
439
- const readyMediaEngine = targetElement.mediaEngineTask.value;
440
- if (!readyMediaEngine) {
441
- return { count: 0, segments: [] };
442
- }
443
- // Continue with the ready media engine
444
- return this.calculateLayoutWithMediaEngine(
445
- stripWidth,
446
- thumbnailWidth,
447
- targetElement,
448
- startTimeMs,
449
- endTimeMs,
450
- useIntrinsicDuration,
451
- readyMediaEngine,
452
- );
453
- }
454
- return { count: 0, segments: [] };
455
- }
456
-
457
- // Media engine is ready, proceed with layout calculation
458
- return this.calculateLayoutWithMediaEngine(
459
- stripWidth,
460
- thumbnailWidth,
461
- targetElement,
462
- startTimeMs,
463
- endTimeMs,
464
- useIntrinsicDuration,
465
- mediaEngine,
466
- );
467
- },
468
- args: () =>
469
- [
470
- this.stripWidth,
471
- this.thumbnailWidth,
472
- this.targetElement,
473
- this.startTimeMs,
474
- this.endTimeMs,
475
- this.useIntrinsicDuration,
476
- this.targetElement?.mediaEngineTask?.value,
477
- ] as const,
478
- });
479
-
480
- /**
481
- * Calculate layout with a ready media engine
482
- */
483
- private calculateLayoutWithMediaEngine(
484
- stripWidth: number,
485
- thumbnailWidth: number,
486
- targetElement: EFVideo,
487
- startTimeMs: number | undefined,
488
- endTimeMs: number | undefined,
489
- useIntrinsicDuration: boolean,
490
- mediaEngine: ImportedMediaEngine,
491
- ) {
492
- // Determine time range for thumbnails with correct timeline coordinate handling
493
- if (useIntrinsicDuration) {
494
- // INTRINSIC MODE: start-time-ms/end-time-ms are relative to source timeline (0 = source start)
495
- const effectiveStartMs = startTimeMs ?? 0;
496
- const effectiveEndMs =
497
- endTimeMs ?? targetElement.intrinsicDurationMs ?? 0;
498
-
499
- return this.generateLayoutFromTimeRange(
500
- stripWidth,
501
- thumbnailWidth,
502
- effectiveStartMs,
503
- effectiveEndMs,
504
- mediaEngine,
505
- );
506
- }
507
- // TRIMMED MODE: start-time-ms/end-time-ms are relative to trimmed timeline (0 = trim start)
508
- const sourceStart = targetElement.sourceStartMs ?? 0;
509
- const trimmedDuration = targetElement.durationMs ?? 0;
510
-
511
- // Convert trimmed timeline coordinates to source timeline coordinates
512
- const effectiveStartMs =
513
- startTimeMs !== undefined
514
- ? sourceStart + startTimeMs // Convert from trimmed timeline to source timeline
515
- : sourceStart; // Default: start of trimmed portion
516
-
517
- const effectiveEndMs =
518
- endTimeMs !== undefined
519
- ? sourceStart + endTimeMs // Convert from trimmed timeline to source timeline
520
- : sourceStart + trimmedDuration; // Default: end of trimmed portion
521
-
522
- return this.generateLayoutFromTimeRange(
523
- stripWidth,
524
- thumbnailWidth,
525
- effectiveStartMs,
526
- effectiveEndMs,
527
- mediaEngine,
528
- );
529
- }
530
-
531
- /**
532
- * Generate layout from calculated time range
533
- */
534
- private generateLayoutFromTimeRange(
535
- stripWidth: number,
536
- thumbnailWidth: number,
537
- effectiveStartMs: number,
538
- effectiveEndMs: number,
539
- mediaEngine: ImportedMediaEngine,
540
- ) {
541
- // Get scrub segment duration from media engine if available
542
- const scrubSegmentDurationMs =
543
- mediaEngine && typeof mediaEngine.getScrubVideoRendition === "function"
544
- ? mediaEngine.getScrubVideoRendition()?.segmentDurationMs
545
- : undefined;
546
-
547
- // Generate layout using our algorithm with segment alignment
548
- const layout = calculateThumbnailLayout(
549
- stripWidth,
550
- thumbnailWidth,
551
- effectiveStartMs,
552
- effectiveEndMs,
553
- scrubSegmentDurationMs,
554
- );
555
-
556
- return layout;
557
- }
558
-
559
- private thumbnailRenderTask = new Task(this, {
560
- autoRun: false,
561
- task: async ([layout, targetElement, thumbnailWidth]: readonly [
562
- ThumbnailLayout | null,
563
- EFVideo | null,
564
- number,
565
- ]) => {
566
- // Simplified task that delegates to renderThumbnails method
567
- if (!layout || !targetElement) {
568
- return [];
569
- }
570
- return this.renderThumbnails(layout, targetElement, thumbnailWidth);
571
- },
572
- args: () =>
573
- [
574
- this.thumbnailLayoutTask.value || null,
575
- this.targetElement,
576
- this.thumbnailWidth,
577
- ] as const,
578
- });
579
-
580
- /**
581
- * Render thumbnails with provided layout (main rendering logic)
582
- */
583
- private async renderThumbnails(
584
- layout: ThumbnailLayout,
585
- targetElement: EFVideo,
586
- thumbnailWidth: number,
587
- ): Promise<ThumbnailRenderInfo[]> {
588
- if (!layout || !targetElement || layout.count === 0) {
589
- return [];
590
- }
591
-
592
- const videoSrc = targetElement.src;
593
- const availableHeight = this._stripHeight - STRIP_BORDER_PADDING; // Account for border/padding
594
-
595
- const allThumbnails: ThumbnailRenderInfo[] = [];
596
- let thumbnailIndex = 0; // Track ordinal position
597
-
598
- // Process each segment
599
- for (const segment of layout.segments) {
600
- for (const thumbnail of segment.thumbnails) {
601
- const cacheKey = getThumbnailCacheKey(videoSrc, thumbnail.timeMs);
602
-
603
- // Try exact cache hit first
604
- let imageData = thumbnailImageCache.get(cacheKey);
605
- let status: ThumbnailRenderInfo["status"] = "exact-hit";
606
- let nearHitKey: string | undefined;
607
-
608
- if (!imageData) {
609
- // Try near cache hit within 5 seconds using proper range search
610
- const timeMinus = Math.max(0, thumbnail.timeMs - 5000);
611
- const timePlus = thumbnail.timeMs + 5000;
612
-
613
- // For range bounds, use raw timestamps (don't quantize the search range)
614
- const rangeStartKey = `${videoSrc}:${timeMinus}`;
615
- const rangeEndKey = `${videoSrc}:${timePlus}`;
616
-
617
- // Use findRange to find any cached items in this time window
618
- const nearHits = thumbnailImageCache.findRange(
619
- rangeStartKey,
620
- rangeEndKey,
621
- );
622
-
623
- // Filter to only include the same video source
624
- const sameVideoHits = nearHits.filter((hit) =>
625
- hit.key.startsWith(`${videoSrc}:`),
626
- );
627
-
628
- if (sameVideoHits.length > 0) {
629
- // Get the closest match by time from same video
630
- const nearestHit = sameVideoHits.reduce((closest, current) => {
631
- const currentParts = current.key.split(":");
632
- const closestParts = closest.key.split(":");
633
- const currentTime = Number.parseFloat(
634
- currentParts[currentParts.length - 1] || "0",
635
- );
636
- const closestTime = Number.parseFloat(
637
- closestParts[closestParts.length - 1] || "0",
638
- );
639
- const currentDiff = Math.abs(currentTime - thumbnail.timeMs);
640
- const closestDiff = Math.abs(closestTime - thumbnail.timeMs);
641
- return currentDiff < closestDiff ? current : closest;
642
- });
643
-
644
- imageData = nearestHit.value;
645
- status = "near-hit";
646
- nearHitKey = nearestHit.key;
647
- } else {
648
- status = "missing";
649
- }
650
- }
651
-
652
- // Fixed integer positioning - no floating point
653
- const x = thumbnailIndex * (thumbnailWidth + THUMBNAIL_GAP);
654
-
655
- allThumbnails.push({
656
- timeMs: thumbnail.timeMs,
657
- segmentId: segment.segmentId,
658
- x,
659
- width: thumbnailWidth, // Always exactly 80px
660
- height: availableHeight, // Always exactly 44px
661
- status,
662
- imageData,
663
- nearHitKey,
664
- });
665
-
666
- thumbnailIndex++; // Increment ordinal position
667
- }
668
- }
669
-
670
- // Draw current state (cache hits and placeholders)
671
- await this.drawThumbnails(allThumbnails);
672
-
673
- // Load missing thumbnails from scrub tracks
674
- await this.loadMissingThumbnails(allThumbnails, targetElement);
675
-
676
- return allThumbnails;
677
- }
678
-
679
- connectedCallback() {
680
- super.connectedCallback();
681
-
682
- // Set up ResizeObserver to track element dimensions
683
- this.resizeObserver = new ResizeObserver((entries) => {
684
- for (const entry of entries) {
685
- // Use borderBoxSize for accurate dimensions including borders/padding
686
- const width =
687
- entry.borderBoxSize && entry.borderBoxSize.length > 0
688
- ? entry.borderBoxSize[0]?.inlineSize
689
- : entry.contentRect.width;
690
-
691
- const height =
692
- entry.borderBoxSize && entry.borderBoxSize.length > 0
693
- ? entry.borderBoxSize[0]?.blockSize
694
- : entry.contentRect.height;
695
-
696
- this._stripHeight = height ?? 0;
697
- this.stripWidth = width ?? 0; // This triggers thumbnail layout update
698
- }
699
- });
700
-
701
- this.resizeObserver.observe(this);
702
-
703
- // Force initial width calculation after element is fully connected
704
- this.updateComplete.then(() => {
705
- if (this._stripWidth === 0) {
706
- const width = this.clientWidth;
707
- if (width > 0) {
708
- this.stripWidth = width ?? 0;
709
- }
710
- }
711
- });
712
- }
713
-
714
- disconnectedCallback() {
715
- super.disconnectedCallback();
716
- this.resizeObserver?.disconnect();
717
- this.resizeObserver = undefined;
718
-
719
- // Clean up video property observer
720
- this._videoPropertyObserver?.disconnect();
721
- this._videoPropertyObserver = undefined;
722
- }
723
-
724
- /**
725
- * Draw thumbnails to the canvas with cache hits and placeholders
726
- */
727
- private async drawThumbnails(
728
- thumbnails: ThumbnailRenderInfo[],
729
- ): Promise<void> {
730
- const canvas = this.canvasRef.value;
731
- if (!canvas) {
732
- return;
733
- }
734
-
735
- const ctx = canvas.getContext("2d");
736
- if (!ctx) {
737
- return;
738
- }
739
-
740
- // Set canvas to exact size we're drawing - prevents CSS scaling
741
- const dpr = window.devicePixelRatio || 1;
742
-
743
- // Set canvas buffer size for high DPI rendering
744
- canvas.width = this._stripWidth * dpr;
745
- canvas.height = this._stripHeight * dpr;
746
-
747
- // Set canvas DOM size to exactly what we're drawing - no CSS scaling
748
- canvas.style.width = `${this._stripWidth}px`;
749
- canvas.style.height = `${this._stripHeight}px`;
750
-
751
- // Scale the drawing context to match device pixel ratio
752
- ctx.scale(dpr, dpr);
753
-
754
- // Clear canvas (use logical pixel dimensions since context is scaled)
755
- ctx.fillStyle = "#2a2a2a";
756
- ctx.fillRect(0, 0, this._stripWidth, this._stripHeight);
757
-
758
- // Draw each thumbnail with proper aspect ratio and centering
759
- for (const thumb of thumbnails) {
760
- if (thumb.imageData) {
761
- // Draw cached thumbnail with aspect ratio preservation
762
- const tempCanvas = document.createElement("canvas");
763
- tempCanvas.width = thumb.imageData.width;
764
- tempCanvas.height = thumb.imageData.height;
765
- const tempCtx = tempCanvas.getContext("2d");
766
- if (!tempCtx) {
767
- continue;
768
- }
769
- tempCtx.putImageData(thumb.imageData, 0, 0);
770
-
771
- // Preserve aspect ratio within fixed container bounds
772
- const sourceAspect = thumb.imageData.width / thumb.imageData.height;
773
- const containerAspect = thumb.width / thumb.height;
774
-
775
- // Calculate aspect-ratio-preserving dimensions with integer coordinates
776
- let drawWidth: number;
777
- let drawHeight: number;
778
- let drawX: number;
779
- let drawY: number;
780
-
781
- if (sourceAspect > containerAspect) {
782
- // Source is wider - fit to container width, letterbox top/bottom
783
- drawWidth = thumb.width;
784
- drawHeight = Math.round(thumb.width / sourceAspect);
785
- drawX = thumb.x;
786
- drawY = Math.round((this._stripHeight - drawHeight) / 2);
787
- } else {
788
- // Source is taller - fit to container height, pillarbox left/right
789
- drawWidth = Math.round(thumb.height * sourceAspect);
790
- drawHeight = thumb.height;
791
- drawX = thumb.x + Math.round((thumb.width - drawWidth) / 2);
792
- drawY = Math.round((this._stripHeight - drawHeight) / 2);
793
- }
794
-
795
- // Draw with proper aspect ratio preservation
796
- ctx.drawImage(tempCanvas, drawX, drawY, drawWidth, drawHeight);
797
-
798
- // Add subtle indicator for near hits
799
- if (thumb.status === "near-hit") {
800
- ctx.fillStyle = "rgba(255, 165, 0, 0.3)";
801
- ctx.fillRect(thumb.x, 0, thumb.width, 2);
802
- }
803
- } else {
804
- // Draw placeholder - center vertically in strip with integer positioning
805
- const placeholderY = Math.round((this._stripHeight - thumb.height) / 2);
806
- ctx.fillStyle = "#404040";
807
- ctx.fillRect(thumb.x, placeholderY, thumb.width, thumb.height);
808
-
809
- // Add subtle loading indicator with integer positioning
810
- ctx.strokeStyle = "#666";
811
- ctx.lineWidth = 1;
812
- ctx.setLineDash([2, 2]);
813
- ctx.strokeRect(thumb.x, placeholderY, thumb.width, thumb.height);
814
- ctx.setLineDash([]);
815
- }
816
- }
817
- }
818
-
819
- /**
820
- * Load missing thumbnails using MediaEngine batch extraction
821
- */
822
- private async loadMissingThumbnails(
823
- thumbnails: ThumbnailRenderInfo[],
824
- targetElement: EFVideo,
825
- ): Promise<void> {
826
- const mediaEngine = targetElement.mediaEngineTask?.value;
827
- if (!mediaEngine) {
828
- return;
829
- }
830
-
831
- // Get all missing thumbnails
832
- const missingThumbnails = thumbnails.filter(
833
- (t) => t.status === "missing" || t.status === "near-hit",
834
- );
835
-
836
- if (missingThumbnails.length === 0) {
837
- return;
838
- }
839
-
840
- // Update status to loading
841
- for (const thumb of missingThumbnails) {
842
- thumb.status = "loading";
843
- }
844
-
845
- // Batch extract all missing thumbnails using MediaEngine
846
- const timestamps = missingThumbnails.map((t) => t.timeMs);
847
-
848
- const thumbnailResults = await mediaEngine.extractThumbnails(timestamps);
849
-
850
- // Convert canvases to ImageData and update thumbnails
851
- for (let i = 0; i < missingThumbnails.length; i++) {
852
- const thumb = missingThumbnails[i];
853
- const thumbnailResult = thumbnailResults[i];
854
-
855
- if (thumb && thumbnailResult) {
856
- // Convert canvas to ImageData
857
- const imageData = this.canvasToImageData(thumbnailResult.thumbnail);
858
-
859
- if (imageData) {
860
- const cacheKey = getThumbnailCacheKey(
861
- targetElement.src,
862
- thumb.timeMs,
863
- );
864
- thumbnailImageCache.set(cacheKey, imageData);
865
- thumb.imageData = imageData;
866
- thumb.status = "exact-hit";
867
- }
868
- }
869
- }
870
-
871
- // Redraw with newly loaded thumbnails
872
- await this.drawThumbnails(thumbnails);
873
- }
874
-
875
- /**
876
- * Convert Canvas to ImageData for caching
877
- */
878
- private canvasToImageData(
879
- canvas: HTMLCanvasElement | OffscreenCanvas,
880
- ): ImageData | null {
881
- // Extract ImageData from canvas
882
- const ctx = canvas.getContext("2d");
883
- if (!ctx) {
884
- return null;
885
- }
886
-
887
- return ctx.getImageData(0, 0, canvas.width, canvas.height);
888
- }
889
-
890
- render() {
891
- return html`
892
- <canvas ${ref(this.canvasRef)}></canvas>
893
- ${this.thumbnailRenderTask.render({
894
- pending: () => html``,
895
- complete: () => html``,
896
- error: (e) =>
897
- html`<div class="error">Error loading thumbnails: ${e}</div>`,
898
- })}
899
- `;
900
- }
901
- }
902
- declare global {
903
- interface HTMLElementTagNameMap {
904
- "ef-thumbnail-strip": EFThumbnailStrip;
905
- }
906
- }