@editframe/elements 0.46.2 → 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 (71) 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 +132 -27
  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.d.ts +4 -3
  58. package/dist/preview/renderTimegroupToCanvas.js +35 -33
  59. package/dist/preview/renderTimegroupToCanvas.js.map +1 -1
  60. package/dist/preview/renderTimegroupToCanvas.types.d.ts +2 -0
  61. package/dist/preview/renderTimegroupToVideo.js +3 -0
  62. package/dist/preview/renderTimegroupToVideo.js.map +1 -1
  63. package/dist/preview/rendering/renderToImageNative.js +7 -2
  64. package/dist/preview/rendering/renderToImageNative.js.map +1 -1
  65. package/dist/preview/rendering/serializeTimelineDirect.js +30 -35
  66. package/dist/preview/rendering/serializeTimelineDirect.js.map +1 -1
  67. package/dist/style.css +7 -0
  68. package/dist/utils/LRUCache.js +17 -5
  69. package/dist/utils/LRUCache.js.map +1 -1
  70. package/dist/version.js +1 -1
  71. package/package.json +2 -2
@@ -5,7 +5,7 @@ import { FetchMixinInterface } from "./FetchMixin.js";
5
5
  import { AsyncValue } from "./EFMedia.js";
6
6
  import { EFAudio } from "./EFAudio.js";
7
7
  import { EFVideo } from "./EFVideo.js";
8
- import * as lit1 from "lit";
8
+ import * as lit0 from "lit";
9
9
  import { LitElement, PropertyValueMap } from "lit";
10
10
  import * as lit_html0 from "lit-html";
11
11
 
