@editframe/elements 0.18.8-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.
Files changed (65) hide show
  1. package/dist/elements/EFMedia/AssetIdMediaEngine.js +4 -1
  2. package/dist/elements/EFMedia/AssetMediaEngine.d.ts +3 -4
  3. package/dist/elements/EFMedia/AssetMediaEngine.js +16 -15
  4. package/dist/elements/EFMedia/BaseMediaEngine.d.ts +30 -11
  5. package/dist/elements/EFMedia/BaseMediaEngine.js +83 -31
  6. package/dist/elements/EFMedia/JitMediaEngine.d.ts +2 -4
  7. package/dist/elements/EFMedia/JitMediaEngine.js +12 -12
  8. package/dist/elements/EFTemporal.d.ts +1 -0
  9. package/dist/elements/EFTemporal.js +8 -4
  10. package/dist/elements/EFTimegroup.js +21 -0
  11. package/dist/elements/EFVideo.d.ts +0 -1
  12. package/dist/elements/EFVideo.js +0 -9
  13. package/dist/elements/TargetController.js +3 -2
  14. package/dist/getRenderInfo.d.ts +2 -2
  15. package/dist/gui/ContextMixin.browsertest.d.ts +5 -0
  16. package/dist/gui/ContextMixin.js +96 -5
  17. package/package.json +2 -2
  18. package/src/elements/EFMedia/AssetIdMediaEngine.ts +10 -1
  19. package/src/elements/EFMedia/AssetMediaEngine.ts +25 -21
  20. package/src/elements/EFMedia/BaseMediaEngine.browsertest.ts +311 -0
  21. package/src/elements/EFMedia/BaseMediaEngine.ts +168 -51
  22. package/src/elements/EFMedia/JitMediaEngine.browsertest.ts +2 -12
  23. package/src/elements/EFMedia/JitMediaEngine.ts +25 -16
  24. package/src/elements/EFTemporal.browsertest.ts +47 -0
  25. package/src/elements/EFTemporal.ts +15 -5
  26. package/src/elements/EFTimegroup.ts +40 -0
  27. package/src/elements/EFVideo.browsertest.ts +127 -281
  28. package/src/elements/EFVideo.ts +9 -9
  29. package/src/elements/TargetController.ts +6 -2
  30. package/src/gui/ContextMixin.browsertest.ts +565 -1
  31. package/src/gui/ContextMixin.ts +138 -5
  32. package/test/__cache__/GET__api_v1_transcode_audio_1_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__32da3954ba60c96ad732020c65a08ebc/metadata.json +3 -8
  33. package/test/__cache__/GET__api_v1_transcode_audio_2_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__b0b2b07efcf607de8ee0f650328c32f7/metadata.json +3 -8
  34. package/test/__cache__/GET__api_v1_transcode_audio_3_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__a75c2252b542e0c152c780e9a8d7b154/metadata.json +3 -8
  35. package/test/__cache__/GET__api_v1_transcode_audio_4_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__a64ff1cfb1b52cae14df4b5dfa1e222b/metadata.json +3 -8
  36. package/test/__cache__/GET__api_v1_transcode_audio_5_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__91e8a522f950809b9f09f4173113b4b0/metadata.json +3 -8
  37. package/test/__cache__/GET__api_v1_transcode_audio_init_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__e66d2c831d951e74ad0aeaa6489795d0/metadata.json +3 -8
  38. package/test/__cache__/GET__api_v1_transcode_high_1_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__26197f6f7c46cacb0a71134131c3f775/data.bin +0 -0
  39. package/test/__cache__/GET__api_v1_transcode_high_1_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__26197f6f7c46cacb0a71134131c3f775/metadata.json +4 -9
  40. package/test/__cache__/GET__api_v1_transcode_high_2_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__4cb6774cd3650ccf59c8f8dc6678c0b9/data.bin +0 -0
  41. package/test/__cache__/GET__api_v1_transcode_high_2_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__4cb6774cd3650ccf59c8f8dc6678c0b9/metadata.json +4 -9
  42. package/test/__cache__/GET__api_v1_transcode_high_3_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__0b3b2b1c8933f7fcf8a9ecaa88d58b41/data.bin +0 -0
  43. package/test/__cache__/GET__api_v1_transcode_high_3_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__0b3b2b1c8933f7fcf8a9ecaa88d58b41/metadata.json +4 -9
  44. package/test/__cache__/GET__api_v1_transcode_high_4_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__a6fb05a22b18d850f7f2950bbcdbdeed/data.bin +0 -0
  45. package/test/__cache__/GET__api_v1_transcode_high_4_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__a6fb05a22b18d850f7f2950bbcdbdeed/metadata.json +4 -9
  46. package/test/__cache__/GET__api_v1_transcode_high_5_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__a50058c7c3602e90879fe3428ed891f4/data.bin +0 -0
  47. package/test/__cache__/GET__api_v1_transcode_high_5_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__a50058c7c3602e90879fe3428ed891f4/metadata.json +4 -9
  48. package/test/__cache__/GET__api_v1_transcode_high_init_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__0798c479b44aaeef850609a430f6e613/data.bin +0 -0
  49. package/test/__cache__/GET__api_v1_transcode_high_init_m4s_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__0798c479b44aaeef850609a430f6e613/metadata.json +4 -9
  50. package/test/__cache__/GET__api_v1_transcode_manifest_json_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4__3be92a0437de726b431ed5af2369158a/metadata.json +2 -4
  51. package/test/recordReplayProxyPlugin.js +46 -31
  52. package/test/setup.ts +16 -0
  53. package/test/useAssetMSW.ts +54 -0
  54. package/test/useMSW.ts +4 -11
  55. package/types.json +1 -1
  56. package/dist/elements/MediaController.d.ts +0 -30
  57. package/src/elements/EFMedia/BaseMediaEngine.test.ts +0 -164
  58. package/src/elements/MediaController.ts +0 -98
  59. package/test/__cache__/GET__api_v1_transcode_audio_1_mp4_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4_bytes_0__9ed2d25c675aa6bb6ff5b3ae23887c71/data.bin +0 -0
  60. package/test/__cache__/GET__api_v1_transcode_audio_1_mp4_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4_bytes_0__9ed2d25c675aa6bb6ff5b3ae23887c71/metadata.json +0 -22
  61. package/test/__cache__/GET__api_v1_transcode_audio_2_mp4_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4_bytes_0__d5a3309a2bf756dd6e304807eb402f56/data.bin +0 -0
  62. package/test/__cache__/GET__api_v1_transcode_audio_2_mp4_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4_bytes_0__d5a3309a2bf756dd6e304807eb402f56/metadata.json +0 -22
  63. package/test/__cache__/GET__api_v1_transcode_audio_3_mp4_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4_bytes_0__773254bb671e3466fca8677139fb239e/data.bin +0 -0
  64. package/test/__cache__/GET__api_v1_transcode_audio_3_mp4_url_http_3A_2F_2Fweb_3A3000_2Fhead_moov_480p_mp4_bytes_0__773254bb671e3466fca8677139fb239e/metadata.json +0 -22
  65. /package/dist/elements/EFMedia/{BaseMediaEngine.test.d.ts → BaseMediaEngine.browsertest.d.ts} +0 -0
