@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.
- package/package.json +2 -2
- package/scripts/build-css.js +3 -3
- package/tsdown.config.ts +1 -1
- package/src/elements/ContextProxiesController.ts +0 -124
- package/src/elements/CrossUpdateController.ts +0 -22
- package/src/elements/EFAudio.browsertest.ts +0 -706
- package/src/elements/EFAudio.ts +0 -56
- package/src/elements/EFCaptions.browsertest.ts +0 -1960
- package/src/elements/EFCaptions.ts +0 -823
- package/src/elements/EFImage.browsertest.ts +0 -120
- package/src/elements/EFImage.ts +0 -113
- package/src/elements/EFMedia/AssetIdMediaEngine.test.ts +0 -224
- package/src/elements/EFMedia/AssetIdMediaEngine.ts +0 -110
- package/src/elements/EFMedia/AssetMediaEngine.browsertest.ts +0 -140
- package/src/elements/EFMedia/AssetMediaEngine.ts +0 -385
- package/src/elements/EFMedia/BaseMediaEngine.browsertest.ts +0 -400
- package/src/elements/EFMedia/BaseMediaEngine.ts +0 -505
- package/src/elements/EFMedia/BufferedSeekingInput.browsertest.ts +0 -386
- package/src/elements/EFMedia/BufferedSeekingInput.ts +0 -430
- package/src/elements/EFMedia/JitMediaEngine.browsertest.ts +0 -226
- package/src/elements/EFMedia/JitMediaEngine.ts +0 -256
- package/src/elements/EFMedia/audioTasks/makeAudioBufferTask.browsertest.ts +0 -679
- package/src/elements/EFMedia/audioTasks/makeAudioBufferTask.ts +0 -117
- package/src/elements/EFMedia/audioTasks/makeAudioFrequencyAnalysisTask.ts +0 -246
- package/src/elements/EFMedia/audioTasks/makeAudioInitSegmentFetchTask.browsertest.ts +0 -59
- package/src/elements/EFMedia/audioTasks/makeAudioInitSegmentFetchTask.ts +0 -27
- package/src/elements/EFMedia/audioTasks/makeAudioInputTask.browsertest.ts +0 -55
- package/src/elements/EFMedia/audioTasks/makeAudioInputTask.ts +0 -53
- package/src/elements/EFMedia/audioTasks/makeAudioSeekTask.chunkboundary.regression.browsertest.ts +0 -207
- package/src/elements/EFMedia/audioTasks/makeAudioSeekTask.ts +0 -72
- package/src/elements/EFMedia/audioTasks/makeAudioSegmentFetchTask.ts +0 -32
- package/src/elements/EFMedia/audioTasks/makeAudioSegmentIdTask.ts +0 -29
- package/src/elements/EFMedia/audioTasks/makeAudioTasksVideoOnly.browsertest.ts +0 -95
- package/src/elements/EFMedia/audioTasks/makeAudioTimeDomainAnalysisTask.ts +0 -184
- package/src/elements/EFMedia/shared/AudioSpanUtils.ts +0 -129
- package/src/elements/EFMedia/shared/BufferUtils.ts +0 -342
- package/src/elements/EFMedia/shared/GlobalInputCache.ts +0 -77
- package/src/elements/EFMedia/shared/MediaTaskUtils.ts +0 -44
- package/src/elements/EFMedia/shared/PrecisionUtils.ts +0 -46
- package/src/elements/EFMedia/shared/RenditionHelpers.browsertest.ts +0 -246
- package/src/elements/EFMedia/shared/RenditionHelpers.ts +0 -56
- package/src/elements/EFMedia/shared/ThumbnailExtractor.ts +0 -227
- package/src/elements/EFMedia/tasks/makeMediaEngineTask.browsertest.ts +0 -167
- package/src/elements/EFMedia/tasks/makeMediaEngineTask.ts +0 -88
- package/src/elements/EFMedia/videoTasks/MainVideoInputCache.ts +0 -76
- package/src/elements/EFMedia/videoTasks/ScrubInputCache.ts +0 -61
- package/src/elements/EFMedia/videoTasks/makeScrubVideoBufferTask.ts +0 -114
- package/src/elements/EFMedia/videoTasks/makeScrubVideoInitSegmentFetchTask.ts +0 -35
- package/src/elements/EFMedia/videoTasks/makeScrubVideoInputTask.ts +0 -52
- package/src/elements/EFMedia/videoTasks/makeScrubVideoSeekTask.ts +0 -124
- package/src/elements/EFMedia/videoTasks/makeScrubVideoSegmentFetchTask.ts +0 -44
- package/src/elements/EFMedia/videoTasks/makeScrubVideoSegmentIdTask.ts +0 -32
- package/src/elements/EFMedia/videoTasks/makeUnifiedVideoSeekTask.ts +0 -370
- package/src/elements/EFMedia/videoTasks/makeVideoBufferTask.ts +0 -109
- package/src/elements/EFMedia.browsertest.ts +0 -872
- package/src/elements/EFMedia.ts +0 -341
- package/src/elements/EFSourceMixin.ts +0 -60
- package/src/elements/EFSurface.browsertest.ts +0 -151
- package/src/elements/EFSurface.ts +0 -142
- package/src/elements/EFTemporal.browsertest.ts +0 -215
- package/src/elements/EFTemporal.ts +0 -800
- package/src/elements/EFThumbnailStrip.browsertest.ts +0 -585
- package/src/elements/EFThumbnailStrip.media-engine.browsertest.ts +0 -714
- package/src/elements/EFThumbnailStrip.ts +0 -906
- package/src/elements/EFTimegroup.browsertest.ts +0 -934
- package/src/elements/EFTimegroup.ts +0 -882
- package/src/elements/EFVideo.browsertest.ts +0 -1482
- package/src/elements/EFVideo.ts +0 -564
- package/src/elements/EFWaveform.ts +0 -547
- package/src/elements/FetchContext.browsertest.ts +0 -401
- package/src/elements/FetchMixin.ts +0 -38
- package/src/elements/SampleBuffer.ts +0 -94
- package/src/elements/TargetController.browsertest.ts +0 -230
- package/src/elements/TargetController.ts +0 -224
- package/src/elements/TimegroupController.ts +0 -26
- package/src/elements/durationConverter.ts +0 -35
- package/src/elements/parseTimeToMs.ts +0 -9
- package/src/elements/printTaskStatus.ts +0 -16
- package/src/elements/renderTemporalAudio.ts +0 -108
- package/src/elements/updateAnimations.browsertest.ts +0 -1884
- package/src/elements/updateAnimations.ts +0 -217
- package/src/elements/util.ts +0 -24
- package/src/gui/ContextMixin.browsertest.ts +0 -860
- package/src/gui/ContextMixin.ts +0 -562
- package/src/gui/Controllable.browsertest.ts +0 -258
- package/src/gui/Controllable.ts +0 -41
- package/src/gui/EFConfiguration.ts +0 -40
- package/src/gui/EFControls.browsertest.ts +0 -389
- package/src/gui/EFControls.ts +0 -195
- package/src/gui/EFDial.browsertest.ts +0 -84
- package/src/gui/EFDial.ts +0 -172
- package/src/gui/EFFilmstrip.browsertest.ts +0 -712
- package/src/gui/EFFilmstrip.ts +0 -1349
- package/src/gui/EFFitScale.ts +0 -152
- package/src/gui/EFFocusOverlay.ts +0 -79
- package/src/gui/EFPause.browsertest.ts +0 -202
- package/src/gui/EFPause.ts +0 -73
- package/src/gui/EFPlay.browsertest.ts +0 -202
- package/src/gui/EFPlay.ts +0 -73
- package/src/gui/EFPreview.ts +0 -74
- package/src/gui/EFResizableBox.browsertest.ts +0 -79
- package/src/gui/EFResizableBox.ts +0 -898
- package/src/gui/EFScrubber.ts +0 -151
- package/src/gui/EFTimeDisplay.browsertest.ts +0 -237
- package/src/gui/EFTimeDisplay.ts +0 -55
- package/src/gui/EFToggleLoop.ts +0 -35
- package/src/gui/EFTogglePlay.ts +0 -70
- package/src/gui/EFWorkbench.ts +0 -115
- package/src/gui/PlaybackController.ts +0 -527
- package/src/gui/TWMixin.css +0 -6
- package/src/gui/TWMixin.ts +0 -61
- package/src/gui/TargetOrContextMixin.ts +0 -185
- package/src/gui/currentTimeContext.ts +0 -5
- package/src/gui/durationContext.ts +0 -3
- package/src/gui/efContext.ts +0 -6
- package/src/gui/fetchContext.ts +0 -5
- package/src/gui/focusContext.ts +0 -7
- package/src/gui/focusedElementContext.ts +0 -5
- package/src/gui/playingContext.ts +0 -5
- package/src/otel/BridgeSpanExporter.ts +0 -150
- package/src/otel/setupBrowserTracing.ts +0 -73
- package/src/otel/tracingHelpers.ts +0 -251
- package/src/transcoding/cache/RequestDeduplicator.test.ts +0 -170
- package/src/transcoding/cache/RequestDeduplicator.ts +0 -65
- package/src/transcoding/cache/URLTokenDeduplicator.test.ts +0 -182
- package/src/transcoding/cache/URLTokenDeduplicator.ts +0 -101
- package/src/transcoding/types/index.ts +0 -312
- package/src/transcoding/utils/MediaUtils.ts +0 -63
- package/src/transcoding/utils/UrlGenerator.ts +0 -68
- package/src/transcoding/utils/constants.ts +0 -36
- package/src/utils/LRUCache.test.ts +0 -274
- 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
|
-
}
|