@editframe/elements 0.18.27-beta.0 → 0.19.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/dist/elements/EFMedia/AssetMediaEngine.d.ts +10 -0
- package/dist/elements/EFMedia/AssetMediaEngine.js +13 -1
- package/dist/elements/EFMedia/JitMediaEngine.d.ts +10 -0
- package/dist/elements/EFMedia/JitMediaEngine.js +12 -0
- package/dist/elements/EFMedia/audioTasks/makeAudioBufferTask.js +16 -12
- package/dist/elements/EFMedia/audioTasks/makeAudioFrequencyAnalysisTask.d.ts +1 -1
- package/dist/elements/EFMedia/audioTasks/makeAudioFrequencyAnalysisTask.js +0 -4
- package/dist/elements/EFMedia/audioTasks/makeAudioTimeDomainAnalysisTask.d.ts +1 -1
- package/dist/elements/EFMedia/audioTasks/makeAudioTimeDomainAnalysisTask.js +0 -4
- package/dist/elements/EFMedia/videoTasks/makeScrubVideoBufferTask.js +1 -1
- package/dist/elements/EFMedia/videoTasks/makeUnifiedVideoSeekTask.js +3 -2
- package/dist/elements/EFMedia/videoTasks/makeVideoBufferTask.js +16 -12
- package/dist/elements/EFMedia.d.ts +2 -3
- package/dist/elements/EFMedia.js +0 -4
- package/dist/elements/EFTemporal.d.ts +9 -6
- package/dist/elements/EFTemporal.js +15 -12
- package/dist/elements/EFTimegroup.browsertest.d.ts +26 -0
- package/dist/elements/EFTimegroup.d.ts +13 -15
- package/dist/elements/EFTimegroup.js +123 -67
- package/dist/elements/EFVideo.d.ts +5 -1
- package/dist/elements/EFVideo.js +16 -8
- package/dist/elements/EFWaveform.js +2 -3
- package/dist/elements/FetchContext.browsertest.d.ts +0 -0
- package/dist/elements/FetchMixin.js +14 -9
- package/dist/elements/TimegroupController.js +2 -1
- package/dist/elements/updateAnimations.browsertest.d.ts +0 -0
- package/dist/elements/updateAnimations.d.ts +19 -9
- package/dist/elements/updateAnimations.js +64 -25
- package/dist/gui/ContextMixin.js +34 -27
- package/dist/gui/EFConfiguration.d.ts +1 -1
- package/dist/gui/EFConfiguration.js +1 -0
- package/dist/gui/EFFilmstrip.d.ts +1 -0
- package/dist/gui/EFFilmstrip.js +12 -14
- package/dist/gui/TWMixin.js +1 -1
- package/dist/style.css +1 -1
- package/dist/transcoding/cache/URLTokenDeduplicator.d.ts +38 -0
- package/dist/transcoding/cache/URLTokenDeduplicator.js +66 -0
- package/dist/transcoding/cache/URLTokenDeduplicator.test.d.ts +1 -0
- package/dist/transcoding/types/index.d.ts +10 -0
- package/package.json +2 -2
- package/src/elements/EFMedia/AssetMediaEngine.ts +16 -2
- package/src/elements/EFMedia/JitMediaEngine.ts +14 -0
- package/src/elements/EFMedia/audioTasks/makeAudioBufferTask.browsertest.ts +0 -1
- package/src/elements/EFMedia/audioTasks/makeAudioBufferTask.ts +11 -4
- package/src/elements/EFMedia/audioTasks/makeAudioFrequencyAnalysisTask.ts +0 -4
- package/src/elements/EFMedia/audioTasks/makeAudioSeekTask.chunkboundary.regression.browsertest.ts +4 -1
- package/src/elements/EFMedia/audioTasks/makeAudioTimeDomainAnalysisTask.ts +0 -5
- package/src/elements/EFMedia/videoTasks/makeScrubVideoBufferTask.ts +2 -2
- package/src/elements/EFMedia/videoTasks/makeUnifiedVideoSeekTask.ts +7 -3
- package/src/elements/EFMedia/videoTasks/makeVideoBufferTask.ts +11 -4
- package/src/elements/EFMedia.browsertest.ts +13 -4
- package/src/elements/EFMedia.ts +6 -10
- package/src/elements/EFTemporal.ts +21 -26
- package/src/elements/EFTimegroup.browsertest.ts +186 -2
- package/src/elements/EFTimegroup.ts +205 -98
- package/src/elements/EFVideo.browsertest.ts +53 -132
- package/src/elements/EFVideo.ts +26 -13
- package/src/elements/EFWaveform.ts +2 -3
- package/src/elements/FetchContext.browsertest.ts +396 -0
- package/src/elements/FetchMixin.ts +25 -8
- package/src/elements/TimegroupController.ts +2 -1
- package/src/elements/updateAnimations.browsertest.ts +586 -0
- package/src/elements/updateAnimations.ts +113 -50
- package/src/gui/ContextMixin.browsertest.ts +4 -9
- package/src/gui/ContextMixin.ts +52 -33
- package/src/gui/EFConfiguration.ts +1 -1
- package/src/gui/EFFilmstrip.ts +15 -18
- package/src/transcoding/cache/URLTokenDeduplicator.test.ts +182 -0
- package/src/transcoding/cache/URLTokenDeduplicator.ts +101 -0
- package/src/transcoding/types/index.ts +11 -0
- package/test/EFVideo.framegen.browsertest.ts +1 -1
- package/test/setup.ts +2 -0
- package/types.json +1 -1
|
@@ -1,94 +1,157 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
1
|
+
import {
|
|
2
|
+
deepGetTemporalElements,
|
|
3
|
+
isEFTemporal,
|
|
4
|
+
type TemporalMixinInterface,
|
|
5
|
+
} from "./EFTemporal.ts";
|
|
6
|
+
|
|
7
|
+
// All animatable elements are temporal elements with HTMLElement interface
|
|
8
|
+
export type AnimatableElement = TemporalMixinInterface & HTMLElement;
|
|
9
|
+
|
|
10
|
+
// Constants
|
|
11
|
+
const ANIMATION_PRECISION_OFFSET = 0.001;
|
|
12
|
+
const DEFAULT_ANIMATION_ITERATIONS = 1;
|
|
13
|
+
const PROGRESS_PROPERTY = "--ef-progress";
|
|
14
|
+
const DURATION_PROPERTY = "--ef-duration";
|
|
15
|
+
const TRANSITION_DURATION_PROPERTY = "--ef-transition-duration";
|
|
16
|
+
const TRANSITION_OUT_START_PROPERTY = "--ef-transition-out-start";
|
|
17
|
+
const TIMEGROUP_TAGNAME = "ef-timegroup";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Represents the temporal state of an element relative to the timeline
|
|
21
|
+
*/
|
|
22
|
+
interface TemporalState {
|
|
23
|
+
progress: number;
|
|
24
|
+
isVisible: boolean;
|
|
25
|
+
timelineTimeMs: number;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Evaluates what the element's state should be based on the timeline
|
|
30
|
+
*/
|
|
31
|
+
export const evaluateTemporalState = (
|
|
32
|
+
element: AnimatableElement,
|
|
33
|
+
): TemporalState => {
|
|
34
|
+
// Get timeline time from root timegroup, or use element's own time if it IS a timegroup
|
|
18
35
|
const timelineTimeMs = (element.rootTimegroup ?? element).currentTimeMs;
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
element.
|
|
22
|
-
|
|
23
|
-
|
|
36
|
+
|
|
37
|
+
const progress =
|
|
38
|
+
element.durationMs <= 0
|
|
39
|
+
? 1
|
|
40
|
+
: Math.max(0, Math.min(1, element.currentTimeMs / element.durationMs));
|
|
41
|
+
|
|
42
|
+
const isVisible =
|
|
43
|
+
element.startTimeMs <= timelineTimeMs && element.endTimeMs > timelineTimeMs;
|
|
44
|
+
|
|
45
|
+
return { progress, isVisible, timelineTimeMs };
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Updates the visual state (CSS + display) to match temporal state
|
|
50
|
+
*/
|
|
51
|
+
const updateVisualState = (
|
|
52
|
+
element: AnimatableElement,
|
|
53
|
+
state: TemporalState,
|
|
54
|
+
): void => {
|
|
55
|
+
// Always set progress (needed for many use cases)
|
|
56
|
+
element.style.setProperty(PROGRESS_PROPERTY, `${state.progress * 100}%`);
|
|
57
|
+
|
|
58
|
+
// Handle visibility
|
|
59
|
+
if (!state.isVisible) {
|
|
60
|
+
if (element.style.display !== "none") {
|
|
61
|
+
element.style.display = "none";
|
|
62
|
+
}
|
|
24
63
|
return;
|
|
25
64
|
}
|
|
26
|
-
element.style.display = "";
|
|
27
65
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
return;
|
|
66
|
+
if (element.style.display === "none") {
|
|
67
|
+
element.style.display = "";
|
|
31
68
|
}
|
|
32
69
|
|
|
33
|
-
|
|
34
|
-
element.style.setProperty(
|
|
70
|
+
// Set other CSS properties for visible elements only
|
|
71
|
+
element.style.setProperty(DURATION_PROPERTY, `${element.durationMs}ms`);
|
|
35
72
|
element.style.setProperty(
|
|
36
|
-
|
|
73
|
+
TRANSITION_DURATION_PROPERTY,
|
|
37
74
|
`${element.parentTimegroup?.overlapMs ?? 0}ms`,
|
|
38
75
|
);
|
|
39
76
|
element.style.setProperty(
|
|
40
|
-
|
|
77
|
+
TRANSITION_OUT_START_PROPERTY,
|
|
41
78
|
`${element.durationMs - (element.parentTimegroup?.overlapMs ?? 0)}ms`,
|
|
42
79
|
);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Coordinates animations to match timeline
|
|
84
|
+
*/
|
|
85
|
+
const coordinateAnimations = (element: AnimatableElement): void => {
|
|
86
|
+
const animations = element.getAnimations({ subtree: true });
|
|
43
87
|
|
|
44
88
|
for (const animation of animations) {
|
|
45
89
|
if (animation.playState === "running") {
|
|
46
90
|
animation.pause();
|
|
47
91
|
}
|
|
92
|
+
|
|
48
93
|
const effect = animation.effect;
|
|
49
94
|
if (!(effect && effect instanceof KeyframeEffect)) {
|
|
50
95
|
continue;
|
|
51
96
|
}
|
|
97
|
+
|
|
52
98
|
const target = effect.target;
|
|
53
|
-
|
|
54
|
-
if (!target) {
|
|
99
|
+
if (!target || target.closest(TIMEGROUP_TAGNAME) !== element) {
|
|
55
100
|
continue;
|
|
56
101
|
}
|
|
57
|
-
|
|
102
|
+
|
|
103
|
+
const timeTarget = isEFTemporal(target)
|
|
104
|
+
? target
|
|
105
|
+
: target.closest(TIMEGROUP_TAGNAME);
|
|
106
|
+
if (!timeTarget) {
|
|
58
107
|
continue;
|
|
59
108
|
}
|
|
60
109
|
|
|
61
110
|
const timing = effect.getTiming();
|
|
62
|
-
const duration = Number(timing.duration)
|
|
63
|
-
const delay = Number(timing.delay)
|
|
64
|
-
const iterations =
|
|
111
|
+
const duration = Number(timing.duration) || 0;
|
|
112
|
+
const delay = Number(timing.delay) || 0;
|
|
113
|
+
const iterations =
|
|
114
|
+
Number(timing.iterations) || DEFAULT_ANIMATION_ITERATIONS;
|
|
65
115
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
: target.closest("ef-timegroup");
|
|
69
|
-
if (!timeTarget) {
|
|
116
|
+
if (duration <= 0) {
|
|
117
|
+
animation.currentTime = 0;
|
|
70
118
|
continue;
|
|
71
119
|
}
|
|
72
120
|
|
|
73
|
-
|
|
121
|
+
// All timegroups are temporal, so always use ownCurrentTimeMs for local time coordination
|
|
122
|
+
const currentTime = timeTarget.ownCurrentTimeMs ?? 0;
|
|
74
123
|
|
|
75
|
-
// Handle delay - don't start animation until delay is complete
|
|
76
124
|
if (currentTime < delay) {
|
|
77
125
|
animation.currentTime = 0;
|
|
78
126
|
continue;
|
|
79
127
|
}
|
|
80
128
|
|
|
81
|
-
const
|
|
82
|
-
const
|
|
129
|
+
const adjustedTime = currentTime - delay;
|
|
130
|
+
const currentIteration = Math.floor(adjustedTime / duration);
|
|
131
|
+
const currentIterationTime = adjustedTime % duration;
|
|
83
132
|
|
|
84
133
|
if (currentIteration >= iterations) {
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
134
|
+
animation.currentTime = duration - ANIMATION_PRECISION_OFFSET;
|
|
135
|
+
} else {
|
|
136
|
+
animation.currentTime =
|
|
137
|
+
Math.min(currentIterationTime, duration - ANIMATION_PRECISION_OFFSET) +
|
|
138
|
+
delay;
|
|
88
139
|
}
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Main function: synchronizes DOM element with timeline
|
|
145
|
+
*/
|
|
146
|
+
export const updateAnimations = (element: AnimatableElement): void => {
|
|
147
|
+
const temporalState = evaluateTemporalState(element);
|
|
148
|
+
deepGetTemporalElements(element).forEach((temporalElement) => {
|
|
149
|
+
const temporalState = evaluateTemporalState(temporalElement);
|
|
150
|
+
updateVisualState(temporalElement, temporalState);
|
|
151
|
+
});
|
|
152
|
+
updateVisualState(element, temporalState);
|
|
89
153
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
Math.min(currentIterationTime, duration - 0.01) + delay;
|
|
154
|
+
if (temporalState.isVisible) {
|
|
155
|
+
coordinateAnimations(element);
|
|
93
156
|
}
|
|
94
157
|
};
|
|
@@ -269,7 +269,7 @@ describe("ContextMixin", () => {
|
|
|
269
269
|
expect(mockFetch).toHaveBeenCalledTimes(7); // 3 signing + 4 media requests (URL2 reused token)
|
|
270
270
|
}, 1000);
|
|
271
271
|
|
|
272
|
-
test("should
|
|
272
|
+
test("should reuse global token cache across component disconnections", async () => {
|
|
273
273
|
const futureTime = Date.now() + 60 * 60 * 1000; // 1 hour from now
|
|
274
274
|
const token1 = createJWTToken(futureTime);
|
|
275
275
|
|
|
@@ -296,13 +296,7 @@ describe("ContextMixin", () => {
|
|
|
296
296
|
element.signingURL = "https://test.com/api/v1/url-token";
|
|
297
297
|
document.body.appendChild(element);
|
|
298
298
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
mockFetch.mockResolvedValueOnce({
|
|
302
|
-
ok: true,
|
|
303
|
-
json: () => Promise.resolve({ token: token2 }),
|
|
304
|
-
});
|
|
305
|
-
|
|
299
|
+
// No need to mock additional token request - should reuse from global cache
|
|
306
300
|
mockFetch.mockResolvedValueOnce({
|
|
307
301
|
ok: true,
|
|
308
302
|
text: () => Promise.resolve("success2"),
|
|
@@ -310,7 +304,8 @@ describe("ContextMixin", () => {
|
|
|
310
304
|
|
|
311
305
|
await element.fetch("https://example.com/media.mp4");
|
|
312
306
|
|
|
313
|
-
|
|
307
|
+
// With global caching: 1 signing + 2 media requests (token is reused)
|
|
308
|
+
expect(mockFetch).toHaveBeenCalledTimes(3);
|
|
314
309
|
}, 1000);
|
|
315
310
|
|
|
316
311
|
test("should use single token for multiple transcode segments with same source URL", async () => {
|
package/src/gui/ContextMixin.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { consume, createContext, provide } from "@lit/context";
|
|
2
2
|
import type { LitElement } from "lit";
|
|
3
3
|
import { property, state } from "lit/decorators.js";
|
|
4
|
+
import { EF_RENDERING } from "../EF_RENDERING.ts";
|
|
4
5
|
import type { EFTimegroup } from "../elements/EFTimegroup.js";
|
|
6
|
+
import { globalURLTokenDeduplicator } from "../transcoding/cache/URLTokenDeduplicator.js";
|
|
5
7
|
import {
|
|
6
8
|
type EFConfiguration,
|
|
7
9
|
efConfigurationContext,
|
|
@@ -89,32 +91,33 @@ export function ContextMixin<T extends Constructor<LitElement>>(superClass: T) {
|
|
|
89
91
|
"Content-Type": "application/json",
|
|
90
92
|
});
|
|
91
93
|
|
|
92
|
-
if (this.signingURL) {
|
|
93
|
-
const now = Date.now();
|
|
94
|
+
if (!EF_RENDERING() && this.signingURL) {
|
|
94
95
|
const { cacheKey, signingPayload } = this.#getTokenCacheKey(url);
|
|
95
|
-
const tokenExpiration = this.#URLTokenExpirations[cacheKey] || 0;
|
|
96
|
-
|
|
97
|
-
// Check if we need to fetch a new token (no token exists or token is expired)
|
|
98
|
-
if (!this.#URLTokens[cacheKey] || now >= tokenExpiration) {
|
|
99
|
-
this.#URLTokens[cacheKey] = fetch(this.signingURL, {
|
|
100
|
-
method: "POST",
|
|
101
|
-
body: JSON.stringify(signingPayload),
|
|
102
|
-
}).then(async (response) => {
|
|
103
|
-
if (response.ok) {
|
|
104
|
-
const tokenData = await response.json();
|
|
105
|
-
const token = tokenData.token;
|
|
106
|
-
// Parse and store the token's actual expiration time
|
|
107
|
-
this.#URLTokenExpirations[cacheKey] =
|
|
108
|
-
this.#parseTokenExpiration(token);
|
|
109
|
-
return token;
|
|
110
|
-
}
|
|
111
|
-
throw new Error(
|
|
112
|
-
`Failed to sign URL: ${url}. SigningURL: ${this.signingURL} ${response.status} ${response.statusText}`,
|
|
113
|
-
);
|
|
114
|
-
});
|
|
115
|
-
}
|
|
116
96
|
|
|
117
|
-
|
|
97
|
+
// Use global token deduplicator to share tokens across all context providers
|
|
98
|
+
const urlToken = await globalURLTokenDeduplicator.getToken(
|
|
99
|
+
cacheKey,
|
|
100
|
+
async () => {
|
|
101
|
+
try {
|
|
102
|
+
const response = await fetch(this.signingURL, {
|
|
103
|
+
method: "POST",
|
|
104
|
+
body: JSON.stringify(signingPayload),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
if (response.ok) {
|
|
108
|
+
const tokenData = await response.json();
|
|
109
|
+
return tokenData.token;
|
|
110
|
+
}
|
|
111
|
+
throw new Error(
|
|
112
|
+
`Failed to sign URL: ${url}. SigningURL: ${this.signingURL} ${response.status} ${response.statusText}`,
|
|
113
|
+
);
|
|
114
|
+
} catch (error) {
|
|
115
|
+
console.error("ContextMixin urlToken fetch error", url, error);
|
|
116
|
+
throw error;
|
|
117
|
+
}
|
|
118
|
+
},
|
|
119
|
+
(token: string) => this.#parseTokenExpiration(token),
|
|
120
|
+
);
|
|
118
121
|
|
|
119
122
|
Object.assign(init.headers, {
|
|
120
123
|
authorization: `Bearer ${urlToken}`,
|
|
@@ -123,11 +126,21 @@ export function ContextMixin<T extends Constructor<LitElement>>(superClass: T) {
|
|
|
123
126
|
init.credentials = "include";
|
|
124
127
|
}
|
|
125
128
|
|
|
126
|
-
|
|
129
|
+
try {
|
|
130
|
+
return fetch(url, init);
|
|
131
|
+
} catch (error) {
|
|
132
|
+
console.error(
|
|
133
|
+
"ContextMixin fetch error",
|
|
134
|
+
url,
|
|
135
|
+
error,
|
|
136
|
+
window.location.href,
|
|
137
|
+
);
|
|
138
|
+
throw error;
|
|
139
|
+
}
|
|
127
140
|
};
|
|
128
141
|
|
|
129
|
-
|
|
130
|
-
|
|
142
|
+
// Note: URL token caching is now handled globally via URLTokenDeduplicator
|
|
143
|
+
// Keeping these for any potential backwards compatibility, but they're no longer used
|
|
131
144
|
|
|
132
145
|
/**
|
|
133
146
|
* Generate a cache key for URL token based on signing strategy
|
|
@@ -236,7 +249,7 @@ export function ContextMixin<T extends Constructor<LitElement>>(superClass: T) {
|
|
|
236
249
|
rendering = false;
|
|
237
250
|
|
|
238
251
|
@state()
|
|
239
|
-
currentTimeMs =
|
|
252
|
+
currentTimeMs = Number.NaN;
|
|
240
253
|
|
|
241
254
|
#FPS = 30;
|
|
242
255
|
#MS_PER_FRAME = 1000 / this.#FPS;
|
|
@@ -306,9 +319,8 @@ export function ContextMixin<T extends Constructor<LitElement>>(superClass: T) {
|
|
|
306
319
|
super.disconnectedCallback();
|
|
307
320
|
this.#timegroupObserver.disconnect();
|
|
308
321
|
this.stopPlayback();
|
|
309
|
-
//
|
|
310
|
-
|
|
311
|
-
this.#URLTokenExpirations = {};
|
|
322
|
+
// Note: Global token cache is shared across all context providers
|
|
323
|
+
// No need to clear per-instance cache on disconnect
|
|
312
324
|
}
|
|
313
325
|
|
|
314
326
|
update(changedProperties: Map<string | number | symbol, unknown>) {
|
|
@@ -319,10 +331,17 @@ export function ContextMixin<T extends Constructor<LitElement>>(superClass: T) {
|
|
|
319
331
|
this.stopPlayback();
|
|
320
332
|
}
|
|
321
333
|
}
|
|
322
|
-
if (
|
|
334
|
+
if (
|
|
335
|
+
changedProperties.has("currentTimeMs") &&
|
|
336
|
+
this.targetTimegroup &&
|
|
337
|
+
!Number.isNaN(this.currentTimeMs)
|
|
338
|
+
) {
|
|
323
339
|
if (this.targetTimegroup.currentTimeMs !== this.currentTimeMs) {
|
|
324
|
-
this.targetTimegroup.currentTimeMs = this.currentTimeMs;
|
|
325
340
|
if (this.isConnected) {
|
|
341
|
+
if (this.targetTimegroup.currentTimeMs === this.currentTimeMs) {
|
|
342
|
+
return;
|
|
343
|
+
}
|
|
344
|
+
this.targetTimegroup.currentTimeMs = this.currentTimeMs;
|
|
326
345
|
this.dispatchEvent(
|
|
327
346
|
new CustomEvent("timeupdate", {
|
|
328
347
|
detail: {
|
|
@@ -23,7 +23,7 @@ export class EFConfiguration extends LitElement {
|
|
|
23
23
|
apiHost?: string;
|
|
24
24
|
|
|
25
25
|
@property({ type: String, attribute: "signing-url" })
|
|
26
|
-
signingURL
|
|
26
|
+
signingURL = "/@ef-sign-url";
|
|
27
27
|
|
|
28
28
|
@property({ type: String, attribute: "media-engine" })
|
|
29
29
|
mediaEngine?: "cloud" | "local" = "cloud";
|
package/src/gui/EFFilmstrip.ts
CHANGED
|
@@ -633,15 +633,7 @@ export class EFFilmstrip extends TWMixin(LitElement) {
|
|
|
633
633
|
if (!this.scrubbing) {
|
|
634
634
|
return;
|
|
635
635
|
}
|
|
636
|
-
|
|
637
|
-
if (!gutter) {
|
|
638
|
-
return;
|
|
639
|
-
}
|
|
640
|
-
const rect = gutter.getBoundingClientRect();
|
|
641
|
-
if (this.targetTimegroup) {
|
|
642
|
-
const layerX = e.pageX - rect.left + gutter.scrollLeft;
|
|
643
|
-
this.targetTimegroup.currentTimeMs = layerX / this.pixelsPerMs;
|
|
644
|
-
}
|
|
636
|
+
this.applyScrub(e);
|
|
645
637
|
}
|
|
646
638
|
|
|
647
639
|
@eventOptions({ capture: false })
|
|
@@ -651,15 +643,7 @@ export class EFFilmstrip extends TWMixin(LitElement) {
|
|
|
651
643
|
// Running scrub in the current microtask doesn't
|
|
652
644
|
// result in an actual update. Not sure why.
|
|
653
645
|
queueMicrotask(() => {
|
|
654
|
-
|
|
655
|
-
if (!gutter) {
|
|
656
|
-
return;
|
|
657
|
-
}
|
|
658
|
-
const rect = gutter.getBoundingClientRect();
|
|
659
|
-
if (this.targetTimegroup) {
|
|
660
|
-
const layerX = e.pageX - rect.left + gutter.scrollLeft;
|
|
661
|
-
this.targetTimegroup.currentTimeMs = layerX / this.pixelsPerMs;
|
|
662
|
-
}
|
|
646
|
+
this.applyScrub(e);
|
|
663
647
|
});
|
|
664
648
|
addEventListener(
|
|
665
649
|
"mouseup",
|
|
@@ -670,6 +654,19 @@ export class EFFilmstrip extends TWMixin(LitElement) {
|
|
|
670
654
|
);
|
|
671
655
|
}
|
|
672
656
|
|
|
657
|
+
applyScrub(e: MouseEvent) {
|
|
658
|
+
const gutter = this.shadowRoot?.querySelector("#gutter");
|
|
659
|
+
if (!gutter) {
|
|
660
|
+
return;
|
|
661
|
+
}
|
|
662
|
+
const rect = gutter.getBoundingClientRect();
|
|
663
|
+
if (this.targetTimegroup) {
|
|
664
|
+
const layerX = e.pageX - rect.left + gutter.scrollLeft;
|
|
665
|
+
const scrubTimeMs = layerX / this.pixelsPerMs;
|
|
666
|
+
this.targetTimegroup.currentTimeMs = scrubTimeMs;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
|
|
673
670
|
@eventOptions({ passive: false })
|
|
674
671
|
scrollScrub(e: WheelEvent) {
|
|
675
672
|
if (this.targetTimegroup && this.gutter && !this.playing) {
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { URLTokenDeduplicator } from "./URLTokenDeduplicator.js";
|
|
3
|
+
|
|
4
|
+
describe("URLTokenDeduplicator", () => {
|
|
5
|
+
let deduplicator: URLTokenDeduplicator;
|
|
6
|
+
|
|
7
|
+
beforeEach(() => {
|
|
8
|
+
deduplicator = new URLTokenDeduplicator();
|
|
9
|
+
vi.useFakeTimers();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
afterEach(() => {
|
|
13
|
+
vi.useRealTimers();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("should deduplicate concurrent requests for same cache key", async () => {
|
|
17
|
+
const mockTokenFactory = vi.fn().mockResolvedValue("test-token");
|
|
18
|
+
const mockParseExpiration = vi.fn().mockReturnValue(Date.now() + 60000);
|
|
19
|
+
|
|
20
|
+
// Make 3 concurrent requests for the same cache key
|
|
21
|
+
const promises = [
|
|
22
|
+
deduplicator.getToken("key1", mockTokenFactory, mockParseExpiration),
|
|
23
|
+
deduplicator.getToken("key1", mockTokenFactory, mockParseExpiration),
|
|
24
|
+
deduplicator.getToken("key1", mockTokenFactory, mockParseExpiration),
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
const results = await Promise.all(promises);
|
|
28
|
+
|
|
29
|
+
// All should return the same token
|
|
30
|
+
expect(results).toEqual(["test-token", "test-token", "test-token"]);
|
|
31
|
+
|
|
32
|
+
// Factory should only be called once due to deduplication
|
|
33
|
+
expect(mockTokenFactory).toHaveBeenCalledTimes(1);
|
|
34
|
+
expect(mockParseExpiration).toHaveBeenCalledTimes(1);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("should use separate tokens for different cache keys", async () => {
|
|
38
|
+
const mockTokenFactory1 = vi.fn().mockResolvedValue("token1");
|
|
39
|
+
const mockTokenFactory2 = vi.fn().mockResolvedValue("token2");
|
|
40
|
+
const mockParseExpiration = vi.fn().mockReturnValue(Date.now() + 60000);
|
|
41
|
+
|
|
42
|
+
const [token1, token2] = await Promise.all([
|
|
43
|
+
deduplicator.getToken("key1", mockTokenFactory1, mockParseExpiration),
|
|
44
|
+
deduplicator.getToken("key2", mockTokenFactory2, mockParseExpiration),
|
|
45
|
+
]);
|
|
46
|
+
|
|
47
|
+
expect(token1).toBe("token1");
|
|
48
|
+
expect(token2).toBe("token2");
|
|
49
|
+
expect(mockTokenFactory1).toHaveBeenCalledTimes(1);
|
|
50
|
+
expect(mockTokenFactory2).toHaveBeenCalledTimes(1);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("should reuse cached tokens that haven't expired", async () => {
|
|
54
|
+
const futureTime = Date.now() + 60000;
|
|
55
|
+
const mockTokenFactory = vi.fn().mockResolvedValue("cached-token");
|
|
56
|
+
const mockParseExpiration = vi.fn().mockReturnValue(futureTime);
|
|
57
|
+
|
|
58
|
+
// First request
|
|
59
|
+
const token1 = await deduplicator.getToken(
|
|
60
|
+
"key1",
|
|
61
|
+
mockTokenFactory,
|
|
62
|
+
mockParseExpiration,
|
|
63
|
+
);
|
|
64
|
+
|
|
65
|
+
// Second request for same key should reuse cached token
|
|
66
|
+
const token2 = await deduplicator.getToken(
|
|
67
|
+
"key1",
|
|
68
|
+
vi.fn(),
|
|
69
|
+
mockParseExpiration,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
expect(token1).toBe("cached-token");
|
|
73
|
+
expect(token2).toBe("cached-token");
|
|
74
|
+
expect(mockTokenFactory).toHaveBeenCalledTimes(1);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("should fetch new token when cached token is expired", async () => {
|
|
78
|
+
const pastTime = Date.now() - 1000; // Expired 1 second ago
|
|
79
|
+
const futureTime = Date.now() + 60000; // New token expires in 1 minute
|
|
80
|
+
|
|
81
|
+
const mockTokenFactory1 = vi.fn().mockResolvedValue("expired-token");
|
|
82
|
+
const mockTokenFactory2 = vi.fn().mockResolvedValue("fresh-token");
|
|
83
|
+
const mockParseExpiration1 = vi.fn().mockReturnValue(pastTime);
|
|
84
|
+
const mockParseExpiration2 = vi.fn().mockReturnValue(futureTime);
|
|
85
|
+
|
|
86
|
+
// First request with expired token
|
|
87
|
+
await deduplicator.getToken(
|
|
88
|
+
"key1",
|
|
89
|
+
mockTokenFactory1,
|
|
90
|
+
mockParseExpiration1,
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
// Advance time to simulate token expiration
|
|
94
|
+
vi.advanceTimersByTime(2000);
|
|
95
|
+
|
|
96
|
+
// Second request should fetch new token due to expiration
|
|
97
|
+
const token2 = await deduplicator.getToken(
|
|
98
|
+
"key1",
|
|
99
|
+
mockTokenFactory2,
|
|
100
|
+
mockParseExpiration2,
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
expect(token2).toBe("fresh-token");
|
|
104
|
+
expect(mockTokenFactory1).toHaveBeenCalledTimes(1);
|
|
105
|
+
expect(mockTokenFactory2).toHaveBeenCalledTimes(1);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("should handle token factory errors by removing from cache", async () => {
|
|
109
|
+
const mockTokenFactory1 = vi
|
|
110
|
+
.fn()
|
|
111
|
+
.mockRejectedValue(new Error("Network error"));
|
|
112
|
+
const mockTokenFactory2 = vi.fn().mockResolvedValue("retry-token");
|
|
113
|
+
const mockParseExpiration = vi.fn().mockReturnValue(Date.now() + 60000);
|
|
114
|
+
|
|
115
|
+
// First request fails
|
|
116
|
+
await expect(
|
|
117
|
+
deduplicator.getToken("key1", mockTokenFactory1, mockParseExpiration),
|
|
118
|
+
).rejects.toThrow("Network error");
|
|
119
|
+
|
|
120
|
+
// Retry should work and not be blocked by failed request
|
|
121
|
+
const token = await deduplicator.getToken(
|
|
122
|
+
"key1",
|
|
123
|
+
mockTokenFactory2,
|
|
124
|
+
mockParseExpiration,
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
expect(token).toBe("retry-token");
|
|
128
|
+
expect(mockTokenFactory1).toHaveBeenCalledTimes(1);
|
|
129
|
+
expect(mockTokenFactory2).toHaveBeenCalledTimes(1);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("should provide utility methods for cache management", () => {
|
|
133
|
+
const mockParseExpiration = vi.fn().mockReturnValue(Date.now() + 60000);
|
|
134
|
+
|
|
135
|
+
expect(deduplicator.getCachedCount()).toBe(0);
|
|
136
|
+
expect(deduplicator.getCachedKeys()).toEqual([]);
|
|
137
|
+
expect(deduplicator.hasValidToken("nonexistent")).toBe(false);
|
|
138
|
+
|
|
139
|
+
// Add a token to cache
|
|
140
|
+
deduplicator.getToken(
|
|
141
|
+
"key1",
|
|
142
|
+
vi.fn().mockResolvedValue("token"),
|
|
143
|
+
mockParseExpiration,
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
expect(deduplicator.getCachedCount()).toBe(1);
|
|
147
|
+
expect(deduplicator.getCachedKeys()).toEqual(["key1"]);
|
|
148
|
+
expect(deduplicator.hasValidToken("key1")).toBe(true);
|
|
149
|
+
|
|
150
|
+
// Clear cache
|
|
151
|
+
deduplicator.clear();
|
|
152
|
+
expect(deduplicator.getCachedCount()).toBe(0);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("should cleanup expired tokens", async () => {
|
|
156
|
+
const pastTime = Date.now() - 1000;
|
|
157
|
+
const futureTime = Date.now() + 60000;
|
|
158
|
+
|
|
159
|
+
const mockParseExpiration1 = vi.fn().mockReturnValue(pastTime);
|
|
160
|
+
const mockParseExpiration2 = vi.fn().mockReturnValue(futureTime);
|
|
161
|
+
|
|
162
|
+
// Add expired and valid tokens
|
|
163
|
+
await deduplicator.getToken(
|
|
164
|
+
"expired",
|
|
165
|
+
vi.fn().mockResolvedValue("token1"),
|
|
166
|
+
mockParseExpiration1,
|
|
167
|
+
);
|
|
168
|
+
await deduplicator.getToken(
|
|
169
|
+
"valid",
|
|
170
|
+
vi.fn().mockResolvedValue("token2"),
|
|
171
|
+
mockParseExpiration2,
|
|
172
|
+
);
|
|
173
|
+
|
|
174
|
+
expect(deduplicator.getCachedCount()).toBe(2);
|
|
175
|
+
|
|
176
|
+
// Cleanup should remove expired tokens
|
|
177
|
+
deduplicator.cleanup();
|
|
178
|
+
|
|
179
|
+
expect(deduplicator.getCachedCount()).toBe(1);
|
|
180
|
+
expect(deduplicator.getCachedKeys()).toEqual(["valid"]);
|
|
181
|
+
});
|
|
182
|
+
});
|