@@ -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>) {
@@ -8,14 +8,9 @@
8
8
  "cache-control": "public, max-age=3600",
9
9
  "content-length": "32957",
10
10
  "content-type": "video/iso.segment",
11
- "date": "Fri, 08 Aug 2025 03:57:07 GMT",
12
- "x-cache": "HIT",
13
- "x-powered-by": "Express",
14
- "x-total-server-time-ms": "0",
15
- "x-transcode-time-ms": "0"
11
+ "x-powered-by": "Express"
16
12
  },
17
13
  "url": "/api/v1/transcode/audio/1.m4s?url=http%3A%2F%2Fweb%3A3000%2Fhead-moov-480p.mp4",
18
14
  "method": "GET",
19
- "range": null,
20
- "timestamp": "2025-08-08T03:57:07.247Z"
21
- }
15
+ "range": null
16
+ }
@@ -8,14 +8,9 @@
8
8
  "cache-control": "public, max-age=3600",
9
9
  "content-length": "32502",
10
10
  "content-type": "video/iso.segment",
11
- "date": "Fri, 08 Aug 2025 03:57:07 GMT",
12
- "x-cache": "HIT",
13
- "x-powered-by": "Express",
14
- "x-total-server-time-ms": "0",
15
- "x-transcode-time-ms": "0"
11
+ "x-powered-by": "Express"
16
12
  },
17
13
  "url": "/api/v1/transcode/audio/2.m4s?url=http%3A%2F%2Fweb%3A3000%2Fhead-moov-480p.mp4",
