@editframe/elements 0.18.19-beta.0 → 0.18.20-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.
@@ -71,6 +71,17 @@ export function ContextMixin<T extends Constructor<LitElement>>(superClass: T) {
71
71
  @state()
72
72
  targetTimegroup: EFTimegroup | null = null;
73
73
 
74
+ // Add reactive properties that depend on the targetTimegroup
75
+ @state()
76
+ get durationMs(): number {
77
+ return this.targetTimegroup?.durationMs ?? 0;
78
+ }
79
+
80
+ @state()
81
+ get endTimeMs(): number {
82
+ return this.targetTimegroup?.endTimeMs ?? 0;
83
+ }
84
+
74
85
  @provide({ context: fetchContext })
75
86
  fetch = async (url: string, init: RequestInit = {}) => {
76
87
  init.headers ||= {};
@@ -79,13 +90,23 @@ export function ContextMixin<T extends Constructor<LitElement>>(superClass: T) {
79
90
  });
80
91
 
81
92
  if (this.signingURL) {
82
- if (!this.#URLTokens[url]) {
83
- this.#URLTokens[url] = fetch(this.signingURL, {
93
+ const now = Date.now();
94
+ 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, {
84
100
  method: "POST",
85
- body: JSON.stringify({ url }),
101
+ body: JSON.stringify(signingPayload),
86
102
  }).then(async (response) => {
87
103
  if (response.ok) {
88
- return (await response.json()).token;
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;
89
110
  }
90
111
  throw new Error(
91
112
  `Failed to sign URL: ${url}. SigningURL: ${this.signingURL} ${response.status} ${response.statusText}`,
@@ -93,7 +114,7 @@ export function ContextMixin<T extends Constructor<LitElement>>(superClass: T) {
93
114
  });
94
115
  }
95
116
 
96
- const urlToken = await this.#URLTokens[url];
117
+ const urlToken = await this.#URLTokens[cacheKey];
97
118
 
98
119
  Object.assign(init.headers, {
99
120
  authorization: `Bearer ${urlToken}`,
@@ -106,6 +127,89 @@ export function ContextMixin<T extends Constructor<LitElement>>(superClass: T) {
106
127
  };
107
128
 
108
129
  #URLTokens: Record<string, Promise<string>> = {};
130
+ #URLTokenExpirations: Record<string, number> = {};
131
+
132
+ /**
133
+ * Generate a cache key for URL token based on signing strategy
134
+ *
135
+ * Uses unified prefix + parameter matching approach:
136
+ * - For transcode URLs: signs base "/api/v1/transcode" + params like {url: "source.mp4"}
137
+ * - For regular URLs: signs full URL with empty params {}
138
+ * - All validation uses prefix matching + exhaustive parameter matching
139
+ * - Multiple transcode segments with same source share one token (reduces round-trips)
140
+ */
141
+ #getTokenCacheKey(url: string): {
142
+ cacheKey: string;
143
+ signingPayload: { url: string; params?: Record<string, string> };
144
+ } {
145
+ try {
146
+ const urlObj = new URL(url);
147
+
148
+ // Check if this is a transcode URL pattern
149
+ if (urlObj.pathname.includes("/api/v1/transcode/")) {
150
+ const urlParam = urlObj.searchParams.get("url");
151
+ if (urlParam) {
152
+ // For transcode URLs, sign the base path + url parameter
153
+ const basePath = `${urlObj.origin}/api/v1/transcode`;
154
+ const cacheKey = `${basePath}?url=${urlParam}`;
155
+ return {
156
+ cacheKey,
157
+ signingPayload: { url: basePath, params: { url: urlParam } },
158
+ };
159
+ }
160
+ }
161
+
162
+ // For non-transcode URLs, use full URL (existing behavior)
163
+ return {
164
+ cacheKey: url,
165
+ signingPayload: { url },
166
+ };
167
+ } catch {
168
+ // If URL parsing fails, fall back to full URL
169
+ return {
170
+ cacheKey: url,
171
+ signingPayload: { url },
172
+ };
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Parse JWT token to extract safe expiration time (with buffer)
178
+ * @param token JWT token string
179
+ * @returns Safe expiration timestamp in milliseconds (actual expiry minus buffer), or 0 if parsing fails
180
+ */
181
+ #parseTokenExpiration(token: string): number {
182
+ try {
183
+ // JWT has 3 parts separated by dots: header.payload.signature
184
+ const parts = token.split(".");
185
+ if (parts.length !== 3) return 0;
186
+
187
+ // Decode the payload (second part)
188
+ const payload = parts[1];
189
+ if (!payload) return 0;
190
+
191
+ const decoded = atob(payload.replace(/-/g, "+").replace(/_/g, "/"));
192
+ const parsed = JSON.parse(decoded);
193
+
194
+ // Extract timestamps (in seconds)
195
+ const exp = parsed.exp;
196
+ const iat = parsed.iat;
197
+ if (!exp) return 0;
198
+
199
+ // Calculate token lifetime and buffer
200
+ const lifetimeSeconds = iat ? exp - iat : 3600; // Default to 1 hour if no iat
201
+ const tenPercentBufferMs = lifetimeSeconds * 0.1 * 1000; // 10% of lifetime in ms
202
+ const fiveMinutesMs = 5 * 60 * 1000; // 5 minutes in ms
203
+
204
+ // Use whichever buffer is smaller (more conservative)
205
+ const bufferMs = Math.min(fiveMinutesMs, tenPercentBufferMs);
206
+
207
+ // Return expiration time minus buffer
208
+ return exp * 1000 - bufferMs;
209
+ } catch {
210
+ return 0;
211
+ }
212
+ }
109
213
 
110
214
  #signingURL?: string;
111
215
  /**
@@ -138,14 +242,40 @@ export function ContextMixin<T extends Constructor<LitElement>>(superClass: T) {
138
242
  #MS_PER_FRAME = 1000 / this.#FPS;
139
243
 
140
244
  #timegroupObserver = new MutationObserver((mutations) => {
245
+ let shouldUpdate = false;
246
+
141
247
  for (const mutation of mutations) {
142
248
  if (mutation.type === "childList") {
143
249
  const newTimegroup = this.querySelector("ef-timegroup");
144
250
  if (newTimegroup !== this.targetTimegroup) {
145
251
  this.targetTimegroup = newTimegroup;
252
+ shouldUpdate = true;
253
+ }
254
+ } else if (mutation.type === "attributes") {
255
+ // Watch for attribute changes that might affect duration
256
+ if (
257
+ mutation.attributeName === "duration" ||
258
+ mutation.attributeName === "mode" ||
259
+ (mutation.target instanceof Element &&
260
+ (mutation.target.tagName === "EF-TIMEGROUP" ||
261
+ mutation.target.closest("ef-timegroup")))
262
+ ) {
263
+ shouldUpdate = true;
146
264
  }
147
265
  }
148
266
  }
267
+
268
+ if (shouldUpdate) {
269
+ // Trigger an update to ensure reactive properties recalculate
270
+ // Use a microtask to ensure DOM updates are complete
271
+ queueMicrotask(() => {
272
+ this.requestUpdate();
273
+ // Also ensure the targetTimegroup updates its computed properties
274
+ if (this.targetTimegroup) {
275
+ this.targetTimegroup.requestUpdate();
276
+ }
277
+ });
278
+ }
149
279
  });
150
280
 
151
281
  connectedCallback(): void {
@@ -169,6 +299,9 @@ export function ContextMixin<T extends Constructor<LitElement>>(superClass: T) {
169
299
  super.disconnectedCallback();
170
300
  this.#timegroupObserver.disconnect();
171
301
  this.stopPlayback();
302
+ // Clear token cache on disconnect to prevent stale tokens
303
+ this.#URLTokens = {};
304
+ this.#URLTokenExpirations = {};
172
305
  }
173
306
 
174
307
  update(changedProperties: Map<string | number | symbol, unknown>) {