@@ -61,7 +61,7 @@ declare class EFCaptionsAfterActiveWord extends EFCaptionsSegment {
61
61
  declare const EFCaptions_base: (new (...args: any[]) => EFSourceMixinInterface) & (new (...args: any[]) => TemporalMixinInterface) & (new (...args: any[]) => FetchMixinInterface) & typeof LitElement;
62
62
  declare class EFCaptions extends EFCaptions_base implements FrameRenderable {
63
63
  #private;
64
- static styles: lit1.CSSResult[];
64
+ static styles: lit0.CSSResult[];
65
65
  targetSelector: string;
66
66
  set target(value: string);
67
67
  wordStyle: string;
@@ -0,0 +1,50 @@
1
+ import { MediaSample, SampleBuffer } from "../SampleBuffer.js";
2
+ import * as mediabunny0 from "mediabunny";
3
+ import { AudioSampleSink, InputAudioTrack, InputTrack, InputVideoTrack, VideoSampleSink } from "mediabunny";
4
+
5
+ //#region src/elements/EFMedia/BufferedSeekingInput.d.ts
6
+ interface BufferedSeekingInputOptions {
7
+ videoBufferSize?: number;
8
+ audioBufferSize?: number;
9
+ /**
10
+ * Timeline offset in milliseconds to map user timeline to media timeline.
11
+ * Applied during seeking to handle media that doesn't start at 0ms.
12
+ */
13
+ startTimeOffsetMs?: number;
14
+ }
15
+ declare class BufferedSeekingInput {
16
+ #private;
17
+ private input;
18
+ private trackIterators;
19
+ private trackBuffers;
20
+ private options;
21
+ private trackIteratorCreationPromises;
22
+ private trackSeekPromises;
23
+ /**
24
+ * Timeline offset in milliseconds to map user timeline to media timeline.
25
+ * Applied during seeking to handle media that doesn't start at 0ms.
26
+ */
27
+ private readonly startTimeOffsetMs;
28
+ constructor(arrayBuffer: ArrayBuffer, options?: BufferedSeekingInputOptions);
29
+ getBufferSize(trackId: number): number;
30
+ getBufferContents(trackId: number): readonly MediaSample[];
31
+ getBufferTimestamps(trackId: number): number[];
32
+ clearBuffer(trackId: number): void;
33
+ computeDuration(): Promise<number>;
34
+ getTrack(trackId: number): Promise<InputTrack>;
35
+ getAudioTrack(trackId: number): Promise<InputAudioTrack>;
36
+ getVideoTrack(trackId: number): Promise<InputVideoTrack>;
37
+ getFirstVideoTrack(): Promise<InputVideoTrack | undefined>;
38
+ getFirstAudioTrack(): Promise<InputAudioTrack | undefined>;
39
+ getTrackIterator(track: InputTrack): AsyncIterator<MediaSample, any, any>;
40
+ createTrackSampleSink(track: InputTrack): AudioSampleSink | VideoSampleSink;
41
+ createTrackIterator(track: InputTrack): AsyncGenerator<mediabunny0.VideoSample, void, unknown> | AsyncGenerator<mediabunny0.AudioSample, void, unknown>;
42
+ createTrackBuffer(track: InputTrack): SampleBuffer;
43
+ getTrackBuffer(track: InputTrack): SampleBuffer;
44
+ seek(trackId: number, timeMs: number): Promise<MediaSample | undefined>;
45
+ private resetIterator;
46
+ private seekSafe;
47
+ }
48
+ //#endregion
49
+ export { BufferedSeekingInput };
50
+ //# sourceMappingURL=BufferedSeekingInput.d.ts.map
@@ -127,18 +127,19 @@ var BufferedSeekingInput = class {
127
127
  } catch (_error) {}
128
128
  this.trackIterators.delete(track.id);
129
129
  }
130
- #seekLock;
130
+ #seekLocks = /* @__PURE__ */ new Map();
131
131
  async seekSafe(trackId, timeMs) {
132
132
  return withSpan("bufferedInput.seekSafe", {
133
133
  trackId,
134
134
  timeMs
135
135
  }, void 0, async (span) => {
136
- if (this.#seekLock) {
136
+ const existingLock = this.#seekLocks.get(trackId);
137
+ if (existingLock) {
137
138
  span.setAttribute("waitedForSeekLock", true);
138
- await this.#seekLock.promise;
139
+ await existingLock.promise;
139
140
  }
140
141
  const seekLock = Promise.withResolvers();
141
- this.#seekLock = seekLock;
142
+ this.#seekLocks.set(trackId, seekLock);
142
143
  try {
143
144
  const track = await this.getTrack(trackId);
144
145
  span.setAttribute("trackType", track.type);
@@ -225,7 +226,7 @@ var BufferedSeekingInput = class {
225
226
  }
226
227
  throw new NoSample(`Sample not found for time ${timeMs} in ${track.type} track ${trackId}`);
227
228
  } finally {
228
- this.#seekLock = void 0;
229
+ this.#seekLocks.delete(trackId);
229
230
  seekLock.resolve();
230
231
  }
231
232
  });
@@ -1 +1 @@
1
- {"version":3,"file":"BufferedSeekingInput.js","names":["defaultOptions: BufferedSeekingInputOptions","track","bufferSize","#seekLock","contents"],"sources":["../../../src/elements/EFMedia/BufferedSeekingInput.ts"],"sourcesContent":["import {\n AudioSampleSink,\n BufferSource,\n Input,\n InputAudioTrack,\n type InputTrack,\n InputVideoTrack,\n MP4,\n VideoSampleSink,\n} from \"mediabunny\";\nimport { withSpan } from \"../../otel/tracingHelpers.js\";\nimport { type MediaSample, SampleBuffer } from \"../SampleBuffer\";\nimport { roundToMilliseconds } from \"./shared/PrecisionUtils\";\nimport { withTimeout, DEFAULT_MEDIABUNNY_TIMEOUT_MS } from \"./shared/timeoutUtils\";\n\ninterface BufferedSeekingInputOptions {\n videoBufferSize?: number;\n audioBufferSize?: number;\n /**\n * Timeline offset in milliseconds to map user timeline to media timeline.\n * Applied during seeking to handle media that doesn't start at 0ms.\n */\n startTimeOffsetMs?: number;\n}\n\nconst defaultOptions: BufferedSeekingInputOptions = {\n videoBufferSize: 30,\n audioBufferSize: 100,\n startTimeOffsetMs: 0,\n};\n\nexport class NoSample extends RangeError {}\n\nexport class ConcurrentSeekError extends RangeError {}\n\nexport class BufferedSeekingInput {\n private input: Input;\n private trackIterators: Map<number, AsyncIterator<MediaSample>> = new Map();\n private trackBuffers: Map<number, SampleBuffer> = new Map();\n private options: BufferedSeekingInputOptions;\n // Separate locks for different operation types to prevent unnecessary blocking\n private trackIteratorCreationPromises: Map<number, Promise<any>> = new Map();\n private trackSeekPromises: Map<number, Promise<any>> = new Map();\n\n /**\n * Timeline offset in milliseconds to map user timeline to media timeline.\n * Applied during seeking to handle media that doesn't start at 0ms.\n */\n private readonly startTimeOffsetMs: number;\n\n constructor(arrayBuffer: ArrayBuffer, options?: BufferedSeekingInputOptions) {\n const bufferSource = new BufferSource(arrayBuffer);\n const input = new Input({\n source: bufferSource,\n formats: [MP4],\n });\n this.input = input;\n this.options = { ...defaultOptions, ...options };\n this.startTimeOffsetMs = this.options.startTimeOffsetMs ?? 0;\n }\n\n // Buffer inspection API for testing\n getBufferSize(trackId: number): number {\n const buffer = this.trackBuffers.get(trackId);\n return buffer ? buffer.length : 0;\n }\n\n getBufferContents(trackId: number): readonly MediaSample[] {\n const buffer = this.trackBuffers.get(trackId);\n return buffer ? Object.freeze([...buffer.getContents()]) : [];\n }\n\n getBufferTimestamps(trackId: number): number[] {\n const contents = this.getBufferContents(trackId);\n return contents.map((sample) => sample.timestamp || 0);\n }\n\n clearBuffer(trackId: number): void {\n const buffer = this.trackBuffers.get(trackId);\n if (buffer) {\n buffer.clear();\n }\n }\n\n computeDuration() {\n return this.input.computeDuration();\n }\n\n async getTrack(trackId: number) {\n const tracks = await withTimeout(\n this.input.getTracks(),\n 5000,\n \"BufferedSeekingInput.getTracks\",\n );\n const track = tracks.find((track) => track.id === trackId);\n if (!track) {\n throw new Error(`Track ${trackId} not found`);\n }\n return track;\n }\n\n async getAudioTrack(trackId: number) {\n const tracks = await withTimeout(\n this.input.getAudioTracks(),\n 5000,\n \"BufferedSeekingInput.getAudioTracks\",\n );\n const track = tracks.find((track) => track.id === trackId && track.type === \"audio\");\n if (!track) {\n throw new Error(`Track ${trackId} not found`);\n }\n return track;\n }\n\n async getVideoTrack(trackId: number) {\n const tracks = await withTimeout(\n this.input.getVideoTracks(),\n 5000,\n \"BufferedSeekingInput.getVideoTracks\",\n );\n const track = tracks.find((track) => track.id === trackId && track.type === \"video\");\n if (!track) {\n throw new Error(`Track ${trackId} not found`);\n }\n return track;\n }\n\n async getFirstVideoTrack() {\n const tracks = await withTimeout(\n this.input.getVideoTracks(),\n 5000,\n \"BufferedSeekingInput.getFirstVideoTrack\",\n );\n return tracks[0];\n }\n\n async getFirstAudioTrack() {\n const tracks = await withTimeout(\n this.input.getAudioTracks(),\n 5000,\n \"BufferedSeekingInput.getFirstAudioTrack\",\n );\n return tracks[0];\n }\n\n getTrackIterator(track: InputTrack) {\n if (this.trackIterators.has(track.id)) {\n // biome-ignore lint/style/noNonNullAssertion: we know the map has the key\n return this.trackIterators.get(track.id)!;\n }\n\n const trackIterator = this.createTrackIterator(track);\n\n this.trackIterators.set(track.id, trackIterator);\n\n return trackIterator;\n }\n\n createTrackSampleSink(track: InputTrack) {\n if (track instanceof InputAudioTrack) {\n return new AudioSampleSink(track);\n }\n if (track instanceof InputVideoTrack) {\n return new VideoSampleSink(track);\n }\n throw new Error(`Unsupported track type ${track.type}`);\n }\n\n createTrackIterator(track: InputTrack) {\n const sampleSink = this.createTrackSampleSink(track);\n return sampleSink.samples();\n }\n\n createTrackBuffer(track: InputTrack) {\n if (track.type === \"audio\") {\n const bufferSize = this.options.audioBufferSize;\n const sampleBuffer = new SampleBuffer(bufferSize);\n return sampleBuffer;\n }\n const bufferSize = this.options.videoBufferSize;\n const sampleBuffer = new SampleBuffer(bufferSize);\n return sampleBuffer;\n }\n\n getTrackBuffer(track: InputTrack) {\n const maybeTrackBuffer = this.trackBuffers.get(track.id);\n\n if (maybeTrackBuffer) {\n return maybeTrackBuffer;\n }\n\n const trackBuffer = this.createTrackBuffer(track);\n this.trackBuffers.set(track.id, trackBuffer);\n return trackBuffer;\n }\n\n async seek(trackId: number, timeMs: number) {\n return withSpan(\n \"bufferedInput.seek\",\n {\n trackId,\n timeMs,\n startTimeOffsetMs: this.startTimeOffsetMs,\n },\n undefined,\n async (span) => {\n // Apply timeline offset to map user timeline to media timeline\n const mediaTimeMs = timeMs + this.startTimeOffsetMs;\n\n // Round using consistent precision handling\n const roundedMediaTimeMs = roundToMilliseconds(mediaTimeMs);\n span.setAttribute(\"roundedMediaTimeMs\", roundedMediaTimeMs);\n\n // Serialize seek operations per track (but don't block iterator creation)\n const existingSeek = this.trackSeekPromises.get(trackId);\n if (existingSeek) {\n span.setAttribute(\"waitedForExistingSeek\", true);\n await existingSeek;\n }\n\n const seekPromise = this.seekSafe(trackId, roundedMediaTimeMs);\n this.trackSeekPromises.set(trackId, seekPromise);\n\n try {\n const result = await seekPromise;\n return result;\n } finally {\n this.trackSeekPromises.delete(trackId);\n }\n },\n );\n }\n\n private async resetIterator(track: InputTrack) {\n const trackBuffer = this.trackBuffers.get(track.id);\n trackBuffer?.clear();\n // Clean up iterator safely - wait for any ongoing iterator creation\n const ongoingIteratorCreation = this.trackIteratorCreationPromises.get(track.id);\n if (ongoingIteratorCreation) {\n await ongoingIteratorCreation;\n }\n\n const iterator = this.trackIterators.get(track.id);\n if (iterator) {\n try {\n await iterator.return?.();\n } catch (_error) {\n // Iterator cleanup failed, continue anyway\n }\n }\n this.trackIterators.delete(track.id);\n }\n\n #seekLock?: PromiseWithResolvers<void>;\n\n private async seekSafe(trackId: number, timeMs: number) {\n return withSpan(\n \"bufferedInput.seekSafe\",\n {\n trackId,\n timeMs,\n },\n undefined,\n async (span) => {\n if (this.#seekLock) {\n span.setAttribute(\"waitedForSeekLock\", true);\n await this.#seekLock.promise;\n }\n const seekLock = Promise.withResolvers<void>();\n this.#seekLock = seekLock;\n\n try {\n const track = await this.getTrack(trackId);\n span.setAttribute(\"trackType\", track.type);\n\n const trackBuffer = this.getTrackBuffer(track);\n\n const roundedTimeMs = roundToMilliseconds(timeMs);\n\n // Add timeout to detect if getFirstTimestamp hangs\n const timeoutMs = 5000;\n const firstTimestamp = await Promise.race([\n track.getFirstTimestamp(),\n new Promise<number>((_, reject) =>\n setTimeout(\n () => reject(new Error(`getFirstTimestamp timeout after ${timeoutMs}ms`)),\n timeoutMs,\n ),\n ),\n ]);\n const firstTimestampMs = roundToMilliseconds(firstTimestamp * 1000);\n\n span.setAttribute(\"firstTimestampMs\", firstTimestampMs);\n\n // Use tolerance for floating point comparison (0.01ms tolerance)\n // This handles rounding errors like 20916.666 vs 20916.667\n const PRECISION_TOLERANCE_MS = 0.01;\n if (roundedTimeMs < firstTimestampMs - PRECISION_TOLERANCE_MS) {\n console.error(\n `[BufferedSeekingInput.seekSafe] OUT_OF_BOUNDS trackId=${trackId} roundedTimeMs=${roundedTimeMs} firstTimestampMs=${firstTimestampMs}`,\n );\n throw new NoSample(\n `Seeking outside bounds of input ${roundedTimeMs} < ${firstTimestampMs}`,\n );\n }\n\n // Check if we need to reset iterator for seeks outside current buffer range\n const bufferContents = trackBuffer.getContents();\n span.setAttribute(\"bufferContentsLength\", bufferContents.length);\n\n if (bufferContents.length > 0) {\n const bufferStartMs = roundToMilliseconds(trackBuffer.firstTimestamp * 1000);\n span.setAttribute(\"bufferStartMs\", bufferStartMs);\n\n if (roundedTimeMs < bufferStartMs) {\n span.setAttribute(\"resetIterator\", true);\n await this.resetIterator(track);\n }\n }\n\n const alreadyInBuffer = trackBuffer.find(timeMs);\n if (alreadyInBuffer) {\n span.setAttribute(\"foundInBuffer\", true);\n span.setAttribute(\"bufferSize\", trackBuffer.length);\n const contents = trackBuffer.getContents();\n if (contents.length > 0) {\n span.setAttribute(\n \"bufferTimestamps\",\n contents\n .map((s) => Math.round((s.timestamp || 0) * 1000))\n .slice(0, 10)\n .join(\",\"),\n );\n }\n return alreadyInBuffer;\n }\n\n // Buffer miss - record buffer state\n span.setAttribute(\"foundInBuffer\", false);\n span.setAttribute(\"bufferSize\", trackBuffer.length);\n span.setAttribute(\"requestedTimeMs\", Math.round(timeMs));\n\n const contents = trackBuffer.getContents();\n if (contents.length > 0) {\n const firstSample = contents[0];\n const lastSample = contents[contents.length - 1];\n if (firstSample && lastSample) {\n const bufferStartMs = Math.round((firstSample.timestamp || 0) * 1000);\n const bufferEndMs = Math.round(\n ((lastSample.timestamp || 0) + (lastSample.duration || 0)) * 1000,\n );\n span.setAttribute(\"bufferStartMs\", bufferStartMs);\n span.setAttribute(\"bufferEndMs\", bufferEndMs);\n span.setAttribute(\"bufferRangeMs\", `${bufferStartMs}-${bufferEndMs}`);\n }\n }\n\n const iterator = this.getTrackIterator(track);\n let iterationCount = 0;\n const decodeStart = performance.now();\n\n while (true) {\n iterationCount++;\n const iterStart = performance.now();\n const { done, value: decodedSample } = await withTimeout(\n iterator.next(),\n DEFAULT_MEDIABUNNY_TIMEOUT_MS,\n `iterator.next() for ${track.type} track ${trackId} iteration ${iterationCount}`,\n );\n const iterEnd = performance.now();\n\n // Record individual iteration timing for first 5 iterations\n if (iterationCount <= 5) {\n span.setAttribute(\n `iter${iterationCount}Ms`,\n Math.round((iterEnd - iterStart) * 100) / 100,\n );\n }\n\n if (decodedSample) {\n trackBuffer.push(decodedSample);\n if (iterationCount <= 5) {\n span.setAttribute(\n `iter${iterationCount}Timestamp`,\n Math.round((decodedSample.timestamp || 0) * 1000),\n );\n }\n }\n\n const foundSample = trackBuffer.find(roundedTimeMs);\n if (foundSample) {\n const decodeEnd = performance.now();\n span.setAttribute(\"iterationCount\", iterationCount);\n span.setAttribute(\"decodeMs\", Math.round((decodeEnd - decodeStart) * 100) / 100);\n span.setAttribute(\n \"avgIterMs\",\n Math.round(((decodeEnd - decodeStart) / iterationCount) * 100) / 100,\n );\n span.setAttribute(\"foundSample\", true);\n span.setAttribute(\"foundTimestamp\", Math.round((foundSample.timestamp || 0) * 1000));\n return foundSample;\n }\n if (done) {\n break;\n }\n }\n\n span.setAttribute(\"iterationCount\", iterationCount);\n span.setAttribute(\"reachedEnd\", true);\n\n // Check if we're seeking to the exact end of the track (legitimate use case)\n const finalBufferContents = trackBuffer.getContents();\n if (finalBufferContents.length > 0) {\n const lastSample = finalBufferContents[finalBufferContents.length - 1];\n const lastSampleEndMs = roundToMilliseconds(\n ((lastSample?.timestamp || 0) + (lastSample?.duration || 0)) * 1000,\n );\n\n // Only return last sample if seeking to exactly the track duration\n // (end of video) AND we have the final segment loaded\n const trackDurationMs = (await track.computeDuration()) * 1000;\n const isSeekingToTrackEnd =\n roundToMilliseconds(timeMs) === roundToMilliseconds(trackDurationMs);\n const isAtEndOfTrack = roundToMilliseconds(timeMs) >= lastSampleEndMs;\n\n if (isSeekingToTrackEnd && isAtEndOfTrack) {\n span.setAttribute(\"returnedLastSample\", true);\n return lastSample;\n }\n }\n\n // For all other cases (seeking within track but outside buffer range), throw error\n // The caller should ensure the correct segment is loaded before seeking\n throw new NoSample(\n `Sample not found for time ${timeMs} in ${track.type} track ${trackId}`,\n );\n } finally {\n this.#seekLock = undefined;\n seekLock.resolve();\n }\n },\n );\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"BufferedSeekingInput.js","names":["defaultOptions: BufferedSeekingInputOptions","track","bufferSize","#seekLocks","contents"],"sources":["../../../src/elements/EFMedia/BufferedSeekingInput.ts"],"sourcesContent":["import {\n AudioSampleSink,\n BufferSource,\n Input,\n InputAudioTrack,\n type InputTrack,\n InputVideoTrack,\n MP4,\n VideoSampleSink,\n} from \"mediabunny\";\nimport { withSpan } from \"../../otel/tracingHelpers.js\";\nimport { type MediaSample, SampleBuffer } from \"../SampleBuffer\";\nimport { roundToMilliseconds } from \"./shared/PrecisionUtils\";\nimport { withTimeout, DEFAULT_MEDIABUNNY_TIMEOUT_MS } from \"./shared/timeoutUtils\";\n\ninterface BufferedSeekingInputOptions {\n videoBufferSize?: number;\n audioBufferSize?: number;\n /**\n * Timeline offset in milliseconds to map user timeline to media timeline.\n * Applied during seeking to handle media that doesn't start at 0ms.\n */\n startTimeOffsetMs?: number;\n}\n\nconst defaultOptions: BufferedSeekingInputOptions = {\n videoBufferSize: 30,\n audioBufferSize: 100,\n startTimeOffsetMs: 0,\n};\n\nexport class NoSample extends RangeError {}\n\nexport class ConcurrentSeekError extends RangeError {}\n\nexport class BufferedSeekingInput {\n private input: Input;\n private trackIterators: Map<number, AsyncIterator<MediaSample>> = new Map();\n private trackBuffers: Map<number, SampleBuffer> = new Map();\n private options: BufferedSeekingInputOptions;\n // Separate locks for different operation types to prevent unnecessary blocking\n private trackIteratorCreationPromises: Map<number, Promise<any>> = new Map();\n private trackSeekPromises: Map<number, Promise<any>> = new Map();\n\n /**\n * Timeline offset in milliseconds to map user timeline to media timeline.\n * Applied during seeking to handle media that doesn't start at 0ms.\n */\n private readonly startTimeOffsetMs: number;\n\n constructor(arrayBuffer: ArrayBuffer, options?: BufferedSeekingInputOptions) {\n const bufferSource = new BufferSource(arrayBuffer);\n const input = new Input({\n source: bufferSource,\n formats: [MP4],\n });\n this.input = input;\n this.options = { ...defaultOptions, ...options };\n this.startTimeOffsetMs = this.options.startTimeOffsetMs ?? 0;\n }\n\n // Buffer inspection API for testing\n getBufferSize(trackId: number): number {\n const buffer = this.trackBuffers.get(trackId);\n return buffer ? buffer.length : 0;\n }\n\n getBufferContents(trackId: number): readonly MediaSample[] {\n const buffer = this.trackBuffers.get(trackId);\n return buffer ? Object.freeze([...buffer.getContents()]) : [];\n }\n\n getBufferTimestamps(trackId: number): number[] {\n const contents = this.getBufferContents(trackId);\n return contents.map((sample) => sample.timestamp || 0);\n }\n\n clearBuffer(trackId: number): void {\n const buffer = this.trackBuffers.get(trackId);\n if (buffer) {\n buffer.clear();\n }\n }\n\n computeDuration() {\n return this.input.computeDuration();\n }\n\n async getTrack(trackId: number) {\n const tracks = await withTimeout(\n this.input.getTracks(),\n 5000,\n \"BufferedSeekingInput.getTracks\",\n );\n const track = tracks.find((track) => track.id === trackId);\n if (!track) {\n throw new Error(`Track ${trackId} not found`);\n }\n return track;\n }\n\n async getAudioTrack(trackId: number) {\n const tracks = await withTimeout(\n this.input.getAudioTracks(),\n 5000,\n \"BufferedSeekingInput.getAudioTracks\",\n );\n const track = tracks.find((track) => track.id === trackId && track.type === \"audio\");\n if (!track) {\n throw new Error(`Track ${trackId} not found`);\n }\n return track;\n }\n\n async getVideoTrack(trackId: number) {\n const tracks = await withTimeout(\n this.input.getVideoTracks(),\n 5000,\n \"BufferedSeekingInput.getVideoTracks\",\n );\n const track = tracks.find((track) => track.id === trackId && track.type === \"video\");\n if (!track) {\n throw new Error(`Track ${trackId} not found`);\n }\n return track;\n }\n\n async getFirstVideoTrack() {\n const tracks = await withTimeout(\n this.input.getVideoTracks(),\n 5000,\n \"BufferedSeekingInput.getFirstVideoTrack\",\n );\n return tracks[0];\n }\n\n async getFirstAudioTrack() {\n const tracks = await withTimeout(\n this.input.getAudioTracks(),\n 5000,\n \"BufferedSeekingInput.getFirstAudioTrack\",\n );\n return tracks[0];\n }\n\n getTrackIterator(track: InputTrack) {\n if (this.trackIterators.has(track.id)) {\n // biome-ignore lint/style/noNonNullAssertion: we know the map has the key\n return this.trackIterators.get(track.id)!;\n }\n\n const trackIterator = this.createTrackIterator(track);\n\n this.trackIterators.set(track.id, trackIterator);\n\n return trackIterator;\n }\n\n createTrackSampleSink(track: InputTrack) {\n if (track instanceof InputAudioTrack) {\n return new AudioSampleSink(track);\n }\n if (track instanceof InputVideoTrack) {\n return new VideoSampleSink(track);\n }\n throw new Error(`Unsupported track type ${track.type}`);\n }\n\n createTrackIterator(track: InputTrack) {\n const sampleSink = this.createTrackSampleSink(track);\n return sampleSink.samples();\n }\n\n createTrackBuffer(track: InputTrack) {\n if (track.type === \"audio\") {\n const bufferSize = this.options.audioBufferSize;\n const sampleBuffer = new SampleBuffer(bufferSize);\n return sampleBuffer;\n }\n const bufferSize = this.options.videoBufferSize;\n const sampleBuffer = new SampleBuffer(bufferSize);\n return sampleBuffer;\n }\n\n getTrackBuffer(track: InputTrack) {\n const maybeTrackBuffer = this.trackBuffers.get(track.id);\n\n if (maybeTrackBuffer) {\n return maybeTrackBuffer;\n }\n\n const trackBuffer = this.createTrackBuffer(track);\n this.trackBuffers.set(track.id, trackBuffer);\n return trackBuffer;\n }\n\n async seek(trackId: number, timeMs: number) {\n return withSpan(\n \"bufferedInput.seek\",\n {\n trackId,\n timeMs,\n startTimeOffsetMs: this.startTimeOffsetMs,\n },\n undefined,\n async (span) => {\n // Apply timeline offset to map user timeline to media timeline\n const mediaTimeMs = timeMs + this.startTimeOffsetMs;\n\n // Round using consistent precision handling\n const roundedMediaTimeMs = roundToMilliseconds(mediaTimeMs);\n span.setAttribute(\"roundedMediaTimeMs\", roundedMediaTimeMs);\n\n // Serialize seek operations per track (but don't block iterator creation)\n const existingSeek = this.trackSeekPromises.get(trackId);\n if (existingSeek) {\n span.setAttribute(\"waitedForExistingSeek\", true);\n await existingSeek;\n }\n\n const seekPromise = this.seekSafe(trackId, roundedMediaTimeMs);\n this.trackSeekPromises.set(trackId, seekPromise);\n\n try {\n const result = await seekPromise;\n return result;\n } finally {\n this.trackSeekPromises.delete(trackId);\n }\n },\n );\n }\n\n private async resetIterator(track: InputTrack) {\n const trackBuffer = this.trackBuffers.get(track.id);\n trackBuffer?.clear();\n // Clean up iterator safely - wait for any ongoing iterator creation\n const ongoingIteratorCreation = this.trackIteratorCreationPromises.get(track.id);\n if (ongoingIteratorCreation) {\n await ongoingIteratorCreation;\n }\n\n const iterator = this.trackIterators.get(track.id);\n if (iterator) {\n try {\n await iterator.return?.();\n } catch (_error) {\n // Iterator cleanup failed, continue anyway\n }\n }\n this.trackIterators.delete(track.id);\n }\n\n #seekLocks = new Map<number, PromiseWithResolvers<void>>();\n\n private async seekSafe(trackId: number, timeMs: number) {\n return withSpan(\n \"bufferedInput.seekSafe\",\n {\n trackId,\n timeMs,\n },\n undefined,\n async (span) => {\n const existingLock = this.#seekLocks.get(trackId);\n if (existingLock) {\n span.setAttribute(\"waitedForSeekLock\", true);\n await existingLock.promise;\n }\n const seekLock = Promise.withResolvers<void>();\n this.#seekLocks.set(trackId, seekLock);\n\n try {\n const track = await this.getTrack(trackId);\n span.setAttribute(\"trackType\", track.type);\n\n const trackBuffer = this.getTrackBuffer(track);\n\n const roundedTimeMs = roundToMilliseconds(timeMs);\n\n // Add timeout to detect if getFirstTimestamp hangs\n const timeoutMs = 5000;\n const firstTimestamp = await Promise.race([\n track.getFirstTimestamp(),\n new Promise<number>((_, reject) =>\n setTimeout(\n () => reject(new Error(`getFirstTimestamp timeout after ${timeoutMs}ms`)),\n timeoutMs,\n ),\n ),\n ]);\n const firstTimestampMs = roundToMilliseconds(firstTimestamp * 1000);\n\n span.setAttribute(\"firstTimestampMs\", firstTimestampMs);\n\n // Use tolerance for floating point comparison (0.01ms tolerance)\n // This handles rounding errors like 20916.666 vs 20916.667\n const PRECISION_TOLERANCE_MS = 0.01;\n if (roundedTimeMs < firstTimestampMs - PRECISION_TOLERANCE_MS) {\n console.error(\n `[BufferedSeekingInput.seekSafe] OUT_OF_BOUNDS trackId=${trackId} roundedTimeMs=${roundedTimeMs} firstTimestampMs=${firstTimestampMs}`,\n );\n throw new NoSample(\n `Seeking outside bounds of input ${roundedTimeMs} < ${firstTimestampMs}`,\n );\n }\n\n // Check if we need to reset iterator for seeks outside current buffer range\n const bufferContents = trackBuffer.getContents();\n span.setAttribute(\"bufferContentsLength\", bufferContents.length);\n\n if (bufferContents.length > 0) {\n const bufferStartMs = roundToMilliseconds(trackBuffer.firstTimestamp * 1000);\n span.setAttribute(\"bufferStartMs\", bufferStartMs);\n\n if (roundedTimeMs < bufferStartMs) {\n span.setAttribute(\"resetIterator\", true);\n await this.resetIterator(track);\n }\n }\n\n const alreadyInBuffer = trackBuffer.find(timeMs);\n if (alreadyInBuffer) {\n span.setAttribute(\"foundInBuffer\", true);\n span.setAttribute(\"bufferSize\", trackBuffer.length);\n const contents = trackBuffer.getContents();\n if (contents.length > 0) {\n span.setAttribute(\n \"bufferTimestamps\",\n contents\n .map((s) => Math.round((s.timestamp || 0) * 1000))\n .slice(0, 10)\n .join(\",\"),\n );\n }\n return alreadyInBuffer;\n }\n\n // Buffer miss - record buffer state\n span.setAttribute(\"foundInBuffer\", false);\n span.setAttribute(\"bufferSize\", trackBuffer.length);\n span.setAttribute(\"requestedTimeMs\", Math.round(timeMs));\n\n const contents = trackBuffer.getContents();\n if (contents.length > 0) {\n const firstSample = contents[0];\n const lastSample = contents[contents.length - 1];\n if (firstSample && lastSample) {\n const bufferStartMs = Math.round((firstSample.timestamp || 0) * 1000);\n const bufferEndMs = Math.round(\n ((lastSample.timestamp || 0) + (lastSample.duration || 0)) * 1000,\n );\n span.setAttribute(\"bufferStartMs\", bufferStartMs);\n span.setAttribute(\"bufferEndMs\", bufferEndMs);\n span.setAttribute(\"bufferRangeMs\", `${bufferStartMs}-${bufferEndMs}`);\n }\n }\n\n const iterator = this.getTrackIterator(track);\n let iterationCount = 0;\n const decodeStart = performance.now();\n\n while (true) {\n iterationCount++;\n const iterStart = performance.now();\n const { done, value: decodedSample } = await withTimeout(\n iterator.next(),\n DEFAULT_MEDIABUNNY_TIMEOUT_MS,\n `iterator.next() for ${track.type} track ${trackId} iteration ${iterationCount}`,\n );\n const iterEnd = performance.now();\n\n // Record individual iteration timing for first 5 iterations\n if (iterationCount <= 5) {\n span.setAttribute(\n `iter${iterationCount}Ms`,\n Math.round((iterEnd - iterStart) * 100) / 100,\n );\n }\n\n if (decodedSample) {\n trackBuffer.push(decodedSample);\n if (iterationCount <= 5) {\n span.setAttribute(\n `iter${iterationCount}Timestamp`,\n Math.round((decodedSample.timestamp || 0) * 1000),\n );\n }\n }\n\n const foundSample = trackBuffer.find(roundedTimeMs);\n if (foundSample) {\n const decodeEnd = performance.now();\n span.setAttribute(\"iterationCount\", iterationCount);\n span.setAttribute(\"decodeMs\", Math.round((decodeEnd - decodeStart) * 100) / 100);\n span.setAttribute(\n \"avgIterMs\",\n Math.round(((decodeEnd - decodeStart) / iterationCount) * 100) / 100,\n );\n span.setAttribute(\"foundSample\", true);\n span.setAttribute(\"foundTimestamp\", Math.round((foundSample.timestamp || 0) * 1000));\n return foundSample;\n }\n if (done) {\n break;\n }\n }\n\n span.setAttribute(\"iterationCount\", iterationCount);\n span.setAttribute(\"reachedEnd\", true);\n\n // Check if we're seeking to the exact end of the track (legitimate use case)\n const finalBufferContents = trackBuffer.getContents();\n if (finalBufferContents.length > 0) {\n const lastSample = finalBufferContents[finalBufferContents.length - 1];\n const lastSampleEndMs = roundToMilliseconds(\n ((lastSample?.timestamp || 0) + (lastSample?.duration || 0)) * 1000,\n );\n\n // Only return last sample if seeking to exactly the track duration\n // (end of video) AND we have the final segment loaded\n const trackDurationMs = (await track.computeDuration()) * 1000;\n const isSeekingToTrackEnd =\n roundToMilliseconds(timeMs) === roundToMilliseconds(trackDurationMs);\n const isAtEndOfTrack = roundToMilliseconds(timeMs) >= lastSampleEndMs;\n\n if (isSeekingToTrackEnd && isAtEndOfTrack) {\n span.setAttribute(\"returnedLastSample\", true);\n return lastSample;\n }\n }\n\n // For all other cases (seeking within track but outside buffer range), throw error\n // The caller should ensure the correct segment is loaded before seeking\n throw new NoSample(\n `Sample not found for time ${timeMs} in ${track.type} track ${trackId}`,\n );\n } finally {\n this.#seekLocks.delete(trackId);\n seekLock.resolve();\n }\n },\n );\n }\n}\n"],"mappings":"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"}
@@ -42,53 +42,43 @@ var CachedFetcher = class {
42
42
  }
43
43
  span.setAttribute("cacheHit", false);
44
44
  const promise = globalRequestDeduplicator.executeRequest(cacheKey, async () => {
45
- try {
46
- const response = await this.#fetchFn(url, {
47
- headers,
48
- signal
49
- });
50
- const contentType = response.headers.get("content-type");
51
- if (responseType === "json") {
52
- if (!response.ok || contentType && !contentType.includes("application/json") && !contentType.includes("text/json")) {
53
- const text = await response.clone().text();
54
- if (!response.ok) throw new Error(`Failed to fetch: ${response.status} ${text.substring(0, 100)}`);
55
- throw new Error(`Expected JSON but got ${contentType}: ${text.substring(0, 100)}`);
56
- }
57
- try {
58
- return await response.json();
59
- } catch (error) {
60
- throw new Error(`Failed to parse JSON response: ${error instanceof Error ? error.message : String(error)}`);
61
- }
62
- }
63
- if (!response.ok) {
45
+ const response = await this.#fetchFn(url, { headers });
46
+ const contentType = response.headers.get("content-type");
47
+ if (responseType === "json") {
48
+ if (!response.ok || contentType && !contentType.includes("application/json") && !contentType.includes("text/json")) {
64
49
  const text = await response.clone().text();
65
- throw new Error(`Failed to fetch: ${response.status} ${text.substring(0, 100)}`);
50
+ if (!response.ok) throw new Error(`Failed to fetch: ${response.status} ${text.substring(0, 100)}`);
51
+ throw new Error(`Expected JSON but got ${contentType}: ${text.substring(0, 100)}`);
52
+ }
53
+ try {
54
+ return await response.json();
55
+ } catch (error) {
56
+ throw new Error(`Failed to parse JSON response: ${error instanceof Error ? error.message : String(error)}`);
66
57
  }
67
- const buffer = await response.arrayBuffer();
68
- span.setAttribute("sizeBytes", buffer.byteLength);
69
- return buffer;
70
- } catch (error) {
71
- if (error instanceof DOMException && error.name === "AbortError") mediaCache.delete(cacheKey);
72
- throw error;
73
58
  }
59
+ if (!response.ok) {
60
+ const text = await response.clone().text();
61
+ throw new Error(`Failed to fetch: ${response.status} ${text.substring(0, 100)}`);
62
+ }
63
+ const buffer = await response.arrayBuffer();
64
+ span.setAttribute("sizeBytes", buffer.byteLength);
65
+ return buffer;
74
66
  });
75
67
  mediaCache.set(cacheKey, promise);
76
- promise.catch((error) => {
77
- if (error instanceof DOMException && error.name === "AbortError") mediaCache.delete(cacheKey);
78
- });
79
68
  if (signal) return this.#handleAbortForCachedRequest(promise, signal);
80
69
  return promise;
81
70
  });
82
71
  }
83
72
  #handleAbortForCachedRequest(promise, signal) {
84
73
  if (signal.aborted) throw new DOMException("Aborted", "AbortError");
74
+ let rejectAbort;
85
75
  const abortPromise = new Promise((_, reject) => {
86
- signal.addEventListener("abort", () => {
87
- reject(new DOMException("Aborted", "AbortError"));
88
- });
76
+ rejectAbort = reject;
89
77
  });
78
+ const onAbort = () => rejectAbort(new DOMException("Aborted", "AbortError"));
79
+ signal.addEventListener("abort", onAbort, { once: true });
90
80
  abortPromise.catch(() => {});
91
- const racePromise = Promise.race([promise, abortPromise]);
81
+ const racePromise = Promise.race([promise.finally(() => signal.removeEventListener("abort", onAbort)), abortPromise]);
92
82
  racePromise.catch(() => {});
93
83
  return racePromise;
94
84
  }
@@ -1 +1 @@
1
- {"version":3,"file":"CachedFetcher.js","names":["#fetchFn","#fetchWithCache","#handleAbortForCachedRequest"],"sources":["../../../src/elements/EFMedia/CachedFetcher.ts"],"sourcesContent":["import { withSpan } from \"../../otel/tracingHelpers.js\";\nimport { RequestDeduplicator } from \"../../transcoding/cache/RequestDeduplicator.js\";\nimport { SizeAwareLRUCache } from \"../../utils/LRUCache.js\";\n\nexport const mediaCache = new SizeAwareLRUCache<string>(100 * 1024 * 1024);\nexport const globalRequestDeduplicator = new RequestDeduplicator();\n\nexport interface FetchFn {\n (\n url: string,\n init?: { headers?: Record<string, string>; signal?: AbortSignal },\n ): Promise<Response>;\n}\n\nexport class CachedFetcher {\n #fetchFn: FetchFn;\n\n constructor(fetchFn: FetchFn) {\n this.#fetchFn = fetchFn;\n }\n\n has(key: string): boolean {\n return mediaCache.has(key);\n }\n\n async fetchArrayBuffer(url: string, signal?: AbortSignal): Promise<ArrayBuffer> {\n if (signal?.aborted) {\n throw new DOMException(\"Aborted\", \"AbortError\");\n }\n return this.#fetchWithCache(url, { responseType: \"arrayBuffer\", signal });\n }\n\n async fetchJson(url: string, signal?: AbortSignal): Promise<any> {\n if (signal?.aborted) {\n throw new DOMException(\"Aborted\", \"AbortError\");\n }\n return this.#fetchWithCache(url, { responseType: \"json\", signal });\n }\n\n async #fetchWithCache(\n url: string,\n options: {\n responseType: \"arrayBuffer\" | \"json\";\n headers?: Record<string, string>;\n signal?: AbortSignal;\n },\n ): Promise<any> {\n return withSpan(\n \"cachedFetcher.fetchWithCache\",\n {\n url: url.length > 100 ? `${url.substring(0, 100)}...` : url,\n responseType: options.responseType,\n },\n undefined,\n async (span) => {\n const { responseType, headers, signal } = options;\n\n const cacheKey = headers ? `${url}:${JSON.stringify(headers)}` : url;\n\n const cached = mediaCache.get(cacheKey);\n if (cached) {\n span.setAttribute(\"cacheHit\", true);\n if (signal) {\n return this.#handleAbortForCachedRequest(cached, signal);\n }\n return cached;\n }\n\n span.setAttribute(\"cacheHit\", false);\n\n const promise = globalRequestDeduplicator.executeRequest(cacheKey, async () => {\n try {\n const response = await this.#fetchFn(url, { headers, signal });\n const contentType = response.headers.get(\"content-type\");\n\n if (responseType === \"json\") {\n if (\n !response.ok ||\n (contentType &&\n !contentType.includes(\"application/json\") &&\n !contentType.includes(\"text/json\"))\n ) {\n const text = await response.clone().text();\n if (!response.ok) {\n throw new Error(`Failed to fetch: ${response.status} ${text.substring(0, 100)}`);\n }\n throw new Error(`Expected JSON but got ${contentType}: ${text.substring(0, 100)}`);\n }\n try {\n return await response.json();\n } catch (error) {\n throw new Error(\n `Failed to parse JSON response: ${error instanceof Error ? error.message : String(error)}`,\n );\n }\n }\n\n if (!response.ok) {\n const text = await response.clone().text();\n throw new Error(`Failed to fetch: ${response.status} ${text.substring(0, 100)}`);\n }\n\n const buffer = await response.arrayBuffer();\n span.setAttribute(\"sizeBytes\", buffer.byteLength);\n return buffer;\n } catch (error) {\n if (error instanceof DOMException && error.name === \"AbortError\") {\n mediaCache.delete(cacheKey);\n }\n throw error;\n }\n });\n\n mediaCache.set(cacheKey, promise);\n\n promise.catch((error) => {\n if (error instanceof DOMException && error.name === \"AbortError\") {\n mediaCache.delete(cacheKey);\n }\n });\n\n if (signal) {\n return this.#handleAbortForCachedRequest(promise, signal);\n }\n\n return promise;\n },\n );\n }\n\n #handleAbortForCachedRequest<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {\n if (signal.aborted) {\n throw new DOMException(\"Aborted\", \"AbortError\");\n }\n\n const abortPromise = new Promise<never>((_, reject) => {\n signal.addEventListener(\"abort\", () => {\n reject(new DOMException(\"Aborted\", \"AbortError\"));\n });\n });\n abortPromise.catch(() => {});\n\n const racePromise = Promise.race([promise, abortPromise]);\n racePromise.catch(() => {});\n return racePromise;\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"CachedFetcher.js","names":["#fetchFn","#fetchWithCache","#handleAbortForCachedRequest","rejectAbort!: (e: DOMException) => void"],"sources":["../../../src/elements/EFMedia/CachedFetcher.ts"],"sourcesContent":["import { withSpan } from \"../../otel/tracingHelpers.js\";\nimport { RequestDeduplicator } from \"../../transcoding/cache/RequestDeduplicator.js\";\nimport { SizeAwareLRUCache } from \"../../utils/LRUCache.js\";\n\nexport const mediaCache = new SizeAwareLRUCache<string>(100 * 1024 * 1024);\nexport const globalRequestDeduplicator = new RequestDeduplicator();\n\nexport interface FetchFn {\n (\n url: string,\n init?: { headers?: Record<string, string>; signal?: AbortSignal },\n ): Promise<Response>;\n}\n\nexport class CachedFetcher {\n #fetchFn: FetchFn;\n\n constructor(fetchFn: FetchFn) {\n this.#fetchFn = fetchFn;\n }\n\n has(key: string): boolean {\n return mediaCache.has(key);\n }\n\n async fetchArrayBuffer(url: string, signal?: AbortSignal): Promise<ArrayBuffer> {\n if (signal?.aborted) {\n throw new DOMException(\"Aborted\", \"AbortError\");\n }\n return this.#fetchWithCache(url, { responseType: \"arrayBuffer\", signal });\n }\n\n async fetchJson(url: string, signal?: AbortSignal): Promise<any> {\n if (signal?.aborted) {\n throw new DOMException(\"Aborted\", \"AbortError\");\n }\n return this.#fetchWithCache(url, { responseType: \"json\", signal });\n }\n\n async #fetchWithCache(\n url: string,\n options: {\n responseType: \"arrayBuffer\" | \"json\";\n headers?: Record<string, string>;\n signal?: AbortSignal;\n },\n ): Promise<any> {\n return withSpan(\n \"cachedFetcher.fetchWithCache\",\n {\n url: url.length > 100 ? `${url.substring(0, 100)}...` : url,\n responseType: options.responseType,\n },\n undefined,\n async (span) => {\n const { responseType, headers, signal } = options;\n\n const cacheKey = headers ? `${url}:${JSON.stringify(headers)}` : url;\n\n const cached = mediaCache.get(cacheKey);\n if (cached) {\n span.setAttribute(\"cacheHit\", true);\n if (signal) {\n return this.#handleAbortForCachedRequest(cached, signal);\n }\n return cached;\n }\n\n span.setAttribute(\"cacheHit\", false);\n\n // The shared fetch must NOT use the caller's signal. If it did, the first\n // caller to abort would cancel the network request for all other callers\n // (including the QualityUpgradeScheduler, which uses a long-lived signal).\n // Instead, the underlying fetch runs to completion unconditionally.\n // Per-caller abort protection is handled by #handleAbortForCachedRequest.\n const promise = globalRequestDeduplicator.executeRequest(cacheKey, async () => {\n const response = await this.#fetchFn(url, { headers });\n const contentType = response.headers.get(\"content-type\");\n\n if (responseType === \"json\") {\n if (\n !response.ok ||\n (contentType &&\n !contentType.includes(\"application/json\") &&\n !contentType.includes(\"text/json\"))\n ) {\n const text = await response.clone().text();\n if (!response.ok) {\n throw new Error(`Failed to fetch: ${response.status} ${text.substring(0, 100)}`);\n }\n throw new Error(`Expected JSON but got ${contentType}: ${text.substring(0, 100)}`);\n }\n try {\n return await response.json();\n } catch (error) {\n throw new Error(\n `Failed to parse JSON response: ${error instanceof Error ? error.message : String(error)}`,\n );\n }\n }\n\n if (!response.ok) {\n const text = await response.clone().text();\n throw new Error(`Failed to fetch: ${response.status} ${text.substring(0, 100)}`);\n }\n\n const buffer = await response.arrayBuffer();\n span.setAttribute(\"sizeBytes\", buffer.byteLength);\n return buffer;\n });\n\n mediaCache.set(cacheKey, promise);\n\n if (signal) {\n return this.#handleAbortForCachedRequest(promise, signal);\n }\n\n return promise;\n },\n );\n }\n\n #handleAbortForCachedRequest<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {\n if (signal.aborted) {\n throw new DOMException(\"Aborted\", \"AbortError\");\n }\n\n let rejectAbort!: (e: DOMException) => void;\n const abortPromise = new Promise<never>((_, reject) => {\n rejectAbort = reject;\n });\n const onAbort = () => rejectAbort(new DOMException(\"Aborted\", \"AbortError\"));\n // Use { once: true } so the listener self-removes after firing.\n // Also remove explicitly when the data promise settles so long-lived signals\n // (e.g. QualityUpgradeScheduler's signal) don't accumulate listeners.\n signal.addEventListener(\"abort\", onAbort, { once: true });\n abortPromise.catch(() => {});\n\n const racePromise = Promise.race([\n promise.finally(() => signal.removeEventListener(\"abort\", onAbort)),\n abortPromise,\n ]);\n racePromise.catch(() => {});\n return racePromise;\n }\n}\n"],"mappings":"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"}
@@ -3,8 +3,8 @@ import "@editframe/assets";
3
3
 
4
4
  //#region src/elements/EFMedia/SegmentTransport.d.ts
5
5
  interface SegmentTransport {
6
- fetchInitSegment(track: TrackRef, signal: AbortSignal): Promise<ArrayBuffer>;
7
- fetchMediaSegment(segmentId: number, track: TrackRef, signal: AbortSignal): Promise<ArrayBuffer>;
6
+ fetchInitSegment(track: TrackRef, signal?: AbortSignal): Promise<ArrayBuffer>;
7
+ fetchMediaSegment(segmentId: number, track: TrackRef, signal?: AbortSignal): Promise<ArrayBuffer>;
8
8
  isCached(segmentId: number, track: TrackRef): boolean;
9
9
  }
10
10
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"SegmentTransport.js","names":[],"sources":["../../../src/elements/EFMedia/SegmentTransport.ts"],"sourcesContent":["import type { TrackFragmentIndex } from \"@editframe/assets\";\nimport type { RenditionId } from \"../../transcoding/types/index.js\";\nimport type { TrackRef } from \"./SegmentIndex.js\";\nimport type { CachedFetcher } from \"./CachedFetcher.js\";\n\nexport interface SegmentTransport {\n fetchInitSegment(track: TrackRef, signal: AbortSignal): Promise<ArrayBuffer>;\n fetchMediaSegment(segmentId: number, track: TrackRef, signal: AbortSignal): Promise<ArrayBuffer>;\n isCached(segmentId: number, track: TrackRef): boolean;\n}\n\n// ---------------------------------------------------------------------------\n// UrlTransport — each segment has its own URL\n// Used by AssetMediaEngine (via JIT URLs) and JitMediaEngine natively.\n// ---------------------------------------------------------------------------\n\ninterface UrlTransportOptions {\n fetcher: CachedFetcher;\n src: string;\n templates: { initSegment: string; mediaSegment: string };\n audioTrackId: number | undefined;\n videoTrackId: number | undefined;\n}\n\nfunction resolveRenditionId(track: TrackRef): RenditionId {\n if (track.role === \"audio\") return \"audio\";\n if (track.role === \"scrub\") return \"scrub\";\n if (typeof track.id === \"string\") return track.id as RenditionId;\n // For numeric IDs (fragment-based), map to JIT rendition names\n if (track.id === -1) return \"scrub\";\n if (track.id === 2) return \"audio\";\n return \"high\";\n}\n\nexport function createUrlTransport(opts: UrlTransportOptions): SegmentTransport {\n const { fetcher, src, templates, audioTrackId, videoTrackId } = opts;\n\n function buildSegmentUrl(segmentId: \"init\" | number, track: TrackRef): string {\n const renditionId = resolveRenditionId(track);\n const template = segmentId === \"init\" ? templates.initSegment : templates.mediaSegment;\n const trackId =\n typeof track.id === \"number\"\n ? track.id\n : track.role === \"audio\"\n ? audioTrackId\n : videoTrackId;\n return template\n .replace(\"{rendition}\", renditionId)\n .replace(\"{segmentId}\", segmentId.toString())\n .replace(\"{src}\", src)\n .replace(\"{trackId}\", trackId?.toString() ?? \"\");\n }\n\n return {\n async fetchInitSegment(track: TrackRef, signal: AbortSignal): Promise<ArrayBuffer> {\n const url = buildSegmentUrl(\"init\", track);\n return fetcher.fetchArrayBuffer(url, signal);\n },\n\n async fetchMediaSegment(\n segmentId: number,\n track: TrackRef,\n signal: AbortSignal,\n ): Promise<ArrayBuffer> {\n const url = buildSegmentUrl(segmentId, track);\n return fetcher.fetchArrayBuffer(url, signal);\n },\n\n isCached(segmentId: number, track: TrackRef): boolean {\n const url = buildSegmentUrl(segmentId, track);\n return fetcher.has(url);\n },\n };\n}\n\n// ---------------------------------------------------------------------------\n// ByteRangeTransport — fetches full track binary, slices segments\n// Used by FileMediaEngine.\n// ---------------------------------------------------------------------------\n\nexport function createByteRangeTransport(\n data: Record<number, TrackFragmentIndex>,\n fileId: string,\n apiHost: string,\n fetcher: CachedFetcher,\n): SegmentTransport {\n function buildTrackUrl(trackId: number): string {\n return `${apiHost}/api/v1/files/${fileId}/tracks/${trackId}`;\n }\n\n function getTrackId(track: TrackRef): number {\n const trackId = typeof track.id === \"number\" ? track.id : Number.parseInt(track.id, 10);\n if (Number.isNaN(trackId)) {\n throw new Error(`Invalid track ID: ${track.id}`);\n }\n return trackId;\n }\n\n return {\n async fetchInitSegment(track: TrackRef, signal: AbortSignal): Promise<ArrayBuffer> {\n const trackId = getTrackId(track);\n const trackData = data[trackId];\n if (!trackData) throw new Error(`Track ${trackId} not found`);\n\n const { offset, size } = trackData.initSegment;\n const url = buildTrackUrl(trackId);\n const fullTrack = await fetcher.fetchArrayBuffer(url, signal);\n return fullTrack.slice(offset, offset + size);\n },\n\n async fetchMediaSegment(\n segmentId: number,\n track: TrackRef,\n signal: AbortSignal,\n ): Promise<ArrayBuffer> {\n const trackId = getTrackId(track);\n const trackData = data[trackId];\n if (!trackData) throw new Error(`Track ${trackId} not found`);\n\n const segment = trackData.segments[segmentId];\n if (!segment) {\n throw new Error(`Segment ${segmentId} not found for track ${trackId}`);\n }\n\n const url = buildTrackUrl(trackId);\n const fullTrack = await fetcher.fetchArrayBuffer(url, signal);\n return fullTrack.slice(segment.offset, segment.offset + segment.size);\n },\n\n isCached(_segmentId: number, track: TrackRef): boolean {\n const trackId = getTrackId(track);\n const url = buildTrackUrl(trackId);\n return fetcher.has(url);\n },\n };\n}\n"],"mappings":";AAwBA,SAAS,mBAAmB,OAA8B;AACxD,KAAI,MAAM,SAAS,QAAS,QAAO;AACnC,KAAI,MAAM,SAAS,QAAS,QAAO;AACnC,KAAI,OAAO,MAAM,OAAO,SAAU,QAAO,MAAM;AAE/C,KAAI,MAAM,OAAO,GAAI,QAAO;AAC5B,KAAI,MAAM,OAAO,EAAG,QAAO;AAC3B,QAAO;;AAGT,SAAgB,mBAAmB,MAA6C;CAC9E,MAAM,EAAE,SAAS,KAAK,WAAW,cAAc,iBAAiB;CAEhE,SAAS,gBAAgB,WAA4B,OAAyB;EAC5E,MAAM,cAAc,mBAAmB,MAAM;EAC7C,MAAM,WAAW,cAAc,SAAS,UAAU,cAAc,UAAU;EAC1E,MAAM,UACJ,OAAO,MAAM,OAAO,WAChB,MAAM,KACN,MAAM,SAAS,UACb,eACA;AACR,SAAO,SACJ,QAAQ,eAAe,YAAY,CACnC,QAAQ,eAAe,UAAU,UAAU,CAAC,CAC5C,QAAQ,SAAS,IAAI,CACrB,QAAQ,aAAa,SAAS,UAAU,IAAI,GAAG;;AAGpD,QAAO;EACL,MAAM,iBAAiB,OAAiB,QAA2C;GACjF,MAAM,MAAM,gBAAgB,QAAQ,MAAM;AAC1C,UAAO,QAAQ,iBAAiB,KAAK,OAAO;;EAG9C,MAAM,kBACJ,WACA,OACA,QACsB;GACtB,MAAM,MAAM,gBAAgB,WAAW,MAAM;AAC7C,UAAO,QAAQ,iBAAiB,KAAK,OAAO;;EAG9C,SAAS,WAAmB,OAA0B;GACpD,MAAM,MAAM,gBAAgB,WAAW,MAAM;AAC7C,UAAO,QAAQ,IAAI,IAAI;;EAE1B;;AAQH,SAAgB,yBACd,MACA,QACA,SACA,SACkB;CAClB,SAAS,cAAc,SAAyB;AAC9C,SAAO,GAAG,QAAQ,gBAAgB,OAAO,UAAU;;CAGrD,SAAS,WAAW,OAAyB;EAC3C,MAAM,UAAU,OAAO,MAAM,OAAO,WAAW,MAAM,KAAK,OAAO,SAAS,MAAM,IAAI,GAAG;AACvF,MAAI,OAAO,MAAM,QAAQ,CACvB,OAAM,IAAI,MAAM,qBAAqB,MAAM,KAAK;AAElD,SAAO;;AAGT,QAAO;EACL,MAAM,iBAAiB,OAAiB,QAA2C;GACjF,MAAM,UAAU,WAAW,MAAM;GACjC,MAAM,YAAY,KAAK;AACvB,OAAI,CAAC,UAAW,OAAM,IAAI,MAAM,SAAS,QAAQ,YAAY;GAE7D,MAAM,EAAE,QAAQ,SAAS,UAAU;GACnC,MAAM,MAAM,cAAc,QAAQ;AAElC,WADkB,MAAM,QAAQ,iBAAiB,KAAK,OAAO,EAC5C,MAAM,QAAQ,SAAS,KAAK;;EAG/C,MAAM,kBACJ,WACA,OACA,QACsB;GACtB,MAAM,UAAU,WAAW,MAAM;GACjC,MAAM,YAAY,KAAK;AACvB,OAAI,CAAC,UAAW,OAAM,IAAI,MAAM,SAAS,QAAQ,YAAY;GAE7D,MAAM,UAAU,UAAU,SAAS;AACnC,OAAI,CAAC,QACH,OAAM,IAAI,MAAM,WAAW,UAAU,uBAAuB,UAAU;GAGxE,MAAM,MAAM,cAAc,QAAQ;AAElC,WADkB,MAAM,QAAQ,iBAAiB,KAAK,OAAO,EAC5C,MAAM,QAAQ,QAAQ,QAAQ,SAAS,QAAQ,KAAK;;EAGvE,SAAS,YAAoB,OAA0B;GAErD,MAAM,MAAM,cADI,WAAW,MAAM,CACC;AAClC,UAAO,QAAQ,IAAI,IAAI;;EAE1B"}
1
+ {"version":3,"file":"SegmentTransport.js","names":[],"sources":["../../../src/elements/EFMedia/SegmentTransport.ts"],"sourcesContent":["import type { TrackFragmentIndex } from \"@editframe/assets\";\nimport type { RenditionId } from \"../../transcoding/types/index.js\";\nimport type { TrackRef } from \"./SegmentIndex.js\";\nimport type { CachedFetcher } from \"./CachedFetcher.js\";\n\nexport interface SegmentTransport {\n fetchInitSegment(track: TrackRef, signal?: AbortSignal): Promise<ArrayBuffer>;\n fetchMediaSegment(segmentId: number, track: TrackRef, signal?: AbortSignal): Promise<ArrayBuffer>;\n isCached(segmentId: number, track: TrackRef): boolean;\n}\n\n// ---------------------------------------------------------------------------\n// UrlTransport — each segment has its own URL\n// Used by AssetMediaEngine (via JIT URLs) and JitMediaEngine natively.\n// ---------------------------------------------------------------------------\n\ninterface UrlTransportOptions {\n fetcher: CachedFetcher;\n src: string;\n templates: { initSegment: string; mediaSegment: string };\n audioTrackId: number | undefined;\n videoTrackId: number | undefined;\n}\n\nfunction resolveRenditionId(track: TrackRef): RenditionId {\n if (track.role === \"audio\") return \"audio\";\n if (track.role === \"scrub\") return \"scrub\";\n if (typeof track.id === \"string\") return track.id as RenditionId;\n // For numeric IDs (fragment-based), map to JIT rendition names\n if (track.id === -1) return \"scrub\";\n if (track.id === 2) return \"audio\";\n return \"high\";\n}\n\nexport function createUrlTransport(opts: UrlTransportOptions): SegmentTransport {\n const { fetcher, src, templates, audioTrackId, videoTrackId } = opts;\n\n function buildSegmentUrl(segmentId: \"init\" | number, track: TrackRef): string {\n const renditionId = resolveRenditionId(track);\n const template = segmentId === \"init\" ? templates.initSegment : templates.mediaSegment;\n const trackId =\n typeof track.id === \"number\"\n ? track.id\n : track.role === \"audio\"\n ? audioTrackId\n : videoTrackId;\n return template\n .replace(\"{rendition}\", renditionId)\n .replace(\"{segmentId}\", segmentId.toString())\n .replace(\"{src}\", src)\n .replace(\"{trackId}\", trackId?.toString() ?? \"\");\n }\n\n return {\n async fetchInitSegment(track: TrackRef, signal?: AbortSignal): Promise<ArrayBuffer> {\n const url = buildSegmentUrl(\"init\", track);\n return fetcher.fetchArrayBuffer(url, signal);\n },\n\n async fetchMediaSegment(\n segmentId: number,\n track: TrackRef,\n signal?: AbortSignal,\n ): Promise<ArrayBuffer> {\n const url = buildSegmentUrl(segmentId, track);\n return fetcher.fetchArrayBuffer(url, signal);\n },\n\n isCached(segmentId: number, track: TrackRef): boolean {\n const url = buildSegmentUrl(segmentId, track);\n return fetcher.has(url);\n },\n };\n}\n\n// ---------------------------------------------------------------------------\n// ByteRangeTransport — fetches full track binary, slices segments\n// Used by FileMediaEngine.\n// ---------------------------------------------------------------------------\n\nexport function createByteRangeTransport(\n data: Record<number, TrackFragmentIndex>,\n fileId: string,\n apiHost: string,\n fetcher: CachedFetcher,\n): SegmentTransport {\n function buildTrackUrl(trackId: number): string {\n return `${apiHost}/api/v1/files/${fileId}/tracks/${trackId}`;\n }\n\n function getTrackId(track: TrackRef): number {\n const trackId = typeof track.id === \"number\" ? track.id : Number.parseInt(track.id, 10);\n if (Number.isNaN(trackId)) {\n throw new Error(`Invalid track ID: ${track.id}`);\n }\n return trackId;\n }\n\n return {\n async fetchInitSegment(track: TrackRef, signal?: AbortSignal): Promise<ArrayBuffer> {\n const trackId = getTrackId(track);\n const trackData = data[trackId];\n if (!trackData) throw new Error(`Track ${trackId} not found`);\n\n const { offset, size } = trackData.initSegment;\n const url = buildTrackUrl(trackId);\n const fullTrack = await fetcher.fetchArrayBuffer(url, signal);\n return fullTrack.slice(offset, offset + size);\n },\n\n async fetchMediaSegment(\n segmentId: number,\n track: TrackRef,\n signal?: AbortSignal,\n ): Promise<ArrayBuffer> {\n const trackId = getTrackId(track);\n const trackData = data[trackId];\n if (!trackData) throw new Error(`Track ${trackId} not found`);\n\n const segment = trackData.segments[segmentId];\n if (!segment) {\n throw new Error(`Segment ${segmentId} not found for track ${trackId}`);\n }\n\n const url = buildTrackUrl(trackId);\n const fullTrack = await fetcher.fetchArrayBuffer(url, signal);\n return fullTrack.slice(segment.offset, segment.offset + segment.size);\n },\n\n isCached(_segmentId: number, track: TrackRef): boolean {\n const trackId = getTrackId(track);\n const url = buildTrackUrl(trackId);\n return fetcher.has(url);\n },\n };\n}\n"],"mappings":";AAwBA,SAAS,mBAAmB,OAA8B;AACxD,KAAI,MAAM,SAAS,QAAS,QAAO;AACnC,KAAI,MAAM,SAAS,QAAS,QAAO;AACnC,KAAI,OAAO,MAAM,OAAO,SAAU,QAAO,MAAM;AAE/C,KAAI,MAAM,OAAO,GAAI,QAAO;AAC5B,KAAI,MAAM,OAAO,EAAG,QAAO;AAC3B,QAAO;;AAGT,SAAgB,mBAAmB,MAA6C;CAC9E,MAAM,EAAE,SAAS,KAAK,WAAW,cAAc,iBAAiB;CAEhE,SAAS,gBAAgB,WAA4B,OAAyB;EAC5E,MAAM,cAAc,mBAAmB,MAAM;EAC7C,MAAM,WAAW,cAAc,SAAS,UAAU,cAAc,UAAU;EAC1E,MAAM,UACJ,OAAO,MAAM,OAAO,WAChB,MAAM,KACN,MAAM,SAAS,UACb,eACA;AACR,SAAO,SACJ,QAAQ,eAAe,YAAY,CACnC,QAAQ,eAAe,UAAU,UAAU,CAAC,CAC5C,QAAQ,SAAS,IAAI,CACrB,QAAQ,aAAa,SAAS,UAAU,IAAI,GAAG;;AAGpD,QAAO;EACL,MAAM,iBAAiB,OAAiB,QAA4C;GAClF,MAAM,MAAM,gBAAgB,QAAQ,MAAM;AAC1C,UAAO,QAAQ,iBAAiB,KAAK,OAAO;;EAG9C,MAAM,kBACJ,WACA,OACA,QACsB;GACtB,MAAM,MAAM,gBAAgB,WAAW,MAAM;AAC7C,UAAO,QAAQ,iBAAiB,KAAK,OAAO;;EAG9C,SAAS,WAAmB,OAA0B;GACpD,MAAM,MAAM,gBAAgB,WAAW,MAAM;AAC7C,UAAO,QAAQ,IAAI,IAAI;;EAE1B;;AAQH,SAAgB,yBACd,MACA,QACA,SACA,SACkB;CAClB,SAAS,cAAc,SAAyB;AAC9C,SAAO,GAAG,QAAQ,gBAAgB,OAAO,UAAU;;CAGrD,SAAS,WAAW,OAAyB;EAC3C,MAAM,UAAU,OAAO,MAAM,OAAO,WAAW,MAAM,KAAK,OAAO,SAAS,MAAM,IAAI,GAAG;AACvF,MAAI,OAAO,MAAM,QAAQ,CACvB,OAAM,IAAI,MAAM,qBAAqB,MAAM,KAAK;AAElD,SAAO;;AAGT,QAAO;EACL,MAAM,iBAAiB,OAAiB,QAA4C;GAClF,MAAM,UAAU,WAAW,MAAM;GACjC,MAAM,YAAY,KAAK;AACvB,OAAI,CAAC,UAAW,OAAM,IAAI,MAAM,SAAS,QAAQ,YAAY;GAE7D,MAAM,EAAE,QAAQ,SAAS,UAAU;GACnC,MAAM,MAAM,cAAc,QAAQ;AAElC,WADkB,MAAM,QAAQ,iBAAiB,KAAK,OAAO,EAC5C,MAAM,QAAQ,SAAS,KAAK;;EAG/C,MAAM,kBACJ,WACA,OACA,QACsB;GACtB,MAAM,UAAU,WAAW,MAAM;GACjC,MAAM,YAAY,KAAK;AACvB,OAAI,CAAC,UAAW,OAAM,IAAI,MAAM,SAAS,QAAQ,YAAY;GAE7D,MAAM,UAAU,UAAU,SAAS;AACnC,OAAI,CAAC,QACH,OAAM,IAAI,MAAM,WAAW,UAAU,uBAAuB,UAAU;GAGxE,MAAM,MAAM,cAAc,QAAQ;AAElC,WADkB,MAAM,QAAQ,iBAAiB,KAAK,OAAO,EAC5C,MAAM,QAAQ,QAAQ,QAAQ,SAAS,QAAQ,KAAK;;EAGvE,SAAS,YAAoB,OAA0B;GAErD,MAAM,MAAM,cADI,WAAW,MAAM,CACC;AAClC,UAAO,QAAQ,IAAI,IAAI;;EAE1B"}
@@ -3,6 +3,55 @@ import { DEFAULT_MEDIABUNNY_TIMEOUT_MS, withTimeout } from "./timeoutUtils.js";
3
3
  import { ALL_FORMATS, BlobSource, CanvasSink, Input } from "mediabunny";
4
4
 
5
5
  //#region src/elements/EFMedia/shared/ThumbnailExtractor.ts
6
+ /**
7
+ * Maximum number of segment extract operations that may run concurrently across
8
+ * all ThumbnailExtractor instances. Each operation fetches one init+media pair
9
+ * and decodes frames via mediabunny. Without this cap, N strips mounting
10
+ * simultaneously would fire N×M concurrent requests (N strips × M segments).
11
+ */
12
+ const THUMBNAIL_SEGMENT_FETCH_CONCURRENCY = 4;
13
+ /**
14
+ * Counting semaphore shared across all ThumbnailExtractor instances.
15
+ * Exported so tests can call reset() between test cases to prevent state bleed.
16
+ */
17
+ var Semaphore = class {
18
+ #active = 0;
19
+ #queue = [];
20
+ #max;
21
+ constructor(max) {
22
+ this.#max = max;
23
+ }
24
+ acquire(signal) {
25
+ if (signal?.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
26
+ if (this.#active < this.#max) {
27
+ this.#active++;
28
+ return Promise.resolve(() => this.#release());
29
+ }
30
+ return new Promise((resolve, reject) => {
31
+ const entry = () => {
32
+ this.#active++;
33
+ resolve(() => this.#release());
34
+ };
35
+ this.#queue.push(entry);
36
+ signal?.addEventListener("abort", () => {
37
+ const idx = this.#queue.indexOf(entry);
38
+ if (idx !== -1) this.#queue.splice(idx, 1);
39
+ reject(new DOMException("Aborted", "AbortError"));
40
+ }, { once: true });
41
+ });
42
+ }
43
+ #release() {
44
+ this.#active--;
45
+ const next = this.#queue.shift();
46
+ if (next) next();
47
+ }
48
+ /** Reset to empty state. For test isolation only. */
49
+ reset() {
50
+ this.#active = 0;
51
+ this.#queue.length = 0;
52
+ }
53
+ };
54
+ const thumbnailSemaphore = new Semaphore(THUMBNAIL_SEGMENT_FETCH_CONCURRENCY);
6
55
  var ThumbnailExtractor = class {
7
56
  constructor(mediaEngine) {
8
57
  this.mediaEngine = mediaEngine;
@@ -47,6 +96,8 @@ var ThumbnailExtractor = class {
47
96
  }
48
97
  async extractSegmentThumbnails(segmentId, timestamps, track, signal) {
49
98
  const results = /* @__PURE__ */ new Map();
99
+ signal?.throwIfAborted();
100
+ const release = await thumbnailSemaphore.acquire(signal);
50
101
  try {
51
102
  signal?.throwIfAborted();
52
103
  const initP = this.mediaEngine.transport.fetchInitSegment(track, signal);
@@ -96,6 +147,8 @@ var ThumbnailExtractor = class {
96
147
  if (error instanceof DOMException && error.name === "AbortError") throw error;
97
148
  console.warn(`ThumbnailExtractor: Failed to extract thumbnails for segment ${segmentId}:`, error);
98
149
  for (const timestamp of timestamps) results.set(timestamp, null);
150
+ } finally {
151
+ release();
99
152
  }
100
153
  return results;
101
154
  }
@@ -1 +1 @@
1
- {"version":3,"file":"ThumbnailExtractor.js","names":["mediaEngine: MediaEngine"],"sources":["../../../../src/elements/EFMedia/shared/ThumbnailExtractor.ts"],"sourcesContent":["import { ALL_FORMATS, BlobSource, CanvasSink, Input } from \"mediabunny\";\nimport type { ThumbnailResult } from \"../../../transcoding/types/index.js\";\nimport type { MediaEngine } from \"../MediaEngine.js\";\nimport type { TrackRef } from \"../SegmentIndex.js\";\nimport { globalInputCache } from \"./GlobalInputCache.js\";\nimport { withTimeout, DEFAULT_MEDIABUNNY_TIMEOUT_MS } from \"./timeoutUtils.js\";\n\nexport class ThumbnailExtractor {\n constructor(private mediaEngine: MediaEngine) {}\n\n async extractThumbnails(\n timestamps: number[],\n track: TrackRef,\n durationMs: number,\n signal?: AbortSignal,\n ): Promise<(ThumbnailResult | null)[]> {\n if (timestamps.length === 0) {\n return [];\n }\n\n const validTimestamps = timestamps.filter((timeMs) => timeMs >= 0 && timeMs <= durationMs);\n\n if (validTimestamps.length === 0) {\n console.warn(`ThumbnailExtractor: All timestamps out of bounds (0-${durationMs}ms)`);\n return timestamps.map(() => null);\n }\n\n const segmentGroups = this.groupTimestampsBySegment(validTimestamps, track);\n const results = new Map<number, ThumbnailResult | null>();\n\n for (const [segmentId, segmentTimestamps] of segmentGroups) {\n signal?.throwIfAborted();\n\n try {\n const segmentResults = await this.extractSegmentThumbnails(\n segmentId,\n segmentTimestamps,\n track,\n signal,\n );\n\n for (const [timestamp, thumbnail] of segmentResults) {\n results.set(timestamp, thumbnail);\n }\n } catch (error) {\n if (error instanceof DOMException && error.name === \"AbortError\") {\n throw error;\n }\n console.warn(\n `ThumbnailExtractor: Failed to extract thumbnails for segment ${segmentId}:`,\n error,\n );\n for (const timestamp of segmentTimestamps) {\n results.set(timestamp, null);\n }\n }\n }\n\n return timestamps.map((t) => {\n if (t < 0 || t > durationMs) {\n return null;\n }\n return results.get(t) || null;\n });\n }\n\n private groupTimestampsBySegment(timestamps: number[], track: TrackRef): Map<number, number[]> {\n const segmentGroups = new Map<number, number[]>();\n\n for (const timeMs of timestamps) {\n try {\n const segmentId = this.mediaEngine.index.segmentAt(timeMs, track);\n if (segmentId !== undefined) {\n if (!segmentGroups.has(segmentId)) {\n segmentGroups.set(segmentId, []);\n }\n const segmentGroup = segmentGroups.get(segmentId)!;\n segmentGroup.push(timeMs);\n }\n } catch (error) {\n console.warn(\n `ThumbnailExtractor: Could not compute segment for timestamp ${timeMs}:`,\n error,\n );\n }\n }\n\n return segmentGroups;\n }\n\n private async extractSegmentThumbnails(\n segmentId: number,\n timestamps: number[],\n track: TrackRef,\n signal?: AbortSignal,\n ): Promise<Map<number, ThumbnailResult | null>> {\n const results = new Map<number, ThumbnailResult | null>();\n\n try {\n signal?.throwIfAborted();\n\n const initP = this.mediaEngine.transport.fetchInitSegment(track, signal!);\n const mediaP = this.mediaEngine.transport.fetchMediaSegment(segmentId, track, signal!);\n initP.catch(() => {});\n mediaP.catch(() => {});\n const [initSegment, mediaSegment] = await Promise.all([initP, mediaP]);\n\n signal?.throwIfAborted();\n\n const segmentBlob = new Blob([initSegment, mediaSegment]);\n const renditionId = typeof track.id === \"string\" ? track.id : undefined;\n\n let input = globalInputCache.get(track.src, segmentId, renditionId);\n if (!input) {\n input = new Input({\n formats: ALL_FORMATS,\n source: new BlobSource(segmentBlob),\n });\n globalInputCache.set(track.src, segmentId, input, renditionId);\n }\n\n const videoTrack = await withTimeout(\n input.getPrimaryVideoTrack(),\n 5000,\n \"ThumbnailExtractor.getPrimaryVideoTrack\",\n signal,\n );\n if (!videoTrack) {\n for (const timestamp of timestamps) {\n results.set(timestamp, null);\n }\n return results;\n }\n\n const sink = new CanvasSink(videoTrack);\n const sortedTimestamps = [...timestamps].sort((a, b) => a - b);\n\n const relativeTimestamps = sortedTimestamps.map((ms) =>\n this.mediaEngine.timing.toContainerSeconds(ms, segmentId, track),\n );\n\n const timestampResults = [];\n const canvasIterator = sink.canvasesAtTimestamps(relativeTimestamps);\n for await (const result of canvasIterator) {\n const canvasResult = await withTimeout(\n Promise.resolve(result),\n DEFAULT_MEDIABUNNY_TIMEOUT_MS,\n \"ThumbnailExtractor canvasesAtTimestamps iteration\",\n signal,\n );\n timestampResults.push(canvasResult);\n }\n\n for (let i = 0; i < sortedTimestamps.length; i++) {\n const globalTimestamp = sortedTimestamps[i];\n if (globalTimestamp === undefined) {\n continue;\n }\n\n const result = timestampResults[i];\n\n if (result?.canvas) {\n const canvas = result.canvas;\n if (canvas instanceof HTMLCanvasElement || canvas instanceof OffscreenCanvas) {\n results.set(globalTimestamp, {\n timestamp: globalTimestamp,\n thumbnail: canvas,\n });\n } else {\n results.set(globalTimestamp, null);\n }\n } else {\n results.set(globalTimestamp, null);\n }\n }\n } catch (error) {\n if (error instanceof DOMException && error.name === \"AbortError\") {\n throw error;\n }\n console.warn(\n `ThumbnailExtractor: Failed to extract thumbnails for segment ${segmentId}:`,\n error,\n );\n for (const timestamp of timestamps) {\n results.set(timestamp, null);\n }\n }\n\n return results;\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"ThumbnailExtractor.js","names":["#max","#active","#release","#queue","mediaEngine: MediaEngine"],"sources":["../../../../src/elements/EFMedia/shared/ThumbnailExtractor.ts"],"sourcesContent":["import { ALL_FORMATS, BlobSource, CanvasSink, Input } from \"mediabunny\";\nimport type { ThumbnailResult } from \"../../../transcoding/types/index.js\";\nimport type { MediaEngine } from \"../MediaEngine.js\";\nimport type { TrackRef } from \"../SegmentIndex.js\";\nimport { globalInputCache } from \"./GlobalInputCache.js\";\nimport { withTimeout, DEFAULT_MEDIABUNNY_TIMEOUT_MS } from \"./timeoutUtils.js\";\n\n/**\n * Maximum number of segment extract operations that may run concurrently across\n * all ThumbnailExtractor instances. Each operation fetches one init+media pair\n * and decodes frames via mediabunny. Without this cap, N strips mounting\n * simultaneously would fire N×M concurrent requests (N strips × M segments).\n */\nconst THUMBNAIL_SEGMENT_FETCH_CONCURRENCY = 4;\n\n/**\n * Counting semaphore shared across all ThumbnailExtractor instances.\n * Exported so tests can call reset() between test cases to prevent state bleed.\n */\nexport class Semaphore {\n #active = 0;\n #queue: Array<() => void> = [];\n readonly #max: number;\n\n constructor(max: number) {\n this.#max = max;\n }\n\n acquire(signal?: AbortSignal): Promise<() => void> {\n if (signal?.aborted) {\n return Promise.reject(new DOMException(\"Aborted\", \"AbortError\"));\n }\n if (this.#active < this.#max) {\n this.#active++;\n return Promise.resolve(() => this.#release());\n }\n return new Promise<() => void>((resolve, reject) => {\n const entry = () => {\n this.#active++;\n resolve(() => this.#release());\n };\n this.#queue.push(entry);\n signal?.addEventListener(\n \"abort\",\n () => {\n const idx = this.#queue.indexOf(entry);\n if (idx !== -1) this.#queue.splice(idx, 1);\n reject(new DOMException(\"Aborted\", \"AbortError\"));\n },\n { once: true },\n );\n });\n }\n\n #release(): void {\n this.#active--;\n const next = this.#queue.shift();\n if (next) next();\n }\n\n /** Reset to empty state. For test isolation only. */\n reset(): void {\n this.#active = 0;\n this.#queue.length = 0;\n }\n}\n\nexport const thumbnailSemaphore = new Semaphore(THUMBNAIL_SEGMENT_FETCH_CONCURRENCY);\n\nexport class ThumbnailExtractor {\n constructor(private mediaEngine: MediaEngine) {}\n\n async extractThumbnails(\n timestamps: number[],\n track: TrackRef,\n durationMs: number,\n signal?: AbortSignal,\n ): Promise<(ThumbnailResult | null)[]> {\n if (timestamps.length === 0) {\n return [];\n }\n\n const validTimestamps = timestamps.filter((timeMs) => timeMs >= 0 && timeMs <= durationMs);\n\n if (validTimestamps.length === 0) {\n console.warn(`ThumbnailExtractor: All timestamps out of bounds (0-${durationMs}ms)`);\n return timestamps.map(() => null);\n }\n\n const segmentGroups = this.groupTimestampsBySegment(validTimestamps, track);\n const results = new Map<number, ThumbnailResult | null>();\n\n for (const [segmentId, segmentTimestamps] of segmentGroups) {\n signal?.throwIfAborted();\n\n try {\n const segmentResults = await this.extractSegmentThumbnails(\n segmentId,\n segmentTimestamps,\n track,\n signal,\n );\n\n for (const [timestamp, thumbnail] of segmentResults) {\n results.set(timestamp, thumbnail);\n }\n } catch (error) {\n if (error instanceof DOMException && error.name === \"AbortError\") {\n throw error;\n }\n console.warn(\n `ThumbnailExtractor: Failed to extract thumbnails for segment ${segmentId}:`,\n error,\n );\n for (const timestamp of segmentTimestamps) {\n results.set(timestamp, null);\n }\n }\n }\n\n return timestamps.map((t) => {\n if (t < 0 || t > durationMs) {\n return null;\n }\n return results.get(t) || null;\n });\n }\n\n private groupTimestampsBySegment(timestamps: number[], track: TrackRef): Map<number, number[]> {\n const segmentGroups = new Map<number, number[]>();\n\n for (const timeMs of timestamps) {\n try {\n const segmentId = this.mediaEngine.index.segmentAt(timeMs, track);\n if (segmentId !== undefined) {\n if (!segmentGroups.has(segmentId)) {\n segmentGroups.set(segmentId, []);\n }\n const segmentGroup = segmentGroups.get(segmentId)!;\n segmentGroup.push(timeMs);\n }\n } catch (error) {\n console.warn(\n `ThumbnailExtractor: Could not compute segment for timestamp ${timeMs}:`,\n error,\n );\n }\n }\n\n return segmentGroups;\n }\n\n private async extractSegmentThumbnails(\n segmentId: number,\n timestamps: number[],\n track: TrackRef,\n signal?: AbortSignal,\n ): Promise<Map<number, ThumbnailResult | null>> {\n const results = new Map<number, ThumbnailResult | null>();\n\n signal?.throwIfAborted();\n const release = await thumbnailSemaphore.acquire(signal);\n\n try {\n signal?.throwIfAborted();\n\n const initP = this.mediaEngine.transport.fetchInitSegment(track, signal);\n const mediaP = this.mediaEngine.transport.fetchMediaSegment(segmentId, track, signal);\n initP.catch(() => {});\n mediaP.catch(() => {});\n const [initSegment, mediaSegment] = await Promise.all([initP, mediaP]);\n\n signal?.throwIfAborted();\n\n const segmentBlob = new Blob([initSegment, mediaSegment]);\n const renditionId = typeof track.id === \"string\" ? track.id : undefined;\n\n let input = globalInputCache.get(track.src, segmentId, renditionId);\n if (!input) {\n input = new Input({\n formats: ALL_FORMATS,\n source: new BlobSource(segmentBlob),\n });\n globalInputCache.set(track.src, segmentId, input, renditionId);\n }\n\n const videoTrack = await withTimeout(\n input.getPrimaryVideoTrack(),\n 5000,\n \"ThumbnailExtractor.getPrimaryVideoTrack\",\n signal,\n );\n if (!videoTrack) {\n for (const timestamp of timestamps) {\n results.set(timestamp, null);\n }\n return results;\n }\n\n const sink = new CanvasSink(videoTrack);\n const sortedTimestamps = [...timestamps].sort((a, b) => a - b);\n\n const relativeTimestamps = sortedTimestamps.map((ms) =>\n this.mediaEngine.timing.toContainerSeconds(ms, segmentId, track),\n );\n\n const timestampResults = [];\n const canvasIterator = sink.canvasesAtTimestamps(relativeTimestamps);\n for await (const result of canvasIterator) {\n const canvasResult = await withTimeout(\n Promise.resolve(result),\n DEFAULT_MEDIABUNNY_TIMEOUT_MS,\n \"ThumbnailExtractor canvasesAtTimestamps iteration\",\n signal,\n );\n timestampResults.push(canvasResult);\n }\n\n for (let i = 0; i < sortedTimestamps.length; i++) {\n const globalTimestamp = sortedTimestamps[i];\n if (globalTimestamp === undefined) {\n continue;\n }\n\n const result = timestampResults[i];\n\n if (result?.canvas) {\n const canvas = result.canvas;\n if (canvas instanceof HTMLCanvasElement || canvas instanceof OffscreenCanvas) {\n results.set(globalTimestamp, {\n timestamp: globalTimestamp,\n thumbnail: canvas,\n });\n } else {\n results.set(globalTimestamp, null);\n }\n } else {\n results.set(globalTimestamp, null);\n }\n }\n } catch (error) {\n if (error instanceof DOMException && error.name === \"AbortError\") {\n throw error;\n }\n console.warn(\n `ThumbnailExtractor: Failed to extract thumbnails for segment ${segmentId}:`,\n error,\n );\n for (const timestamp of timestamps) {\n results.set(timestamp, null);\n }\n } finally {\n release();\n }\n\n return results;\n }\n}\n"],"mappings":"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"}
@@ -7,7 +7,13 @@
7
7
  var MainVideoInputCache = class {
8
8
  #cache = /* @__PURE__ */ new Map();
9
9
  #pendingPromises = /* @__PURE__ */ new Map();
10
- #maxCacheSize = 10;
10
+ #maxCacheSize;
11
+ /** Incremented on clear(). Promises capture the generation at creation time and
12
+ * skip writing back if the generation has advanced (cache was cleared). */
13
+ #generation = 0;
14
+ constructor(maxCacheSize = 10) {
15
+ this.#maxCacheSize = maxCacheSize;
16
+ }
11
17
  /**
12
18
  * Create a cache key that uniquely identifies a segment
13
19
  */
@@ -27,16 +33,22 @@ var MainVideoInputCache = class {
27
33
  async getOrCreateInput(src, segmentId, renditionId, createInputFn) {
28
34
  const cacheKey = this.#getCacheKey(src, segmentId, renditionId);
29
35
  const cached = this.#cache.get(cacheKey);
30
- if (cached) return cached;
36
+ if (cached) {
37
+ this.#cache.delete(cacheKey);
38
+ this.#cache.set(cacheKey, cached);
39
+ return cached;
40
+ }
31
41
  const pending = this.#pendingPromises.get(cacheKey);
32
42
  if (pending) return pending;
43
+ const capturedGeneration = this.#generation;
33
44
  const promise = createInputFn().then((input) => {
34
45
  this.#pendingPromises.delete(cacheKey);
35
- if (input) {
46
+ if (input && this.#generation === capturedGeneration) {
36
47
  this.#cache.set(cacheKey, input);
37
- if (this.#cache.size > this.#maxCacheSize) {
48
+ while (this.#cache.size > this.#maxCacheSize) {
38
49
  const oldestKey = this.#cache.keys().next().value;
39
50
  if (oldestKey !== void 0) this.#cache.delete(oldestKey);
51
+ else break;
40
52
  }
41
53
  }
42
54
  return input;
@@ -48,9 +60,12 @@ var MainVideoInputCache = class {
48
60
  return promise;
49
61
  }
50
62
  /**
51
- * Clear the entire cache (called when video changes)
63
+ * Clear the entire cache (called when video changes).
64
+ * Increments the generation counter so any in-flight promises resolved after
65
+ * this call do not repopulate the cache with stale data from the old source.
52
66
  */
53
67
  clear() {
68
+ this.#generation++;
54
69
  this.#cache.clear();
55
70
  this.#pendingPromises.clear();
56
71
  }
@@ -1 +1 @@
1
- {"version":3,"file":"MainVideoInputCache.js","names":["#getCacheKey","#cache","#pendingPromises","#maxCacheSize"],"sources":["../../../../src/elements/EFMedia/videoTasks/MainVideoInputCache.ts"],"sourcesContent":["import type { BufferedSeekingInput } from \"../BufferedSeekingInput\";\n\n/**\n * Cache for main video BufferedSeekingInput instances\n * Main video segments are typically 2s long, so we can reuse the same input\n * for multiple frames within that segment (e.g., 60 frames at 30fps)\n */\nexport class MainVideoInputCache {\n #cache = new Map<string, BufferedSeekingInput>();\n #pendingPromises = new Map<string, Promise<BufferedSeekingInput | undefined>>();\n #maxCacheSize = 10; // Keep last 10 main inputs (covers 20 seconds at 2s/segment)\n\n /**\n * Create a cache key that uniquely identifies a segment\n */\n #getCacheKey(src: string, segmentId: number, renditionId: string | undefined): string {\n return `${src}:${renditionId || \"default\"}:${segmentId}`;\n }\n\n /**\n * Get or create BufferedSeekingInput for a main video segment.\n *\n * Uses promise deduplication to prevent race conditions when multiple\n * concurrent requests arrive for the same segment. Without this,\n * the first segment often fails when DevTools is closed because:\n * 1. Video display and thumbnail extraction both request segment 0\n * 2. Both find cache empty and start createInputFn()\n * 3. Both create separate instances, causing conflicts\n */\n async getOrCreateInput(\n src: string,\n segmentId: number,\n renditionId: string | undefined,\n createInputFn: () => Promise<BufferedSeekingInput | undefined>,\n ): Promise<BufferedSeekingInput | undefined> {\n const cacheKey = this.#getCacheKey(src, segmentId, renditionId);\n\n // Check if we already have a completed result cached\n const cached = this.#cache.get(cacheKey);\n if (cached) {\n return cached;\n }\n\n // Check if there's already a pending request for this segment (deduplication!)\n // This prevents the race condition where multiple concurrent requests\n // each create their own BufferedSeekingInput instance.\n const pending = this.#pendingPromises.get(cacheKey);\n if (pending) {\n return pending;\n }\n\n // Create the promise and cache it IMMEDIATELY to prevent race conditions\n const promise = createInputFn()\n .then((input) => {\n // Clean up pending promise\n this.#pendingPromises.delete(cacheKey);\n\n if (input) {\n // Add to completed cache\n this.#cache.set(cacheKey, input);\n\n // Evict oldest entries if cache is too large (LRU-like behavior)\n if (this.#cache.size > this.#maxCacheSize) {\n const oldestKey = this.#cache.keys().next().value;\n if (oldestKey !== undefined) {\n this.#cache.delete(oldestKey);\n }\n }\n }\n\n return input;\n })\n .catch((error) => {\n // Clean up pending promise on failure so retry is possible\n this.#pendingPromises.delete(cacheKey);\n throw error;\n });\n\n this.#pendingPromises.set(cacheKey, promise);\n return promise;\n }\n\n /**\n * Clear the entire cache (called when video changes)\n */\n clear() {\n this.#cache.clear();\n this.#pendingPromises.clear();\n }\n\n /**\n * Get cache statistics\n */\n getStats() {\n return {\n size: this.#cache.size,\n pendingSize: this.#pendingPromises.size,\n cacheKeys: Array.from(this.#cache.keys()),\n };\n }\n}\n"],"mappings":";;;;;;AAOA,IAAa,sBAAb,MAAiC;CAC/B,yBAAS,IAAI,KAAmC;CAChD,mCAAmB,IAAI,KAAwD;CAC/E,gBAAgB;;;;CAKhB,aAAa,KAAa,WAAmB,aAAyC;AACpF,SAAO,GAAG,IAAI,GAAG,eAAe,UAAU,GAAG;;;;;;;;;;;;CAa/C,MAAM,iBACJ,KACA,WACA,aACA,eAC2C;EAC3C,MAAM,WAAW,MAAKA,YAAa,KAAK,WAAW,YAAY;EAG/D,MAAM,SAAS,MAAKC,MAAO,IAAI,SAAS;AACxC,MAAI,OACF,QAAO;EAMT,MAAM,UAAU,MAAKC,gBAAiB,IAAI,SAAS;AACnD,MAAI,QACF,QAAO;EAIT,MAAM,UAAU,eAAe,CAC5B,MAAM,UAAU;AAEf,SAAKA,gBAAiB,OAAO,SAAS;AAEtC,OAAI,OAAO;AAET,UAAKD,MAAO,IAAI,UAAU,MAAM;AAGhC,QAAI,MAAKA,MAAO,OAAO,MAAKE,cAAe;KACzC,MAAM,YAAY,MAAKF,MAAO,MAAM,CAAC,MAAM,CAAC;AAC5C,SAAI,cAAc,OAChB,OAAKA,MAAO,OAAO,UAAU;;;AAKnC,UAAO;IACP,CACD,OAAO,UAAU;AAEhB,SAAKC,gBAAiB,OAAO,SAAS;AACtC,SAAM;IACN;AAEJ,QAAKA,gBAAiB,IAAI,UAAU,QAAQ;AAC5C,SAAO;;;;;CAMT,QAAQ;AACN,QAAKD,MAAO,OAAO;AACnB,QAAKC,gBAAiB,OAAO;;;;;CAM/B,WAAW;AACT,SAAO;GACL,MAAM,MAAKD,MAAO;GAClB,aAAa,MAAKC,gBAAiB;GACnC,WAAW,MAAM,KAAK,MAAKD,MAAO,MAAM,CAAC;GAC1C"}
1
+ {"version":3,"file":"MainVideoInputCache.js","names":["#maxCacheSize","#getCacheKey","#cache","#pendingPromises","#generation"],"sources":["../../../../src/elements/EFMedia/videoTasks/MainVideoInputCache.ts"],"sourcesContent":["import type { BufferedSeekingInput } from \"../BufferedSeekingInput\";\n\n/**\n * Cache for main video BufferedSeekingInput instances\n * Main video segments are typically 2s long, so we can reuse the same input\n * for multiple frames within that segment (e.g., 60 frames at 30fps)\n */\nexport class MainVideoInputCache {\n #cache = new Map<string, BufferedSeekingInput>();\n #pendingPromises = new Map<string, Promise<BufferedSeekingInput | undefined>>();\n #maxCacheSize: number;\n /** Incremented on clear(). Promises capture the generation at creation time and\n * skip writing back if the generation has advanced (cache was cleared). */\n #generation = 0;\n\n constructor(maxCacheSize = 10) {\n this.#maxCacheSize = maxCacheSize;\n }\n\n /**\n * Create a cache key that uniquely identifies a segment\n */\n #getCacheKey(src: string, segmentId: number, renditionId: string | undefined): string {\n return `${src}:${renditionId || \"default\"}:${segmentId}`;\n }\n\n /**\n * Get or create BufferedSeekingInput for a main video segment.\n *\n * Uses promise deduplication to prevent race conditions when multiple\n * concurrent requests arrive for the same segment. Without this,\n * the first segment often fails when DevTools is closed because:\n * 1. Video display and thumbnail extraction both request segment 0\n * 2. Both find cache empty and start createInputFn()\n * 3. Both create separate instances, causing conflicts\n */\n async getOrCreateInput(\n src: string,\n segmentId: number,\n renditionId: string | undefined,\n createInputFn: () => Promise<BufferedSeekingInput | undefined>,\n ): Promise<BufferedSeekingInput | undefined> {\n const cacheKey = this.#getCacheKey(src, segmentId, renditionId);\n\n // Check if we already have a completed result cached\n const cached = this.#cache.get(cacheKey);\n if (cached) {\n // Move to end (most recently used) for true LRU ordering\n this.#cache.delete(cacheKey);\n this.#cache.set(cacheKey, cached);\n return cached;\n }\n\n // Check if there's already a pending request for this segment (deduplication!)\n // This prevents the race condition where multiple concurrent requests\n // each create their own BufferedSeekingInput instance.\n const pending = this.#pendingPromises.get(cacheKey);\n if (pending) {\n return pending;\n }\n\n // Create the promise and cache it IMMEDIATELY to prevent race conditions.\n // Capture the generation so that if clear() fires while this promise is\n // in-flight, the .then() callback does not repopulate the cache with data\n // from the old source.\n const capturedGeneration = this.#generation;\n const promise = createInputFn()\n .then((input) => {\n // Clean up pending promise\n this.#pendingPromises.delete(cacheKey);\n\n if (input && this.#generation === capturedGeneration) {\n // Add to completed cache\n this.#cache.set(cacheKey, input);\n\n // Evict oldest entries if cache is too large (LRU-like behavior)\n while (this.#cache.size > this.#maxCacheSize) {\n const oldestKey = this.#cache.keys().next().value;\n if (oldestKey !== undefined) {\n this.#cache.delete(oldestKey);\n } else {\n break;\n }\n }\n }\n\n return input;\n })\n .catch((error) => {\n // Clean up pending promise on failure so retry is possible\n this.#pendingPromises.delete(cacheKey);\n throw error;\n });\n\n this.#pendingPromises.set(cacheKey, promise);\n return promise;\n }\n\n /**\n * Clear the entire cache (called when video changes).\n * Increments the generation counter so any in-flight promises resolved after\n * this call do not repopulate the cache with stale data from the old source.\n */\n clear() {\n this.#generation++;\n this.#cache.clear();\n this.#pendingPromises.clear();\n }\n\n /**\n * Get cache statistics\n */\n getStats() {\n return {\n size: this.#cache.size,\n pendingSize: this.#pendingPromises.size,\n cacheKeys: Array.from(this.#cache.keys()),\n };\n }\n}\n"],"mappings":";;;;;;AAOA,IAAa,sBAAb,MAAiC;CAC/B,yBAAS,IAAI,KAAmC;CAChD,mCAAmB,IAAI,KAAwD;CAC/E;;;CAGA,cAAc;CAEd,YAAY,eAAe,IAAI;AAC7B,QAAKA,eAAgB;;;;;CAMvB,aAAa,KAAa,WAAmB,aAAyC;AACpF,SAAO,GAAG,IAAI,GAAG,eAAe,UAAU,GAAG;;;;;;;;;;;;CAa/C,MAAM,iBACJ,KACA,WACA,aACA,eAC2C;EAC3C,MAAM,WAAW,MAAKC,YAAa,KAAK,WAAW,YAAY;EAG/D,MAAM,SAAS,MAAKC,MAAO,IAAI,SAAS;AACxC,MAAI,QAAQ;AAEV,SAAKA,MAAO,OAAO,SAAS;AAC5B,SAAKA,MAAO,IAAI,UAAU,OAAO;AACjC,UAAO;;EAMT,MAAM,UAAU,MAAKC,gBAAiB,IAAI,SAAS;AACnD,MAAI,QACF,QAAO;EAOT,MAAM,qBAAqB,MAAKC;EAChC,MAAM,UAAU,eAAe,CAC5B,MAAM,UAAU;AAEf,SAAKD,gBAAiB,OAAO,SAAS;AAEtC,OAAI,SAAS,MAAKC,eAAgB,oBAAoB;AAEpD,UAAKF,MAAO,IAAI,UAAU,MAAM;AAGhC,WAAO,MAAKA,MAAO,OAAO,MAAKF,cAAe;KAC5C,MAAM,YAAY,MAAKE,MAAO,MAAM,CAAC,MAAM,CAAC;AAC5C,SAAI,cAAc,OAChB,OAAKA,MAAO,OAAO,UAAU;SAE7B;;;AAKN,UAAO;IACP,CACD,OAAO,UAAU;AAEhB,SAAKC,gBAAiB,OAAO,SAAS;AACtC,SAAM;IACN;AAEJ,QAAKA,gBAAiB,IAAI,UAAU,QAAQ;AAC5C,SAAO;;;;;;;CAQT,QAAQ;AACN,QAAKC;AACL,QAAKF,MAAO,OAAO;AACnB,QAAKC,gBAAiB,OAAO;;;;;CAM/B,WAAW;AACT,SAAO;GACL,MAAM,MAAKD,MAAO;GAClB,aAAa,MAAKC,gBAAiB;GACnC,WAAW,MAAM,KAAK,MAAKD,MAAO,MAAM,CAAC;GAC1C"}