18
14
  "method": "GET",
19
- "range": null,
20
- "timestamp": "2025-08-08T03:57:07.247Z"
21
- }
15
+ "range": null
16
+ }
@@ -8,14 +8,9 @@
8
8
  "cache-control": "public, max-age=3600",
9
9
  "content-length": "32196",
10
10
  "content-type": "video/iso.segment",
11
- "date": "Fri, 08 Aug 2025 03:57:07 GMT",
12
- "x-cache": "HIT",
13
- "x-powered-by": "Express",
14
- "x-total-server-time-ms": "0",
15
- "x-transcode-time-ms": "0"
11
+ "x-powered-by": "Express"
16
12
  },
17
13
  "url": "/api/v1/transcode/audio/3.m4s?url=http%3A%2F%2Fweb%3A3000%2Fhead-moov-480p.mp4",
18
14
  "method": "GET",
19
- "range": null,
20
- "timestamp": "2025-08-08T03:57:07.271Z"
21
- }
15
+ "range": null
16
+ }
@@ -8,14 +8,9 @@
8
8
  "cache-control": "public, max-age=3600",
9
9
  "content-length": "32570",
10
10
  "content-type": "video/iso.segment",
11
- "date": "Fri, 08 Aug 2025 03:57:07 GMT",
12
- "x-cache": "HIT",
13
- "x-powered-by": "Express",
14
- "x-total-server-time-ms": "0",
15
- "x-transcode-time-ms": "0"
11
+ "x-powered-by": "Express"
16
12
  },
17
13
  "url": "/api/v1/transcode/audio/4.m4s?url=http%3A%2F%2Fweb%3A3000%2Fhead-moov-480p.mp4",
18
14
  "method": "GET",
19
- "range": null,
20
- "timestamp": "2025-08-08T03:57:07.247Z"
21
- }
15
+ "range": null
16
+ }
@@ -8,14 +8,9 @@
8
8
  "cache-control": "public, max-age=3600",
9
9
  "content-length": "32922",
10
10
  "content-type": "video/iso.segment",
11
- "date": "Mon, 04 Aug 2025 18:16:50 GMT",
12
- "x-cache": "HIT",
13
- "x-powered-by": "Express",
14
- "x-total-server-time-ms": "5",
15
- "x-transcode-time-ms": "0"
11
+ "x-powered-by": "Express"
16
12
  },
17
13
  "url": "/api/v1/transcode/audio/5.m4s?url=http%3A%2F%2Fweb%3A3000%2Fhead-moov-480p.mp4",
18
14
  "method": "GET",
19
- "range": null,
20
- "timestamp": "2025-08-04T18:16:50.399Z"
21
- }
15
+ "range": null
16
+ }
@@ -8,14 +8,9 @@
8
8
  "cache-control": "public, max-age=3600",
9
9
  "content-length": "728",
10
10
  "content-type": "video/iso.segment",
11
- "date": "Fri, 08 Aug 2025 03:57:07 GMT",
12
- "x-cache": "HIT",
13
- "x-powered-by": "Express",
14
- "x-total-server-time-ms": "0",
15
- "x-transcode-time-ms": "0"
11
+ "x-powered-by": "Express"
16
12
  },
17
13
  "url": "/api/v1/transcode/audio/init.m4s?url=http%3A%2F%2Fweb%3A3000%2Fhead-moov-480p.mp4",
18
14
  "method": "GET",
19
- "range": null,
20
- "timestamp": "2025-08-08T03:57:07.205Z"
21
- }
15
+ "range": null
16
+ }
@@ -6,16 +6,11 @@
6
6
  "access-control-allow-origin": "*",
7
7
  "access-control-expose-headers": "Content-Length, Content-Range, X-Cache, X-Actual-Start-Time, X-Actual-Duration, X-Transcode-Time-Ms, X-Total-Server-Time-Ms",
8
8
  "cache-control": "public, max-age=3600",
9
- "content-length": "827216",
9
+ "content-length": "2057283",
10
10
  "content-type": "video/iso.segment",
11
- "date": "Fri, 08 Aug 2025 03:57:07 GMT",
12
- "x-cache": "HIT",
13
- "x-powered-by": "Express",
14
- "x-total-server-time-ms": "0",
15
- "x-transcode-time-ms": "0"
11
+ "x-powered-by": "Express"
16
12
  },
