@editframe/elements 0.46.4 → 0.47.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 (68) hide show
  1. package/dist/elements/EFCaptions.d.ts +2 -2
  2. package/dist/elements/EFMedia/BufferedSeekingInput.d.ts +50 -0
  3. package/dist/elements/EFMedia/BufferedSeekingInput.js +6 -5
  4. package/dist/elements/EFMedia/BufferedSeekingInput.js.map +1 -1
  5. package/dist/elements/EFMedia/CachedFetcher.js +23 -33
  6. package/dist/elements/EFMedia/CachedFetcher.js.map +1 -1
  7. package/dist/elements/EFMedia/SegmentTransport.d.ts +2 -2
  8. package/dist/elements/EFMedia/SegmentTransport.js.map +1 -1
  9. package/dist/elements/EFMedia/shared/ThumbnailExtractor.js +53 -0
  10. package/dist/elements/EFMedia/shared/ThumbnailExtractor.js.map +1 -1
  11. package/dist/elements/EFMedia/videoTasks/MainVideoInputCache.js +20 -5
  12. package/dist/elements/EFMedia/videoTasks/MainVideoInputCache.js.map +1 -1
  13. package/dist/elements/EFMedia/videoTasks/ScrubInputCache.d.ts +48 -0
  14. package/dist/elements/EFMedia/videoTasks/ScrubInputCache.js +36 -7
  15. package/dist/elements/EFMedia/videoTasks/ScrubInputCache.js.map +1 -1
  16. package/dist/elements/EFMedia.d.ts +2 -2
  17. package/dist/elements/EFMotionBlur.d.ts +130 -0
  18. package/dist/elements/EFMotionBlur.js +808 -0
  19. package/dist/elements/EFMotionBlur.js.map +1 -0
  20. package/dist/elements/EFTemporal.js +1 -2
  21. package/dist/elements/EFTemporal.js.map +1 -1
  22. package/dist/elements/EFText.d.ts +20 -0
  23. package/dist/elements/EFText.js +66 -9
  24. package/dist/elements/EFText.js.map +1 -1
  25. package/dist/elements/EFTimegroup.d.ts +12 -0
  26. package/dist/elements/EFTimegroup.js +43 -4
  27. package/dist/elements/EFTimegroup.js.map +1 -1
  28. package/dist/elements/EFVideo.d.ts +26 -0
  29. package/dist/elements/EFVideo.js +114 -36
  30. package/dist/elements/EFVideo.js.map +1 -1
  31. package/dist/elements/SampleBuffer.d.ts +19 -0
  32. package/dist/elements/updateAnimations.js +49 -3
  33. package/dist/elements/updateAnimations.js.map +1 -1
  34. package/dist/gui/EFWorkbench.d.ts +1 -0
  35. package/dist/gui/EFWorkbench.js +15 -0
  36. package/dist/gui/EFWorkbench.js.map +1 -1
  37. package/dist/gui/EFWorkbench.spacebar.js +26 -0
  38. package/dist/gui/EFWorkbench.spacebar.js.map +1 -0
  39. package/dist/gui/TWMixin.js +1 -1
  40. package/dist/gui/TWMixin.js.map +1 -1
  41. package/dist/gui/timeline/EFTimeline.d.ts +18 -1
  42. package/dist/gui/timeline/EFTimeline.js +119 -25
  43. package/dist/gui/timeline/EFTimeline.js.map +1 -1
  44. package/dist/gui/timeline/timelineStateContext.d.ts +2 -0
  45. package/dist/gui/timeline/timelineStateContext.js.map +1 -1
  46. package/dist/gui/timeline/tracks/EFThumbnailStrip.js +14 -8
  47. package/dist/gui/timeline/tracks/EFThumbnailStrip.js.map +1 -1
  48. package/dist/index.d.ts +2 -1
  49. package/dist/index.js +2 -1
  50. package/dist/index.js.map +1 -1
  51. package/dist/preview/FrameController.d.ts +22 -1
  52. package/dist/preview/FrameController.js +26 -5
  53. package/dist/preview/FrameController.js.map +1 -1
  54. package/dist/preview/QualityUpgradeScheduler.d.ts +11 -2
  55. package/dist/preview/QualityUpgradeScheduler.js +31 -21
  56. package/dist/preview/QualityUpgradeScheduler.js.map +1 -1
  57. package/dist/preview/renderTimegroupToCanvas.js +4 -0
  58. package/dist/preview/renderTimegroupToCanvas.js.map +1 -1
  59. package/dist/preview/renderTimegroupToCanvas.types.d.ts +2 -0
  60. package/dist/preview/renderTimegroupToVideo.js +3 -0
  61. package/dist/preview/renderTimegroupToVideo.js.map +1 -1
  62. package/dist/preview/rendering/serializeTimelineDirect.js +30 -35
  63. package/dist/preview/rendering/serializeTimelineDirect.js.map +1 -1
  64. package/dist/style.css +4 -0
  65. package/dist/utils/LRUCache.js +17 -5
  66. package/dist/utils/LRUCache.js.map +1 -1
  67. package/dist/version.js +1 -1
  68. package/package.json +2 -2