17
13
  "url": "/api/v1/transcode/high/1.m4s?url=http%3A%2F%2Fweb%3A3000%2Fhead-moov-480p.mp4",
18
14
  "method": "GET",
19
- "range": null,
20
- "timestamp": "2025-08-08T03:57:07.256Z"
21
- }
15
+ "range": null
16
+ }
@@ -6,16 +6,11 @@
6
6
  "access-control-allow-origin": "*",
7
7
  "access-control-expose-headers": "Content-Length, Content-Range, X-Cache, X-Actual-Start-Time, X-Actual-Duration, X-Transcode-Time-Ms, X-Total-Server-Time-Ms",
8
8
  "cache-control": "public, max-age=3600",
9
- "content-length": "905893",
9
+ "content-length": "2185975",
10
10
  "content-type": "video/iso.segment",
11
- "date": "Fri, 08 Aug 2025 03:57:07 GMT",
12
- "x-cache": "HIT",
13
- "x-powered-by": "Express",
14
- "x-total-server-time-ms": "1",
15
- "x-transcode-time-ms": "0"
11
+ "x-powered-by": "Express"
16
12
  },
17
13
  "url": "/api/v1/transcode/high/2.m4s?url=http%3A%2F%2Fweb%3A3000%2Fhead-moov-480p.mp4",
18
14
  "method": "GET",
19
- "range": null,
20
- "timestamp": "2025-08-08T03:57:07.697Z"
21
- }
15
+ "range": null
16
+ }
@@ -6,16 +6,11 @@
6
6
  "access-control-allow-origin": "*",
7
7
  "access-control-expose-headers": "Content-Length, Content-Range, X-Cache, X-Actual-Start-Time, X-Actual-Duration, X-Transcode-Time-Ms, X-Total-Server-Time-Ms",
8
8
  "cache-control": "public, max-age=3600",
9
- "content-length": "856088",
9
+ "content-length": "2120135",
10
10
  "content-type": "video/iso.segment",
11
- "date": "Mon, 04 Aug 2025 18:16:50 GMT",
12
- "x-cache": "HIT",
13
- "x-powered-by": "Express",
14
- "x-total-server-time-ms": "8",
15
- "x-transcode-time-ms": "0"
11
+ "x-powered-by": "Express"
16
12
  },
17
13
  "url": "/api/v1/transcode/high/3.m4s?url=http%3A%2F%2Fweb%3A3000%2Fhead-moov-480p.mp4",
18
14
  "method": "GET",
19
- "range": null,
20
- "timestamp": "2025-08-04T18:16:50.395Z"
21
- }
15
+ "range": null
16
+ }
@@ -6,16 +6,11 @@
6
6
  "access-control-allow-origin": "*",
7
7
  "access-control-expose-headers": "Content-Length, Content-Range, X-Cache, X-Actual-Start-Time, X-Actual-Duration, X-Transcode-Time-Ms, X-Total-Server-Time-Ms",
8
8
  "cache-control": "public, max-age=3600",
9
- "content-length": "911925",
9
+ "content-length": "2221511",
10
10
  "content-type": "video/iso.segment",
11
- "date": "Fri, 08 Aug 2025 03:57:07 GMT",
12
- "x-cache": "HIT",
13
- "x-powered-by": "Express",
14
- "x-total-server-time-ms": "0",
15
- "x-transcode-time-ms": "0"
11
+ "x-powered-by": "Express"
16
12
  },
17
13
  "url": "/api/v1/transcode/high/4.m4s?url=http%3A%2F%2Fweb%3A3000%2Fhead-moov-480p.mp4",
18
14
  "method": "GET",
19
- "range": null,
20
- "timestamp": "2025-08-08T03:57:07.247Z"
21
- }
15
+ "range": null
16
+ }
@@ -6,16 +6,11 @@
6
6
  "access-control-allow-origin": "*",
7
7
  "access-control-expose-headers": "Content-Length, Content-Range, X-Cache, X-Actual-Start-Time, X-Actual-Duration, X-Transcode-Time-Ms, X-Total-Server-Time-Ms",
8
8
  "cache-control": "public, max-age=3600",
9
- "content-length": "829743",
9
+ "content-length": "2037521",
10
10
  "content-type": "video/iso.segment",
11
- "date": "Tue, 05 Aug 2025 06:16:17 GMT",
12
- "x-cache": "HIT",
13
- "x-powered-by": "Express",
14
- "x-total-server-time-ms": "16",
15
- "x-transcode-time-ms": "0"
11
+ "x-powered-by": "Express"
16
12
  },
17
13
  "url": "/api/v1/transcode/high/5.m4s?url=http%3A%2F%2Fweb%3A3000%2Fhead-moov-480p.mp4",
18
14
  "method": "GET",
19
- "range": null,
20
- "timestamp": "2025-08-05T06:16:17.550Z"
21
- }
15
+ "range": null
16
+ }
@@ -6,16 +6,11 @@
6
6
  "access-control-allow-origin": "*",
7
7
  "access-control-expose-headers": "Content-Length, Content-Range, X-Cache, X-Actual-Start-Time, X-Actual-Duration, X-Transcode-Time-Ms, X-Total-Server-Time-Ms",
8
8
  "cache-control": "public, max-age=3600",
9
- "content-length": "782",
9
+ "content-length": "775",
10
10
  "content-type": "video/iso.segment",
11
- "date": "Fri, 08 Aug 2025 03:57:07 GMT",
12
- "x-cache": "HIT",
13
- "x-powered-by": "Express",
14
- "x-total-server-time-ms": "0",
15
- "x-transcode-time-ms": "0"
11
+ "x-powered-by": "Express"
16
12
  },
17
13
  "url": "/api/v1/transcode/high/init.m4s?url=http%3A%2F%2Fweb%3A3000%2Fhead-moov-480p.mp4",
18
14
  "method": "GET",
19
- "range": null,
20
- "timestamp": "2025-08-08T03:57:07.214Z"
21
- }
15
+ "range": null
16
+ }
@@ -8,12 +8,10 @@
8
8
  "cache-control": "public, max-age=300",
9
9
  "content-length": "2045",
10
10
  "content-type": "application/json; charset=utf-8",
11
- "date": "Thu, 07 Aug 2025 20:55:37 GMT",
12
11
  "etag": "W/\"81b-wi6z588RhWTgs57jivyDs3lEkkA\"",
13
12
  "x-powered-by": "Express"
14
13
  },
15
14
  "url": "/api/v1/transcode/manifest.json?url=http%3A%2F%2Fweb%3A3000%2Fhead-moov-480p.mp4",
16
15
  "method": "GET",
17
- "range": null,
18
- "timestamp": "2025-08-07T20:55:37.385Z"
19
- }
16
+ "range": null
17
+ }
@@ -9,6 +9,9 @@ const CACHE_DIR = join(__dirname, "__cache__");
9
9
  const TARGET_HOST = "host.docker.internal";
10
10
  const TARGET_PORT = 3000;
11
11
 