@@ -2,6 +2,7 @@ import { FrameRenderable, FrameState } from "../preview/FrameController.js";
2
2
  import { EFFramegen } from "../EF_FRAMEGEN.js";
3
3
  import { EFMedia } from "./EFMedia.js";
4
4
  import { RenderToVideoOptions } from "../preview/renderTimegroupToVideo.types.js";
5
+ import { ScrubInputCache } from "./EFMedia/videoTasks/ScrubInputCache.js";
5
6
  import * as lit3 from "lit";
6
7
  import { PropertyValueMap } from "lit";
7
8
  import { VideoSample } from "mediabunny";
@@ -167,6 +168,31 @@ declare class EFVideo extends EFVideo_base implements FrameRenderable {
167
168
  * @public
168
169
  */
169
170
  prefetchScrubSegments(timestamps: number[], onProgress?: (loaded: number, total: number, segmentTimeRange: [number, number]) => void, signal?: AbortSignal): Promise<void>;
171
+ /**
172
+ * Warm the scrub BufferedSeekingInput cache for all segments covering [fromMs, toMs].
173
+ *
174
+ * Unlike prefetchScrubSegments (which only warms the network layer), this method
175
+ * constructs BufferedSeekingInput instances for each segment so that subsequent
176
+ * scrub seeks within the range complete without a network round-trip or BSI
177
+ * construction overhead.
178
+ *
179
+ * Returns a Promise that resolves after the range has been computed and segment
180
+ * fetches have been kicked off (but before individual fetches complete).
181
+ * Callers may await this or discard the promise — both are valid.
182
+ */
183
+ warmScrubCacheForRange(fromMs: number, toMs: number, signal?: AbortSignal): Promise<void>;
184
+ /**
185
+ * Reset per-instance caches and rendition state. Allows tests to force
186
+ * the scrub fallback path on the next render without clearing the shared
187
+ * mediaCache (which races with in-flight fetches from other elements).
188
+ * @public – test-only
189
+ */
190
+ clearInstanceCaches(): void;
191
+ /**
192
+ * Returns scrub cache statistics for inspection in tests.
193
+ * @public – test-only
194
+ */
195
+ getScrubCacheStats(): ReturnType<ScrubInputCache["getStats"]>;
170
196
  /**
171
197
  * Clean up resources when component is disconnected
172
198
  */
@@ -14,8 +14,6 @@ import { context, trace } from "@opentelemetry/api";
14
14
  import { createRef, ref } from "lit/directives/ref.js";
15
15
 
16
16
  //#region src/elements/EFVideo.ts
17
- const mainVideoInputCache = new MainVideoInputCache();
18
- const scrubInputCache = new ScrubInputCache();
19
17
  const log = debug("ef:elements:EFVideo");
20
18
  var VideoSeekTask = class {
21
19
  constructor() {
@@ -99,6 +97,8 @@ let EFVideo = class EFVideo$1 extends TWMixin(EFMedia) {
99
97
  * Standalone upgrade controller for elements without a timegroup.
100
98
  */
101
99
  #standaloneUpgradeController = null;
100
+ #mainVideoInputCache = new MainVideoInputCache();
101
+ #scrubInputCache = new ScrubInputCache();
102
102
  /**
103
103
  * Set to true while renderToVideo is executing to suppress background
104
104
  * quality upgrade tasks that would race with the render pipeline.
@@ -131,6 +131,20 @@ let EFVideo = class EFVideo$1 extends TWMixin(EFMedia) {
131
131
  getFrameState(_timeMs) {
132
132
  const sourceTimeMs = this.currentSourceTimeMs;
133
133
  const hasCache = this.#cachedVideoSample !== void 0 && this.#cachedVideoSampleTimeMs === sourceTimeMs;
134
+ if (this.#currentRenditionId === "scrub" && !this.rootTimegroup?.isRenderClone) {
135
+ const mediaEngine = this.mediaEngineTask.value;
136
+ if (mediaEngine) {
137
+ const mainTrack = mediaEngine.tracks.video;
138
+ if (mainTrack) {
139
+ const mainSegmentId = mediaEngine.index.segmentAt(sourceTimeMs, mainTrack);
140
+ if (mainSegmentId !== void 0 && mediaEngine.transport.isCached(mainSegmentId, mainTrack)) return {
141
+ needsPreparation: true,
142
+ isReady: false,
143
+ priority: PRIORITY_VIDEO
144
+ };
145
+ }
146
+ }
147
+ }
134
148
  return {
135
149
  needsPreparation: !hasCache,
136
150
  isReady: hasCache,
@@ -162,10 +176,10 @@ let EFVideo = class EFVideo$1 extends TWMixin(EFMedia) {
162
176
  signal.throwIfAborted();
163
177
  try {
164
178
  const videoSample = await this.#fetchVideoSampleForFrame(mediaEngine, sourceTimeMs, signal);
165
- signal.throwIfAborted();
166
179
  this.#cachedVideoSample = videoSample;
167
180
  this.#cachedVideoSampleTimeMs = sourceTimeMs;
168
181
  this.unifiedVideoSeekTask.complete(videoSample);
182
+ signal.throwIfAborted();
169
183
  } catch (error) {
170
184
  if (error instanceof DOMException && error.name === "AbortError") {
171
185
  this.unifiedVideoSeekTask.abort();
@@ -189,13 +203,15 @@ let EFVideo = class EFVideo$1 extends TWMixin(EFMedia) {
189
203
  */
190
204
  renderFrame(_timeMs) {
191
205
  const sourceTimeMs = this.currentSourceTimeMs;
192
- if (this.#cachedVideoSampleTimeMs === sourceTimeMs && this.#cachedVideoSample) {
206
+ if (this.#cachedVideoSampleTimeMs === sourceTimeMs && this.#cachedVideoSample) try {
193
207
  const videoFrame = this.#cachedVideoSample.toVideoFrame();
194
208
  try {
195
209
  this.displayFrame(videoFrame, sourceTimeMs);
196
210
  } finally {
197
211
  videoFrame.close();
198
212
  }
213
+ } catch {
214
+ this.#cachedVideoSample = void 0;
199
215
  }
200
216
  if (!this.parentTimegroup) updateAnimations(this);
201
217
  }
@@ -240,25 +256,16 @@ let EFVideo = class EFVideo$1 extends TWMixin(EFMedia) {
240
256
  if (!scrubTrack) return;
241
257
  const segmentId = mediaEngine.index.segmentAt(desiredSeekTimeMs, scrubTrack);
242
258
  if (segmentId === void 0) return;
243
- const scrubInput = await scrubInputCache.getOrCreateInput(mediaEngine.src, segmentId, async () => {
259
+ const scrubInput = await this.#scrubInputCache.getOrCreateInput(mediaEngine.src, segmentId, async () => {
244
260
  let initSegment;
245
261
  let mediaSegment;
246
262
  try {
247
- const initP = mediaEngine.transport.fetchInitSegment(scrubTrack, signal);
248
- const mediaP = mediaEngine.transport.fetchMediaSegment(segmentId, scrubTrack, signal);
249
- initP.catch(() => {});
250
- mediaP.catch(() => {});
251
- [initSegment, mediaSegment] = await Promise.all([initP, mediaP]);
263
+ [initSegment, mediaSegment] = await Promise.all([mediaEngine.transport.fetchInitSegment(scrubTrack), mediaEngine.transport.fetchMediaSegment(segmentId, scrubTrack)]);
252
264
  } catch (error) {
253
- if (error instanceof DOMException && error.name === "AbortError") throw error;
254
265
  return;
255
266
  }
256
267
  if (!initSegment || !mediaSegment) return;
257
- signal.throwIfAborted();
258
- const combinedBlob = new Blob([initSegment, mediaSegment]);
259
- signal.throwIfAborted();
260
- const arrayBuffer = await combinedBlob.arrayBuffer();
261
- signal.throwIfAborted();
268
+ const arrayBuffer = await new Blob([initSegment, mediaSegment]).arrayBuffer();
262
269
  const { BufferedSeekingInput } = await import("./EFMedia/BufferedSeekingInput.js");
263
270
  return new BufferedSeekingInput(arrayBuffer, {
264
271
  videoBufferSize: EFMedia.VIDEO_SAMPLE_BUFFER_SIZE,
@@ -281,26 +288,19 @@ let EFVideo = class EFVideo$1 extends TWMixin(EFMedia) {
281
288
  if (!videoTrack) return;
282
289
  const segmentId = mediaEngine.index.segmentAt(desiredSeekTimeMs, videoTrack);
283
290
  if (segmentId === void 0) return;
284
- const mainInput = await mainVideoInputCache.getOrCreateInput(mediaEngine.src, segmentId, String(videoTrack.id), async () => {
291
+ const mainInput = await this.#mainVideoInputCache.getOrCreateInput(mediaEngine.src, segmentId, String(videoTrack.id), async () => {
285
292
  let initSegment;
286
293
  let mediaSegment;
287
294
  try {
288
- const initP = mediaEngine.transport.fetchInitSegment(videoTrack, signal);
289
- const mediaP = mediaEngine.transport.fetchMediaSegment(segmentId, videoTrack, signal);
290
- initP.catch(() => {});
291
- mediaP.catch(() => {});
295
+ const initP = mediaEngine.transport.fetchInitSegment(videoTrack);
296
+ const mediaP = mediaEngine.transport.fetchMediaSegment(segmentId, videoTrack);
292
297
  [initSegment, mediaSegment] = await Promise.all([initP, mediaP]);
293
298
  } catch (error) {
294
- if (error instanceof DOMException && error.name === "AbortError") throw error;
295
299
  if (error instanceof Error && (error.message.includes("401") || error.message.includes("UNAUTHORIZED") || error.message.includes("Failed to fetch") || error.message.includes("File not found") || error.message.includes("Media segment not found") || error.message.includes("Init segment not found") || error.message.includes("Track not found"))) return;
296
300
  throw error;
297
301
  }
298
302
  if (!initSegment || !mediaSegment) return;
299
- signal.throwIfAborted();
300
- const combinedBlob = new Blob([initSegment, mediaSegment]);
301
- signal.throwIfAborted();
302
- const arrayBuffer = await combinedBlob.arrayBuffer();
303
- signal.throwIfAborted();
303
+ const arrayBuffer = await new Blob([initSegment, mediaSegment]).arrayBuffer();
304
304
  const { BufferedSeekingInput } = await import("./EFMedia/BufferedSeekingInput.js");
305
305
  return new BufferedSeekingInput(arrayBuffer, {
306
306
  videoBufferSize: EFMedia.VIDEO_SAMPLE_BUFFER_SIZE,
@@ -335,6 +335,10 @@ let EFVideo = class EFVideo$1 extends TWMixin(EFMedia) {
335
335
  updated(changedProperties) {
336
336
  super.updated(changedProperties);
337
337
  if (changedProperties.has("src") || changedProperties.has("fileId")) {
338
+ this.#cachedVideoSample = void 0;
339
+ this.#cachedVideoSampleTimeMs = void 0;
340
+ this.#mainVideoInputCache.clear();
341
+ this.#scrubInputCache.clear();
338
342
  this.#invalidateUpgradeState("src-change");
339
343
  this.#prewarmQualityUpgrade();
340
344
  }
@@ -361,8 +365,8 @@ let EFVideo = class EFVideo$1 extends TWMixin(EFMedia) {
361
365
  if (!this.src && !this.fileId) return;
362
366
  this.getMediaEngine().then((engine) => {
363
367
  if (!engine) return;
364
- const sourceInMs = this.sourceInMs ?? 0;
365
- this.#maybeScheduleQualityUpgrade(engine, sourceInMs);
368
+ const targetTimeMs = this.currentSourceTimeMs ?? this.sourceInMs ?? 0;
369
+ this.#maybeScheduleQualityUpgrade(engine, targetTimeMs);
366
370
  }).catch(() => {});
367
371
  }
368
372
  render() {
@@ -570,9 +574,9 @@ let EFVideo = class EFVideo$1 extends TWMixin(EFMedia) {
570
574
  if (!videoTrack) throw new Error("No video rendition available");
571
575
  const segmentId = mediaEngine.index.segmentAt(sourceTimeMs, videoTrack);
572
576
  if (segmentId === void 0) throw new Error(`Cannot compute segment ID for time ${sourceTimeMs}ms`);
573
- const seekingInput = await mainVideoInputCache.getOrCreateInput(mediaEngine.src, segmentId, String(videoTrack.id), async () => {
574
- const initP = mediaEngine.transport.fetchInitSegment(videoTrack, signal);
575
- const mediaP = mediaEngine.transport.fetchMediaSegment(segmentId, videoTrack, signal);
577
+ const seekingInput = await this.#mainVideoInputCache.getOrCreateInput(mediaEngine.src, segmentId, String(videoTrack.id), async () => {
578
+ const initP = mediaEngine.transport.fetchInitSegment(videoTrack);
579
+ const mediaP = mediaEngine.transport.fetchMediaSegment(segmentId, videoTrack);
576
580
  initP.catch(() => {});
577
581
  mediaP.catch(() => {});
578
582
  const [initSegment, mediaSegment] = await Promise.all([initP, mediaP]);
@@ -598,9 +602,9 @@ let EFVideo = class EFVideo$1 extends TWMixin(EFMedia) {
598
602
  });
599
603
  const segmentId = mediaEngine.index.segmentAt(sourceTimeMs, scrubTrack);
600
604
  if (segmentId === void 0) throw new Error(`Cannot compute scrub segment ID for time ${sourceTimeMs}ms`);
601
- const seekingInput = await scrubInputCache.getOrCreateInput(mediaEngine.src, segmentId, async () => {
602
- const initP = mediaEngine.transport.fetchInitSegment(scrubTrack, signal);
603
- const mediaP = mediaEngine.transport.fetchMediaSegment(segmentId, scrubTrack, signal);
605
+ const seekingInput = await this.#scrubInputCache.getOrCreateInput(mediaEngine.src, segmentId, async () => {
606
+ const initP = mediaEngine.transport.fetchInitSegment(scrubTrack);
607
+ const mediaP = mediaEngine.transport.fetchMediaSegment(segmentId, scrubTrack);
604
608
  initP.catch(() => {});
605
609
  mediaP.catch(() => {});
606
610
  const [initSegment, mediaSegment] = await Promise.all([initP, mediaP]);
@@ -747,11 +751,56 @@ let EFVideo = class EFVideo$1 extends TWMixin(EFMedia) {
747
751
  log(`prefetchScrubSegments: complete`);
748
752
  }
749
753
  /**
754
+ * Warm the scrub BufferedSeekingInput cache for all segments covering [fromMs, toMs].
755
+ *
756
+ * Unlike prefetchScrubSegments (which only warms the network layer), this method
757
+ * constructs BufferedSeekingInput instances for each segment so that subsequent
758
+ * scrub seeks within the range complete without a network round-trip or BSI
759
+ * construction overhead.
760
+ *
761
+ * Returns a Promise that resolves after the range has been computed and segment
762
+ * fetches have been kicked off (but before individual fetches complete).
763
+ * Callers may await this or discard the promise — both are valid.
764
+ */
765
+ async warmScrubCacheForRange(fromMs, toMs, signal) {
766
+ const mediaEngine = await this.getMediaEngine(signal);
767
+ if (!mediaEngine) return;
768
+ if (signal?.aborted) return;
769
+ const scrubTrack = mediaEngine.tracks.scrub;
770
+ if (!scrubTrack) return;
771
+ const segments = mediaEngine.index.segmentsInRange(fromMs, toMs, scrubTrack);
772
+ if (segments.length === 0) return;
773
+ const { src } = mediaEngine;
774
+ for (const { segmentId } of segments) {
775
+ if (signal?.aborted) return;
776
+ const capturedSegmentId = segmentId;
777
+ this.#scrubInputCache.getOrCreateInput(src, capturedSegmentId, async () => {
778
+ if (signal?.aborted) return void 0;
779
+ let initSegment;
780
+ let mediaSegment;
781
+ try {
782
+ [initSegment, mediaSegment] = await Promise.all([mediaEngine.transport.fetchInitSegment(scrubTrack), mediaEngine.transport.fetchMediaSegment(capturedSegmentId, scrubTrack)]);
783
+ } catch {
784
+ return;
785
+ }
786
+ if (!initSegment || !mediaSegment) return void 0;
787
+ const arrayBuffer = await new Blob([initSegment, mediaSegment]).arrayBuffer();
788
+ const { BufferedSeekingInput } = await import("./EFMedia/BufferedSeekingInput.js");
789
+ return new BufferedSeekingInput(arrayBuffer, {
790
+ videoBufferSize: EFMedia.VIDEO_SAMPLE_BUFFER_SIZE,
791
+ audioBufferSize: EFMedia.AUDIO_SAMPLE_BUFFER_SIZE,
792
+ startTimeOffsetMs: scrubTrack.startTimeOffsetMs
793
+ });
794
+ }).catch(() => {});
795
+ }
796
+ }
797
+ /**
750
798
  * Maybe schedule quality upgrade tasks for this element.
751
799
  * Called when returning a scrub sample - checks if state has changed and submits tasks.
752
800
  */
753
801
  #maybeScheduleQualityUpgrade(mediaEngine, sourceTimeMs) {
754
802
  if (this.#renderingToVideo) return;
803
+ if (this.rootTimegroup?.isRenderClone) return;
755
804
  const mainTrack = mediaEngine.tracks.video;
756
805
  if (!mainTrack) return;
757
806
  const segmentId = mediaEngine.index.segmentAt(sourceTimeMs, mainTrack);
@@ -766,12 +815,17 @@ let EFVideo = class EFVideo$1 extends TWMixin(EFMedia) {
766
815
  }
767
816
  const segments = this.#computeLookaheadSegments(mediaEngine, sourceTimeMs, mainTrack);
768
817
  if (segments.length === 0) return;
818
+ const capturedSrc = mediaEngine.src;
769
819
  const tasks = segments.map((seg) => ({
770
820
  key: `${this.#upgradeOwnerId}:${seg.segmentId}:${mainTrack.id}`,
771
821
  fetch: async (signal) => {
772
822
  await mediaEngine.transport.fetchInitSegment(mainTrack, signal);
773
823
  await mediaEngine.transport.fetchMediaSegment(seg.segmentId, mainTrack, signal);
774
824
  },
825
+ isCached: () => {
826
+ if (this.mediaEngineTask?.value?.src !== capturedSrc) return false;
827
+ return mediaEngine.transport.isCached(seg.segmentId, mainTrack);
828
+ },
775
829
  deadlineMs: seg.deadlineMs,
776
830
  owner: this.#upgradeOwnerId
777
831
  }));
@@ -824,7 +878,8 @@ let EFVideo = class EFVideo$1 extends TWMixin(EFMedia) {
824
878
  await task.fetch(signal);
825
879
  } catch {}
826
880
  }
827
- if (!signal.aborted) this.playbackController?.runThrottledFrameTask().catch(() => {});
881
+ if (!signal.aborted) if (this.rootTimegroup) this.rootTimegroup.requestFrameRender();
882
+ else this.playbackController?.runThrottledFrameTask().catch(() => {});
828
883
  })().catch(() => {});
829
884
  }
830
885
  /**
@@ -835,10 +890,33 @@ let EFVideo = class EFVideo$1 extends TWMixin(EFMedia) {
835
890
  this.#upgradeState = null;
836
891
  }
837
892
  /**
893
+ * Reset per-instance caches and rendition state. Allows tests to force
894
+ * the scrub fallback path on the next render without clearing the shared
895
+ * mediaCache (which races with in-flight fetches from other elements).
896
+ * @public – test-only
897
+ */
898
+ clearInstanceCaches() {
899
+ this.#cachedVideoSample = void 0;
900
+ this.#cachedVideoSampleTimeMs = void 0;
901
+ this.#currentRenditionId = void 0;
902
+ this.#upgradeState = null;
903
+ this.#mainVideoInputCache.clear();
904
+ this.#scrubInputCache.clear();
905
+ }
906
+ /**
907
+ * Returns scrub cache statistics for inspection in tests.
908
+ * @public – test-only
909
+ */
910
+ getScrubCacheStats() {
911
+ return this.#scrubInputCache.getStats();
912
+ }
913
+ /**
838
914
  * Clean up resources when component is disconnected
839
915
  */
840
916
  disconnectedCallback() {
841
917
  super.disconnectedCallback();
918
+ this.#cachedVideoSample = void 0;
919
+ this.#cachedVideoSampleTimeMs = void 0;
842
920
  this.#delayedLoadingState.clearAllLoading();
843
921
  this.#invalidateUpgradeState("disconnect");
844
922
  this.#standaloneUpgradeController?.abort();