12
+ // Check if we should run in cache-only mode (for CI/prepare-release)
13
+ const CACHE_ONLY_MODE = process.env.EF_CACHE_ONLY === "true";
14
+
12
15
  /**
13
16
  * Vite plugin that adds record-and-replay proxy middleware
14
17
  * This proxy intercepts requests to /api/v1/transcode/*, caches responses to disk,
@@ -20,7 +23,7 @@ export function recordReplayProxyPlugin() {
20
23
 
21
24
  configureServer(server) {
22
25
  console.log(
23
- "[Proxy Plugin] Configuring record-replay proxy middleware...",
26
+ `[Proxy Plugin] Configuring record-replay proxy middleware... ${CACHE_ONLY_MODE ? "(CACHE-ONLY MODE)" : ""}`,
24
27
  );
25
28
 
26
29
  // Initialize cache directory
@@ -33,6 +36,11 @@ export function recordReplayProxyPlugin() {
33
36
 
34
37
  console.log("[Proxy Plugin] Proxy middleware configured");
35
38
  console.log(`[Proxy Plugin] Cache directory: ${CACHE_DIR}`);
39
+ if (CACHE_ONLY_MODE) {
40
+ console.log(
41
+ "[Proxy Plugin] ⚠️ Running in CACHE-ONLY mode - no remote fetching",
42
+ );
43
+ }
36
44
  },
37
45
  };
38
46
 
@@ -107,6 +115,7 @@ export function recordReplayProxyPlugin() {
107
115
  const headers = { ...normalized.headers };
108
116
  delete headers.date;
109
117
  delete headers["x-total-server-time-ms"];
118
+ delete headers["x-transcode-time-ms"]; // This varies between requests
110
119
  delete headers["x-cache"]; // This can vary between HIT/MISS
111
120
  normalized.headers = headers;
112
121
  }
@@ -117,30 +126,6 @@ export function recordReplayProxyPlugin() {
117
126
  return normalized;
118
127
  }
119
128
 
120
- // Check if cache should be updated by comparing normalized metadata
121
- async function shouldUpdateCache(cacheDir, newMetadata) {
122
- try {
123
- const metadataFile = join(cacheDir, "metadata.json");
124
- if (!existsSync(metadataFile)) {
125
- return true; // No existing cache, should update
126
- }
127
-
128
- const existingMetadata = JSON.parse(
129
- await readFile(metadataFile, "utf-8"),
130
- );
131
- const normalizedExisting = normalizeMetadata(existingMetadata);
132
- const normalizedNew = normalizeMetadata(newMetadata);
133
-
134
- // Compare normalized metadata to decide if update is needed
135
- return (
136
- JSON.stringify(normalizedExisting) !== JSON.stringify(normalizedNew)
137
- );
138
- } catch (error) {
139
- console.warn(`[Proxy] Failed to check cache metadata: ${error.message}`);
140
- return true; // Default to updating on error
141
- }
142
- }
143
-
144
129
  // Save response to cache
145
130
  async function cacheResponse(
146
131
  cacheDir,
@@ -163,14 +148,15 @@ export function recordReplayProxyPlugin() {
163
148
  timestamp: new Date().toISOString(),
164
149
  };
165
150
 
166
- // Only update cache if metadata has meaningfully changed
167
- if (!(await shouldUpdateCache(cacheDir, metadata))) {
168
- console.log("[Proxy] Cache up to date, skipping write");
169
- return;
170
- }
151
+ // Always write the response to cache - binary content can change even if headers don't
152
+ // Write normalized metadata to disk (without dynamic fields)
153
+ const normalizedMetadata = normalizeMetadata(metadata);
171
154
 
172
155
  const metadataFile = join(cacheDir, "metadata.json");
173
- await writeFile(metadataFile, JSON.stringify(metadata, null, 2));
156
+ await writeFile(
157
+ metadataFile,
158
+ JSON.stringify(normalizedMetadata, null, 2),
159
+ );
174
160
 
175
161
  const dataFile = join(cacheDir, "data.bin");
176
162
  await writeFile(dataFile, body); // Write raw binary data
@@ -209,6 +195,35 @@ export function recordReplayProxyPlugin() {
209
195
  const cacheKey = getCacheKey(req.method, fullPath, req.headers);
210
196
  const cacheDir = join(CACHE_DIR, cacheKey);
211
197
 
198
+ // In cache-only mode, try to serve from cache first
199
+ if (CACHE_ONLY_MODE) {
200
+ if (existsSync(cacheDir)) {
201
+ try {
202
+ const metadataFile = join(cacheDir, "metadata.json");
203
+ if (existsSync(metadataFile)) {
204
+ console.log(
205
+ `[Proxy] ✓ CACHE-ONLY: Serving from cache: ${cacheKey}`,
206
+ );
207
+ await serveCachedResponse(res, cacheDir, req);
208
+ return;
209
+ }
210
+ } catch (cacheError) {
211
+ console.error(`[Proxy] Failed to read cache: ${cacheError.message}`);
212
+ }
213
+ }
214
+
215
+ console.log(`[Proxy] ✗ CACHE-ONLY: No cache available for ${cacheKey}`);
216
+ res.writeHead(404, { "Content-Type": "application/json" });
217
+ res.end(
218
+ JSON.stringify({
219
+ error: "Cache-only mode enabled but no cache found",
220
+ cacheKey,
221
+ suggestion: "Run tests locally first to populate cache",
222
+ }),
223
+ );
224
+ return;
225
+ }
226
+
212
227
  try {
213
228
  // Collect request body
214
229
  const requestChunks = [];
package/test/setup.ts ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Global test setup for all browser tests
3
+ * This runs before every test to ensure clean state
4
+ */
5
+
6
+ import { beforeEach } from "vitest";
7
+ import {
8
+ globalRequestDeduplicator,
9
+ mediaCache,
10
+ } from "../src/elements/EFMedia/BaseMediaEngine.js";
11
+
12
+ // Clear global caches before each test to ensure isolation
13
+ beforeEach(() => {
14
+ globalRequestDeduplicator.clear();
15
+ mediaCache.clear();
16
+ });