supervision 0.1.7 → 0.2.0-next.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 (215) hide show
  1. package/dist/detections/chunked-detection-frame-source.d.ts.map +1 -1
  2. package/dist/index.d.ts +11 -4
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +8856 -4559
  5. package/dist/index.js.map +1 -1
  6. package/dist/mask-preparation.worker.js +310 -164
  7. package/dist/mask-preparation.worker.js.map +1 -1
  8. package/dist/media/display-pixel-ratio.d.ts +15 -0
  9. package/dist/media/display-pixel-ratio.d.ts.map +1 -0
  10. package/dist/media/engine-import-failure.d.ts +16 -0
  11. package/dist/media/engine-import-failure.d.ts.map +1 -0
  12. package/dist/media/media-condition-probe.d.ts +51 -0
  13. package/dist/media/media-condition-probe.d.ts.map +1 -0
  14. package/dist/media/media-conditions.d.ts +28 -0
  15. package/dist/media/media-conditions.d.ts.map +1 -0
  16. package/dist/media/media-errors.d.ts +6 -0
  17. package/dist/media/media-errors.d.ts.map +1 -1
  18. package/dist/media/media-normalization.d.ts.map +1 -1
  19. package/dist/media/media-probe.d.ts.map +1 -1
  20. package/dist/media/media-source.d.ts +17 -0
  21. package/dist/media/media-source.d.ts.map +1 -1
  22. package/dist/media/mediabunny-media-source.d.ts.map +1 -1
  23. package/dist/media/video-engine-media-source.d.ts +44 -0
  24. package/dist/media/video-engine-media-source.d.ts.map +1 -0
  25. package/dist/render-preparation/mask-frame-artifact.d.ts +46 -12
  26. package/dist/render-preparation/mask-frame-artifact.d.ts.map +1 -1
  27. package/dist/render-preparation/mask-frame-compositor.d.ts +5 -5
  28. package/dist/render-preparation/mask-frame-compositor.d.ts.map +1 -1
  29. package/dist/render-preparation/mask-frame-preparer.d.ts.map +1 -1
  30. package/dist/render-preparation/mask-preparation-worker-count.d.ts +4 -4
  31. package/dist/render-preparation/mask-preparation-worker-protocol.d.ts +12 -3
  32. package/dist/render-preparation/mask-preparation-worker-protocol.d.ts.map +1 -1
  33. package/dist/render-preparation/prepared-render-window.d.ts +23 -0
  34. package/dist/render-preparation/prepared-render-window.d.ts.map +1 -1
  35. package/dist/render-preparation/prepared-window-timeline.d.ts +2 -6
  36. package/dist/render-preparation/prepared-window-timeline.d.ts.map +1 -1
  37. package/dist/renderers/mask-palette.d.ts +14 -0
  38. package/dist/renderers/mask-palette.d.ts.map +1 -0
  39. package/dist/renderers/mask-vertex.d.ts +14 -0
  40. package/dist/renderers/mask-vertex.d.ts.map +1 -0
  41. package/dist/renderers/media-renderer-core.d.ts.map +1 -1
  42. package/dist/renderers/media-renderer-scene.d.ts +37 -0
  43. package/dist/renderers/media-renderer-scene.d.ts.map +1 -1
  44. package/dist/renderers/media-renderer-state.d.ts +11 -1
  45. package/dist/renderers/media-renderer-state.d.ts.map +1 -1
  46. package/dist/renderers/media-renderer-transport.d.ts +59 -0
  47. package/dist/renderers/media-renderer-transport.d.ts.map +1 -0
  48. package/dist/renderers/pixi-box-layer.d.ts +5 -0
  49. package/dist/renderers/pixi-box-layer.d.ts.map +1 -1
  50. package/dist/renderers/pixi-focus-layer.d.ts +18 -2
  51. package/dist/renderers/pixi-focus-layer.d.ts.map +1 -1
  52. package/dist/renderers/pixi-frame-present.d.ts +77 -0
  53. package/dist/renderers/pixi-frame-present.d.ts.map +1 -0
  54. package/dist/renderers/pixi-id-mask-shader.d.ts +12 -2
  55. package/dist/renderers/pixi-id-mask-shader.d.ts.map +1 -1
  56. package/dist/renderers/pixi-interaction-layer.d.ts.map +1 -1
  57. package/dist/renderers/pixi-interaction-presentation-layer.d.ts +13 -2
  58. package/dist/renderers/pixi-interaction-presentation-layer.d.ts.map +1 -1
  59. package/dist/renderers/pixi-mask-halo.d.ts +24 -0
  60. package/dist/renderers/pixi-mask-halo.d.ts.map +1 -1
  61. package/dist/renderers/pixi-mask-layer.d.ts +71 -8
  62. package/dist/renderers/pixi-mask-layer.d.ts.map +1 -1
  63. package/dist/renderers/pixi-media-scene.d.ts +67 -0
  64. package/dist/renderers/pixi-media-scene.d.ts.map +1 -1
  65. package/dist/renderers/pixi-polygon-layer.d.ts +7 -2
  66. package/dist/renderers/pixi-polygon-layer.d.ts.map +1 -1
  67. package/dist/renderers/pixi-region-coverage-mask.d.ts +91 -0
  68. package/dist/renderers/pixi-region-coverage-mask.d.ts.map +1 -0
  69. package/dist/renderers/pixi-region-effect.d.ts +42 -0
  70. package/dist/renderers/pixi-region-effect.d.ts.map +1 -0
  71. package/dist/renderers/pixi-region-layer.d.ts +68 -2
  72. package/dist/renderers/pixi-region-layer.d.ts.map +1 -1
  73. package/dist/renderers/pixi-vector-layer.d.ts.map +1 -1
  74. package/dist/renderers/prepared-annotation-window.d.ts +47 -0
  75. package/dist/renderers/prepared-annotation-window.d.ts.map +1 -0
  76. package/dist/renderers/presented-frame-channel.d.ts +107 -0
  77. package/dist/renderers/presented-frame-channel.d.ts.map +1 -0
  78. package/dist/renderers/scene-render-scheduler.d.ts +20 -0
  79. package/dist/renderers/scene-render-scheduler.d.ts.map +1 -0
  80. package/dist/sessions/media-session-defaults.d.ts +10 -6
  81. package/dist/sessions/media-session-defaults.d.ts.map +1 -1
  82. package/dist/sessions/media-session-media.d.ts.map +1 -1
  83. package/dist/sessions/media-session-state.d.ts.map +1 -1
  84. package/dist/sessions/media-session.d.ts.map +1 -1
  85. package/dist/tracking.worker.js +58 -3
  86. package/dist/tracking.worker.js.map +1 -1
  87. package/dist/types/media-conditions.d.ts +149 -0
  88. package/dist/types/media-conditions.d.ts.map +1 -0
  89. package/dist/types/media-normalization.d.ts +34 -0
  90. package/dist/types/media-normalization.d.ts.map +1 -1
  91. package/dist/types/media-renderer.d.ts +31 -1
  92. package/dist/types/media-renderer.d.ts.map +1 -1
  93. package/dist/types/media-session.d.ts +89 -8
  94. package/dist/types/media-session.d.ts.map +1 -1
  95. package/dist/types/render-preparation.d.ts +101 -4
  96. package/dist/types/render-preparation.d.ts.map +1 -1
  97. package/dist/web-video-engine/analysis-session.d.ts +76 -0
  98. package/dist/web-video-engine/analysis-session.d.ts.map +1 -0
  99. package/dist/web-video-engine/analysis.d.ts +7 -0
  100. package/dist/web-video-engine/analysis.d.ts.map +1 -0
  101. package/dist/web-video-engine/analysis.js +1678 -0
  102. package/dist/web-video-engine/analysis.js.map +1 -0
  103. package/dist/web-video-engine/cache-budget.d.ts +19 -0
  104. package/dist/web-video-engine/cache-budget.d.ts.map +1 -0
  105. package/dist/web-video-engine/canvas-sink-scrub-cursor.d.ts +90 -0
  106. package/dist/web-video-engine/canvas-sink-scrub-cursor.d.ts.map +1 -0
  107. package/dist/web-video-engine/clock.d.ts +83 -0
  108. package/dist/web-video-engine/clock.d.ts.map +1 -0
  109. package/dist/web-video-engine/constants.d.ts +253 -0
  110. package/dist/web-video-engine/constants.d.ts.map +1 -0
  111. package/dist/web-video-engine/create-scrub-cursor.d.ts +62 -0
  112. package/dist/web-video-engine/create-scrub-cursor.d.ts.map +1 -0
  113. package/dist/web-video-engine/decode-resolution.d.ts +115 -0
  114. package/dist/web-video-engine/decode-resolution.d.ts.map +1 -0
  115. package/dist/web-video-engine/decode-scheduler.d.ts +356 -0
  116. package/dist/web-video-engine/decode-scheduler.d.ts.map +1 -0
  117. package/dist/web-video-engine/decode-session.d.ts +276 -0
  118. package/dist/web-video-engine/decode-session.d.ts.map +1 -0
  119. package/dist/web-video-engine/decode-source.d.ts +250 -0
  120. package/dist/web-video-engine/decode-source.d.ts.map +1 -0
  121. package/dist/web-video-engine/diagnostics-store.d.ts +18 -0
  122. package/dist/web-video-engine/diagnostics-store.d.ts.map +1 -0
  123. package/dist/web-video-engine/diagnostics.d.ts +316 -0
  124. package/dist/web-video-engine/diagnostics.d.ts.map +1 -0
  125. package/dist/web-video-engine/embedded-engine-worker.d.ts +2 -0
  126. package/dist/web-video-engine/embedded-engine-worker.d.ts.map +1 -0
  127. package/dist/web-video-engine/engine-core.d.ts +258 -0
  128. package/dist/web-video-engine/engine-core.d.ts.map +1 -0
  129. package/dist/web-video-engine/engine.d.ts +26 -0
  130. package/dist/web-video-engine/engine.d.ts.map +1 -0
  131. package/dist/web-video-engine/engine.js +873 -0
  132. package/dist/web-video-engine/engine.js.map +1 -0
  133. package/dist/web-video-engine/engine.worker.d.ts +2 -0
  134. package/dist/web-video-engine/engine.worker.d.ts.map +1 -0
  135. package/dist/web-video-engine/engine.worker.js +31320 -0
  136. package/dist/web-video-engine/engine.worker.js.map +1 -0
  137. package/dist/web-video-engine/frame-cache.d.ts +129 -0
  138. package/dist/web-video-engine/frame-cache.d.ts.map +1 -0
  139. package/dist/web-video-engine/frame-extractor.d.ts +28 -0
  140. package/dist/web-video-engine/frame-extractor.d.ts.map +1 -0
  141. package/dist/web-video-engine/frame-timeline-GINI8Gum.js +560 -0
  142. package/dist/web-video-engine/frame-timeline-GINI8Gum.js.map +1 -0
  143. package/dist/web-video-engine/frame-timeline.d.ts +71 -0
  144. package/dist/web-video-engine/frame-timeline.d.ts.map +1 -0
  145. package/dist/web-video-engine/frame-walker.d.ts +74 -0
  146. package/dist/web-video-engine/frame-walker.d.ts.map +1 -0
  147. package/dist/web-video-engine/index.d.ts +3 -0
  148. package/dist/web-video-engine/index.d.ts.map +1 -0
  149. package/dist/web-video-engine/index.js +3 -0
  150. package/dist/web-video-engine/index.js.map +1 -0
  151. package/dist/web-video-engine/key-packet.d.ts +65 -0
  152. package/dist/web-video-engine/key-packet.d.ts.map +1 -0
  153. package/dist/web-video-engine/keyframe-index.d.ts +91 -0
  154. package/dist/web-video-engine/keyframe-index.d.ts.map +1 -0
  155. package/dist/web-video-engine/mirror-store.d.ts +56 -0
  156. package/dist/web-video-engine/mirror-store.d.ts.map +1 -0
  157. package/dist/web-video-engine/renderer.d.ts +94 -0
  158. package/dist/web-video-engine/renderer.d.ts.map +1 -0
  159. package/dist/web-video-engine/rotation.d.ts +35 -0
  160. package/dist/web-video-engine/rotation.d.ts.map +1 -0
  161. package/dist/web-video-engine/scrub-controller.d.ts +376 -0
  162. package/dist/web-video-engine/scrub-controller.d.ts.map +1 -0
  163. package/dist/web-video-engine/scrub-cursor.d.ts +342 -0
  164. package/dist/web-video-engine/scrub-cursor.d.ts.map +1 -0
  165. package/dist/web-video-engine/scrub-trajectory.d.ts +30 -0
  166. package/dist/web-video-engine/scrub-trajectory.d.ts.map +1 -0
  167. package/dist/web-video-engine/source-residency.d.ts +57 -0
  168. package/dist/web-video-engine/source-residency.d.ts.map +1 -0
  169. package/dist/web-video-engine/trace-recorder.d.ts +164 -0
  170. package/dist/web-video-engine/trace-recorder.d.ts.map +1 -0
  171. package/dist/web-video-engine/types.d.ts +204 -0
  172. package/dist/web-video-engine/types.d.ts.map +1 -0
  173. package/dist/web-video-engine/video-engine.d.ts +426 -0
  174. package/dist/web-video-engine/video-engine.d.ts.map +1 -0
  175. package/dist/web-video-engine/webgpu-renderer.d.ts +62 -0
  176. package/dist/web-video-engine/webgpu-renderer.d.ts.map +1 -0
  177. package/dist/web-video-engine/worker-bridge.d.ts +12 -0
  178. package/dist/web-video-engine/worker-bridge.d.ts.map +1 -0
  179. package/dist/web-video-engine/worker-dispatch.d.ts +17 -0
  180. package/dist/web-video-engine/worker-dispatch.d.ts.map +1 -0
  181. package/dist/web-video-engine/worker-protocol.d.ts +267 -0
  182. package/dist/web-video-engine/worker-protocol.d.ts.map +1 -0
  183. package/node_modules/supervision-js-core/dist/detections/buffered-detection-timeline.d.ts.map +1 -1
  184. package/node_modules/supervision-js-core/dist/detections/composite-detection-frame-source.d.ts.map +1 -1
  185. package/node_modules/supervision-js-core/dist/index.d.ts +4 -3
  186. package/node_modules/supervision-js-core/dist/index.d.ts.map +1 -1
  187. package/node_modules/supervision-js-core/dist/index.js +1075 -266
  188. package/node_modules/supervision-js-core/dist/index.js.map +1 -1
  189. package/node_modules/supervision-js-core/dist/styles/default-annotation-presentation.d.ts.map +1 -1
  190. package/node_modules/supervision-js-core/dist/styles/interaction-style.d.ts +4 -36
  191. package/node_modules/supervision-js-core/dist/styles/interaction-style.d.ts.map +1 -1
  192. package/node_modules/supervision-js-core/dist/styles/polyline-style.d.ts +5 -0
  193. package/node_modules/supervision-js-core/dist/styles/polyline-style.d.ts.map +1 -1
  194. package/node_modules/supervision-js-core/dist/types/annotation-renderer.d.ts +92 -7
  195. package/node_modules/supervision-js-core/dist/types/annotation-renderer.d.ts.map +1 -1
  196. package/node_modules/supervision-js-core/dist/types/detection-timeline.d.ts +179 -17
  197. package/node_modules/supervision-js-core/dist/types/detection-timeline.d.ts.map +1 -1
  198. package/node_modules/supervision-js-core/dist/types/media-rendering.d.ts +78 -2
  199. package/node_modules/supervision-js-core/dist/types/media-rendering.d.ts.map +1 -1
  200. package/node_modules/supervision-js-core/dist/types/polyline-style.d.ts +2 -0
  201. package/node_modules/supervision-js-core/dist/types/polyline-style.d.ts.map +1 -1
  202. package/node_modules/supervision-js-core/dist/types/session-lifecycle.d.ts +16 -0
  203. package/node_modules/supervision-js-core/dist/types/session-lifecycle.d.ts.map +1 -1
  204. package/node_modules/supervision-js-core/dist/utils/detection-conversions.d.ts.map +1 -1
  205. package/node_modules/supervision-js-core/dist/utils/detection-frames.d.ts +5 -0
  206. package/node_modules/supervision-js-core/dist/utils/detection-frames.d.ts.map +1 -1
  207. package/node_modules/supervision-js-core/dist/utils/detection-masks.d.ts +9 -0
  208. package/node_modules/supervision-js-core/dist/utils/detection-masks.d.ts.map +1 -1
  209. package/node_modules/supervision-js-core/dist/utils/detection-ranges.d.ts +18 -0
  210. package/node_modules/supervision-js-core/dist/utils/detection-ranges.d.ts.map +1 -0
  211. package/node_modules/supervision-js-core/dist/utils/id-mask-frame.d.ts +15 -2
  212. package/node_modules/supervision-js-core/dist/utils/id-mask-frame.d.ts.map +1 -1
  213. package/node_modules/supervision-js-core/dist/utils/wait-bound.d.ts +7 -0
  214. package/node_modules/supervision-js-core/dist/utils/wait-bound.d.ts.map +1 -0
  215. package/package.json +22 -1
@@ -0,0 +1,1678 @@
1
+ import { CanvasSink, EncodedPacketSink, Input, ALL_FORMATS, VideoSampleSink, ReadableStreamSource, BlobSource, UrlSource, UnsupportedInputFormatError } from 'mediabunny';
2
+ import { V as VideoEngineError, a as VideoEngineErrorCode, S as SCRUB, b as asSec, n as nativeResolution, r as resolveDecodeDimensions, c as SourceKind, F as FRAME_TIMELINE, d as FrameTimeline } from './frame-timeline-GINI8Gum.js';
3
+
4
+ /**
5
+ * An H.264 sync sample that is not an IDR frame cannot open a WebCodecs decode
6
+ * session: Chrome rejects the chunk with "An EncodedVideoChunk was marked as
7
+ * type key but wasn't a key frame" and emits no frames. Adding a recovery-point
8
+ * SEI to the access unit declares it a valid entry point, which is enough for the
9
+ * decoder to accept it and decode forward from there.
10
+ *
11
+ * The decoder demands an entry point for exactly one chunk, the first after
12
+ * configure() or flush(), and putting the SEI on any other chunk misdescribes
13
+ * the stream. KeyPacketRequirement is what tracks which chunk that is.
14
+ */
15
+ /**
16
+ * SEI NAL (unit type 6) carrying one recovery_point payload: recovery_frame_cnt
17
+ * 0, so this access unit is itself the recovery point, and exact_match_flag 1,
18
+ * so frames from here reconstruct exactly. Closed by RBSP trailing bits.
19
+ */
20
+ const RECOVERY_POINT_SEI_NAL = Uint8Array.of(0x06, 0x06, 0x01, 0xc2, 0x80);
21
+ /** Low five bits of a NAL's first byte, the AVC unit type. */
22
+ const NAL_TYPE_MASK = 0x1f;
23
+ /**
24
+ * An access unit delimiter has to be the first NAL of its access unit, so it is
25
+ * the one unit the SEI goes behind rather than ahead of.
26
+ */
27
+ const ACCESS_UNIT_DELIMITER = 9;
28
+ /** Coded slice of an IDR picture. */
29
+ const IDR_SLICE = 5;
30
+ /**
31
+ * Scalable and multiview extension NALs. A decoder configured from an avcC
32
+ * record decodes the base layer and reads none of them, and Chromium's key-frame
33
+ * detector reads the opening chunk as something other than a key frame when they
34
+ * are in it.
35
+ */
36
+ const EXTENSION_NAL_FIRST = 20;
37
+ const EXTENSION_NAL_LAST = 31;
38
+ /** Byte of the avcC record holding lengthSizeMinusOne in its low two bits. */
39
+ const LENGTH_SIZE_MINUS_ONE_OFFSET = 4;
40
+ const LENGTH_SIZE_MINUS_ONE_MASK = 0b11;
41
+ /**
42
+ * Prefix width per lengthSizeMinusOne encoding. ISO/IEC 14496-15 reserves the
43
+ * encoding 2, so a record carrying it names no width at all.
44
+ */
45
+ const PREFIX_WIDTHS = [1, 2, undefined, 4];
46
+ /**
47
+ * Width of the AVCC length prefix this track frames every NAL with, read from
48
+ * its avcC record, or null when the description names no usable width. Files
49
+ * carry 1, 2, and 4, so nothing downstream may assume one of them.
50
+ */
51
+ function nalPrefixWidth(description) {
52
+ if (!description)
53
+ return null;
54
+ const record = avccRecord(description);
55
+ if (record.length <= LENGTH_SIZE_MINUS_ONE_OFFSET)
56
+ return null;
57
+ const encoding = record[LENGTH_SIZE_MINUS_ONE_OFFSET] & LENGTH_SIZE_MINUS_ONE_MASK;
58
+ return PREFIX_WIDTHS[encoding] ?? null;
59
+ }
60
+ function avccRecord(description) {
61
+ if (ArrayBuffer.isView(description)) {
62
+ return new Uint8Array(description.buffer, description.byteOffset, description.byteLength);
63
+ }
64
+ return new Uint8Array(description);
65
+ }
66
+ /** Big-endian NAL length over exactly `width` bytes, the AVCC framing. */
67
+ function writePrefix(target, width, length) {
68
+ for (let i = 0; i < width; i++) {
69
+ target[i] = (length >>> ((width - 1 - i) * 8)) & 0xff;
70
+ }
71
+ }
72
+ /**
73
+ * The packet's NAL units in order, or null when the prefix widths do not walk
74
+ * to exactly the end of the packet. That reading is the wrong one, and dropping
75
+ * or reordering anything on it would throw away picture data.
76
+ */
77
+ function nalUnits(packetBytes, prefixWidth) {
78
+ const units = [];
79
+ let offset = 0;
80
+ while (offset < packetBytes.length) {
81
+ if (offset + prefixWidth > packetBytes.length)
82
+ return null;
83
+ let length = 0;
84
+ for (let i = 0; i < prefixWidth; i++)
85
+ length = length * 256 + packetBytes[offset + i];
86
+ offset += prefixWidth;
87
+ if (length === 0 || offset + length > packetBytes.length)
88
+ return null;
89
+ units.push(packetBytes.subarray(offset, offset + length));
90
+ offset += length;
91
+ }
92
+ return units;
93
+ }
94
+ function nalType(unit) {
95
+ return unit[0] & NAL_TYPE_MASK;
96
+ }
97
+ /** The units re-framed at `prefixWidth`, the only framing this track's decoder
98
+ * can parse the packet under. */
99
+ function frameNalUnits(units, prefixWidth) {
100
+ let total = 0;
101
+ for (const unit of units)
102
+ total += prefixWidth + unit.length;
103
+ const framed = new Uint8Array(total);
104
+ let offset = 0;
105
+ for (const unit of units) {
106
+ writePrefix(framed.subarray(offset), prefixWidth, unit.length);
107
+ offset += prefixWidth;
108
+ framed.set(unit, offset);
109
+ offset += unit.length;
110
+ }
111
+ return framed;
112
+ }
113
+ /**
114
+ * Whether this access unit carries an IDR slice.
115
+ *
116
+ * A container's sync table names sync samples, and an H.264 sync sample is
117
+ * usually an ordinary I picture rather than an IDR. The two differ in what a
118
+ * decoder may assume about the pictures that follow: an IDR empties the
119
+ * reference list, so nothing after it can name a picture from before it, while
120
+ * an I picture leaves the list standing and the next reference frame is free to
121
+ * name pictures the entry point never decoded. Only the IDR is an entry point
122
+ * the bitstream cannot invalidate.
123
+ *
124
+ * Unreadable framing reads as not-IDR: the caller's fallback is to enter
125
+ * further back, which costs a walk and is never wrong.
126
+ */
127
+ function isIdrAccessUnit(packetBytes, prefixWidth) {
128
+ const units = nalUnits(packetBytes, prefixWidth);
129
+ if (!units)
130
+ return false;
131
+ return units.some((unit) => nalType(unit) === IDR_SLICE);
132
+ }
133
+ /**
134
+ * The access unit as the chunk that opens a decode, carrying the recovery-point
135
+ * SEI that makes it a legal entry point.
136
+ *
137
+ * Where the SEI goes is the whole of it: an access unit delimiter, when the
138
+ * packet has one, is required to be the access unit's first NAL, so the SEI goes
139
+ * behind it. Ahead of it the access unit is malformed, and a decoder reading the
140
+ * opening chunk to decide whether it is really a key frame is entitled to say it
141
+ * is not.
142
+ */
143
+ function openingKeyPacket(packetBytes, prefixWidth) {
144
+ const units = nalUnits(packetBytes, prefixWidth);
145
+ if (!units)
146
+ return seiAhead(packetBytes, prefixWidth);
147
+ const kept = units.filter((unit) => {
148
+ const type = nalType(unit);
149
+ return type < EXTENSION_NAL_FIRST || type > EXTENSION_NAL_LAST;
150
+ });
151
+ const seiAt = kept.length > 0 && nalType(kept[0]) === ACCESS_UNIT_DELIMITER ? 1 : 0;
152
+ kept.splice(seiAt, 0, RECOVERY_POINT_SEI_NAL);
153
+ return frameNalUnits(kept, prefixWidth);
154
+ }
155
+ /** `packetBytes` behind a recovery-point SEI framed at the file's own prefix
156
+ * width, since a prefix of any other width makes the whole packet unparseable. */
157
+ function seiAhead(packetBytes, prefixWidth) {
158
+ const seiBytes = prefixWidth + RECOVERY_POINT_SEI_NAL.length;
159
+ const framed = new Uint8Array(seiBytes + packetBytes.length);
160
+ writePrefix(framed, prefixWidth, RECOVERY_POINT_SEI_NAL.length);
161
+ framed.set(RECOVERY_POINT_SEI_NAL, prefixWidth);
162
+ framed.set(packetBytes, seiBytes);
163
+ return framed;
164
+ }
165
+ /**
166
+ * WebCodecs' standing demand for a key frame, which configure() and flush()
167
+ * both arm and the next submitted chunk clears.
168
+ */
169
+ class KeyPacketRequirement {
170
+ prefixWidth;
171
+ pending = true;
172
+ constructor(prefixWidth) {
173
+ this.prefixWidth = prefixWidth;
174
+ }
175
+ /** Whether the demand still stands, so the chunk about to satisfy it can
176
+ * also be typed as the key chunk the decoder is waiting for. */
177
+ get armed() {
178
+ return this.pending;
179
+ }
180
+ /** Call after every configure() and flush(). */
181
+ rearm() {
182
+ this.pending = true;
183
+ }
184
+ /**
185
+ * Bytes to submit for the next packet in decode order, carrying the
186
+ * recovery-point SEI while the demand stands. The container reports sync
187
+ * samples, never IDR frames, so the SEI goes on without inspecting the
188
+ * packet; ahead of a true IDR it is a NAL the decoder ignores.
189
+ */
190
+ satisfy(packetBytes) {
191
+ if (!this.pending)
192
+ return packetBytes;
193
+ this.pending = false;
194
+ return openingKeyPacket(packetBytes, this.prefixWidth);
195
+ }
196
+ }
197
+
198
+ /**
199
+ * Draws an image upright into a destination box, mirroring the transform
200
+ * mediabunny's own VideoSample.draw applies so a frame decoded by the runtime
201
+ * and a frame decoded by a mediabunny sink land the same pixels.
202
+ *
203
+ * dWidth and dHeight name the destination AFTER the turn, which is what the
204
+ * track's display size already is. So on a quarter turn the source is drawn at
205
+ * swapped extents inside the turned frame, and the scale below undoes the
206
+ * aspect that swap introduces.
207
+ */
208
+ function drawRotated(context, image, rotation, dx, dy, dWidth, dHeight) {
209
+ if (rotation === 0) {
210
+ context.drawImage(image, dx, dy, dWidth, dHeight);
211
+ return;
212
+ }
213
+ context.save();
214
+ context.translate(dx + dWidth / 2, dy + dHeight / 2);
215
+ context.rotate((rotation * Math.PI) / 180);
216
+ const aspectRatioChange = rotation % 180 === 0 ? 1 : dWidth / dHeight;
217
+ context.scale(1 / aspectRatioChange, aspectRatioChange);
218
+ context.drawImage(image, -dWidth / 2, -dHeight / 2, dWidth, dHeight);
219
+ context.restore();
220
+ }
221
+
222
+ /**
223
+ * Half a microsecond: a submitted timestamp survives the round trip through the
224
+ * decoder as whole microseconds, so anything closer than this is the same
225
+ * packet and anything further is a decoder counting on a clock of its own.
226
+ */
227
+ const TIMING_MATCH_S = 5e-7;
228
+ function anchorKey(timestampS) {
229
+ return Math.round(timestampS * MICROSECONDS_PER_SECOND);
230
+ }
231
+ /**
232
+ * Verification rejects every sync sample that is not a true IDR, which collapses
233
+ * a container's sync table down to the handful of real IDRs and drags the anchor
234
+ * back by that much. Off, the honest table stands and a recovery-point SEI makes
235
+ * the non-IDR anchor legal to decode from.
236
+ *
237
+ * The anchor is read with its bytes: it is both the packet the walk starts from
238
+ * and the first chunk submitted, and a reader refuses a metadata-only packet as
239
+ * the start of a full-data walk.
240
+ */
241
+ const ANCHOR_PROBE = { verifyKeyPackets: false };
242
+ /**
243
+ * The fallback anchor read: the last IDR at or before the target, found by
244
+ * reading bitstreams rather than trusting the sync table. Reached only once an
245
+ * optimistic anchor has actually failed, since it drags the entry point back to
246
+ * the previous IDR and every picture between there and the target is decoded
247
+ * and thrown away.
248
+ */
249
+ const IDR_PROBE = { verifyKeyPackets: true };
250
+ /** The packet that closes the anchor span is read for its timestamp alone. */
251
+ const SPAN_END_PROBE = {
252
+ metadataOnly: true,
253
+ verifyKeyPackets: false,
254
+ };
255
+ /**
256
+ * Chunks kept outstanding in the decoder. H.264 lets a decoder hold up to
257
+ * sixteen pictures before it has to emit one, and a stream that uses the whole
258
+ * buffer emits nothing at all until the seventeenth chunk arrives, so a
259
+ * shallower pipeline waits forever on the frame it just asked for.
260
+ */
261
+ const DECODER_PIPELINE_CHUNKS = 17;
262
+ /** Decoded frames held before the session stops feeding the decoder. These are
263
+ * full-resolution and the session's own to retain, unlike the pipeline above,
264
+ * which is the decoder's. */
265
+ const READY_FRAMES = 4;
266
+ /** Absorbs float error when a requested bound lands on a frame's timestamp. */
267
+ const BOUND_EPSILON_S = 1e-6;
268
+ /**
269
+ * Ceiling on waiting for ONE decoder output while chunks are in flight, which a
270
+ * healthy decoder answers in single-digit milliseconds. It is not the ceiling on
271
+ * a walk (that is the caller's, and a legitimate GOP walk runs far longer); it
272
+ * exists so a decoder that goes silent surfaces as a failure instead of parking
273
+ * the surface for the length of the walk ceiling. It only ever runs while output
274
+ * is owed, so an idle session cannot trip it however long it sits.
275
+ */
276
+ const OUTPUT_TIMEOUT_MS = 5_000;
277
+ const MICROSECONDS_PER_SECOND = 1e6;
278
+ /** The recovery-point SEI is an H.264 NAL behind an AVCC length prefix, so the
279
+ * session opens only codecs framed that way. */
280
+ const AVCC_H264_CODEC = /^avc1\./;
281
+ /**
282
+ * Width the session must frame its SEI at to drive a track with this config, or
283
+ * null when it cannot drive the track at all. The SEI it prepends to a non-IDR
284
+ * anchor is an H.264 NAL behind an AVCC length prefix, and the width of that
285
+ * prefix is only knowable from the avcC record in `description`, so a config
286
+ * without one is unusable however well-formed the rest of it looks.
287
+ */
288
+ function drivablePrefixWidth(config) {
289
+ if (!AVCC_H264_CODEC.test(config.codec))
290
+ return null;
291
+ return nalPrefixWidth(config.description);
292
+ }
293
+ /**
294
+ * Whether the session can drive a track with this decoder config. The gate that
295
+ * routes a source here and the constructor that refuses everything else read the
296
+ * same predicate.
297
+ */
298
+ function sessionDrivable(config) {
299
+ return drivablePrefixWidth(config) !== null;
300
+ }
301
+ /**
302
+ * One long-lived VideoDecoder, held across seeks.
303
+ *
304
+ * A decoder can only ever move forward, and every flush re-arms its demand for a
305
+ * key frame, so a runtime that flushes per retrieval pays a walk back to the
306
+ * nearest true IDR on every seek. This session flushes only at end of stream:
307
+ * a seek at-or-ahead of the read head, inside the current anchor span, decodes
308
+ * only the packets between the two, and a backward jump or a jump past the span
309
+ * re-anchors. Positioning is the only thing that costs a walk.
310
+ *
311
+ * One decoder serving several readers is the whole point, and it is also the
312
+ * hazard: a scrub's frameAt and playback's framesFrom drive the same decoder,
313
+ * the same packet iterator, and the same queue of decoded frames. So every
314
+ * retrieval takes the decoder exclusively for the length of one frame, and a
315
+ * reader displaced by someone else's re-anchor is told, rather than left to read
316
+ * the emptied queue as end of stream and stop for good.
317
+ *
318
+ * Frames are handed out as VideoSampleLike and their close is the caller's, the
319
+ * same obligation the zero-copy sample path carries.
320
+ */
321
+ class DecodeSession {
322
+ options;
323
+ keyPacket;
324
+ prefixWidth;
325
+ decoder = null;
326
+ iterator = null;
327
+ peeked = null;
328
+ /** Timestamp of the key packet after the anchor: the end of the span a
329
+ * forward seek can reach without re-anchoring. */
330
+ spanEndS = Infinity;
331
+ exhausted = true;
332
+ /** Timings the decoder still owes pictures for, in presentation order. */
333
+ pending = [];
334
+ decoded = [];
335
+ wake = null;
336
+ /**
337
+ * Latched once the decoder is judged unable to decode this source at all.
338
+ * Every later retrieval refuses with it instead of re-anchoring onto the
339
+ * same failure, which is what turns a silent forever-retry into one honest
340
+ * error the caller can show.
341
+ */
342
+ stalledError = null;
343
+ /**
344
+ * The current entry point's failure, held only until the walk that is owed a
345
+ * picture decides what it means. It condemns the anchor, never the session:
346
+ * a source whose sync table names one bad entry point still decodes from
347
+ * every other one, and latching the first failure is what turned a seek into
348
+ * a poisoned GOP into a player that never painted again.
349
+ */
350
+ anchorError = null;
351
+ /** The entry currently feeding the decoder, or null before the first one. */
352
+ entry = null;
353
+ /** Entry points a decoder error has ruled out, by whole-microsecond
354
+ * timestamp. Only ever grows: a bitstream does not change. */
355
+ rejectedAnchors = new Set();
356
+ /** Where the live retrieval wants to be, so a re-anchor after a failed entry
357
+ * aims at the position the caller asked for rather than the anchor's. */
358
+ entryTargetS = 0;
359
+ /** The last picture handed out since the caller last chose a position, so a
360
+ * re-anchor mid-walk resumes rather than replays. */
361
+ servedS = -Infinity;
362
+ closed = false;
363
+ anchors = 0;
364
+ framesDecodedCount = 0;
365
+ /** Where the decoder sits, kept in step with what it hands out. Read
366
+ * synchronously by callers deciding what to ask for, so it cannot await. */
367
+ reachable = -Infinity;
368
+ /** Serializes retrievals; see the class note on shared-decoder exclusivity. */
369
+ tail = Promise.resolve();
370
+ /** Bumped by every re-anchor, so a reader can tell whether the decoder is
371
+ * still where it left it or has been moved under it by another reader. */
372
+ epoch = 0;
373
+ outputTimeoutMs;
374
+ rotation;
375
+ constructor(options) {
376
+ this.options = options;
377
+ this.outputTimeoutMs = options.outputTimeoutMs ?? OUTPUT_TIMEOUT_MS;
378
+ this.rotation = options.rotation;
379
+ const prefixWidth = drivablePrefixWidth(options.config);
380
+ if (prefixWidth === null) {
381
+ throw new VideoEngineError(VideoEngineErrorCode.DecodeUnsupported, `DecodeSession: ${options.config.codec} is not AVCC-framed H.264`);
382
+ }
383
+ this.prefixWidth = prefixWidth;
384
+ this.keyPacket = new KeyPacketRequirement(prefixWidth);
385
+ }
386
+ /** Times the session has configured the decoder onto an anchor. */
387
+ get anchorCount() {
388
+ return this.anchors;
389
+ }
390
+ /** Every frame the decoder has ever output, including walk pre-roll that is
391
+ * discarded before the target: the honest denominator for what a paint
392
+ * actually cost. Monotonic for the session's lifetime. */
393
+ get framesDecoded() {
394
+ return this.framesDecodedCount;
395
+ }
396
+ /** See SessionFrameSource. The oldest frame decoded but not yet handed out
397
+ * when there is one, since those are still servable, else the last position
398
+ * handed out. */
399
+ get reachableFromS() {
400
+ const queued = this.decoded[0];
401
+ if (queued)
402
+ return queued.timestampS;
403
+ return this.reachable;
404
+ }
405
+ /** The frame at or before `targetS`, or null when nothing precedes it. */
406
+ async frameAt(targetS) {
407
+ const landed = await this.exclusive(() => this.land(targetS));
408
+ return landed ? videoFrameSample(landed, this.rotation) : null;
409
+ }
410
+ /**
411
+ * Frames from `startS` onward, beginning at the frame at or before it. The
412
+ * walk from the anchor up to `startS` is decoded but not yielded, since the
413
+ * caller asked to start there.
414
+ *
415
+ * Playback rides this for the length of a session, so it outlives any number
416
+ * of scrubs. When one of those moved the decoder, the walk resumes from the
417
+ * last frame it handed out rather than ending.
418
+ */
419
+ async *framesFrom(startS) {
420
+ let handedOutS = null;
421
+ let seenEpoch = this.epoch;
422
+ for (;;) {
423
+ const picture = await this.exclusive(() => {
424
+ if (handedOutS === null)
425
+ return this.land(startS);
426
+ if (seenEpoch !== this.epoch)
427
+ return this.resumeAfter(handedOutS);
428
+ return this.pull(Infinity);
429
+ });
430
+ if (!picture)
431
+ return;
432
+ handedOutS = picture.timestampS;
433
+ seenEpoch = this.epoch;
434
+ yield videoFrameSample(picture, this.rotation);
435
+ }
436
+ }
437
+ /**
438
+ * Every frame decoded while covering `[startS, endS]`, including the walk
439
+ * from the anchor. Those prefix frames cost the same decode either way, so
440
+ * yielding them hands the caller a span of frames for the price of the one
441
+ * it asked for.
442
+ *
443
+ * This serves speculative sweeps, so a re-anchor by anyone else ends it:
444
+ * chasing the span back would spend a foreground decode's worth of decoder
445
+ * time on frames nobody is waiting for.
446
+ */
447
+ async *framesCovering(startS, endS) {
448
+ let positioned = false;
449
+ let seenEpoch = 0;
450
+ for (;;) {
451
+ const picture = await this.exclusive(async () => {
452
+ if (!positioned) {
453
+ await this.positionFor(startS);
454
+ positioned = true;
455
+ }
456
+ else if (seenEpoch !== this.epoch) {
457
+ return null;
458
+ }
459
+ return this.pull(endS);
460
+ });
461
+ if (!picture)
462
+ return;
463
+ seenEpoch = this.epoch;
464
+ yield videoFrameSample(picture, this.rotation);
465
+ }
466
+ }
467
+ /**
468
+ * Runs `op` with the decoder to itself. Retrievals interleave at frame
469
+ * granularity, which bounds how long a foreground seek waits behind a
470
+ * background sweep at one frame, while keeping any one walk's view of the
471
+ * packet iterator, the in-flight count, and the decoded queue consistent.
472
+ */
473
+ exclusive(op) {
474
+ const run = this.tail.then(op, op);
475
+ this.tail = run.then(() => undefined, () => undefined);
476
+ return run;
477
+ }
478
+ /**
479
+ * The first frame after `afterS` once another reader has moved the decoder.
480
+ * Re-positions and discards the frames already handed out, so a walk picks
481
+ * up exactly where it left off however far away the decoder was taken.
482
+ */
483
+ async resumeAfter(afterS) {
484
+ await this.positionFor(afterS);
485
+ for (;;) {
486
+ const picture = await this.pull(Infinity);
487
+ if (!picture)
488
+ return null;
489
+ if (picture.timestampS > afterS + BOUND_EPSILON_S)
490
+ return picture;
491
+ picture.frame.close();
492
+ }
493
+ }
494
+ close() {
495
+ if (this.closed)
496
+ return;
497
+ this.closed = true;
498
+ void this.iterator?.return();
499
+ this.iterator = null;
500
+ this.peeked = null;
501
+ this.exhausted = true;
502
+ this.discardDecoded();
503
+ this.decoder?.close();
504
+ this.decoder = null;
505
+ this.wake?.();
506
+ }
507
+ /** Decodes through `targetS` and returns the last frame at or before it,
508
+ * closing the frames walked past. */
509
+ async land(targetS) {
510
+ await this.positionFor(targetS);
511
+ let landed = null;
512
+ for (;;) {
513
+ const picture = await this.pull(targetS);
514
+ if (!picture)
515
+ return landed;
516
+ landed?.frame.close();
517
+ landed = picture;
518
+ }
519
+ }
520
+ async positionFor(targetS) {
521
+ if (this.closed)
522
+ return;
523
+ if (this.stalledError)
524
+ throw this.stalledError;
525
+ this.entryTargetS = targetS;
526
+ if (this.decoder && !this.exhausted) {
527
+ const headS = await this.readHeadS();
528
+ if (headS !== null && targetS >= headS && targetS < this.spanEndS)
529
+ return;
530
+ }
531
+ this.servedS = -Infinity;
532
+ await this.anchorAt(targetS);
533
+ }
534
+ /** The earliest position still reachable without re-anchoring: the oldest
535
+ * frame decoded but not yet handed out, or the next packet in line when
536
+ * there is none. Decode runs ahead of the read, so the packet alone would
537
+ * put the head past frames the session can still serve. */
538
+ async readHeadS() {
539
+ const queued = this.decoded[0];
540
+ if (queued)
541
+ return queued.timestampS;
542
+ const packet = await this.peek();
543
+ return packet ? packet.timestamp : null;
544
+ }
545
+ async anchorAt(targetS) {
546
+ const entry = await this.resolveEntry(targetS);
547
+ void this.iterator?.return();
548
+ this.iterator = null;
549
+ this.peeked = null;
550
+ this.exhausted = true;
551
+ this.entry = null;
552
+ this.anchorError = null;
553
+ this.quiesce();
554
+ if (!entry || this.closed)
555
+ return;
556
+ this.spanEndS = await this.spanEndAfter(entry.anchor, targetS);
557
+ this.configureDecoder();
558
+ this.iterator = this.options.packets.packets(entry.anchor);
559
+ this.entry = entry;
560
+ this.exhausted = false;
561
+ this.anchors++;
562
+ this.epoch++;
563
+ this.reachable = entry.anchor.timestamp;
564
+ }
565
+ /**
566
+ * The packet to open a decode of `targetS` from: the container's own sync
567
+ * sample, unless a decoder has already proved that one is not a legal entry
568
+ * point, in which case the last verified IDR at or before the target.
569
+ *
570
+ * A sync sample that is itself an IDR is already the furthest-back entry
571
+ * worth reaching for, so a failure there is a failure of the source.
572
+ */
573
+ async resolveEntry(targetS) {
574
+ const sync = await this.options.packets.getKeyPacket(targetS, ANCHOR_PROBE);
575
+ if (sync) {
576
+ const key = anchorKey(sync.timestamp);
577
+ if (!this.rejectedAnchors.has(key)) {
578
+ return {
579
+ anchor: sync,
580
+ key,
581
+ hasFallback: !isIdrAccessUnit(sync.data, this.prefixWidth),
582
+ };
583
+ }
584
+ }
585
+ const idr = await this.options.packets.getKeyPacket(targetS, IDR_PROBE);
586
+ if (!idr)
587
+ return null;
588
+ return { anchor: idr, key: anchorKey(idr.timestamp), hasFallback: false };
589
+ }
590
+ /**
591
+ * Where the span a forward seek can reach without re-anchoring ends: the
592
+ * first sync sample past the target that is still worth anchoring at.
593
+ *
594
+ * Skipping the rejected ones is what keeps the pre-roll paid for once. Ending
595
+ * the span at an entry point already known not to decode would send the very
596
+ * next seek into that GOP back to the same rejected anchor and back through
597
+ * the same walk to recover from it.
598
+ */
599
+ async spanEndAfter(anchor, targetS) {
600
+ let key = anchor;
601
+ for (;;) {
602
+ const next = await this.options.packets.getNextKeyPacket(key, SPAN_END_PROBE);
603
+ if (!next)
604
+ return Infinity;
605
+ if (next.timestamp > targetS + BOUND_EPSILON_S &&
606
+ !this.rejectedAnchors.has(anchorKey(next.timestamp))) {
607
+ return next.timestamp;
608
+ }
609
+ key = next;
610
+ }
611
+ }
612
+ /** Drops the work in flight and the frames it produced, in one turn, so no
613
+ * output from the old anchor can land against the new one. */
614
+ quiesce() {
615
+ this.decoder?.reset();
616
+ this.pending.length = 0;
617
+ this.discardDecoded();
618
+ }
619
+ configureDecoder() {
620
+ try {
621
+ if (!this.decoder) {
622
+ // Named so the callback can check that the decoder reporting the
623
+ // failure is still the one driving the session: a decoder dropped for
624
+ // erroring may report again afterwards, and that report must not
625
+ // condemn the anchor built to replace it.
626
+ const built = this.options.createDecoder({
627
+ output: (frame) => this.receive(frame),
628
+ error: (error) => this.fail(built, error),
629
+ });
630
+ this.decoder = built;
631
+ }
632
+ // Without prompt per-frame emission a session that never flushes has
633
+ // no way to get its frames out, so the whole flush-free design rests
634
+ // here.
635
+ this.decoder.configure({
636
+ ...this.options.config,
637
+ optimizeForLatency: true,
638
+ });
639
+ }
640
+ catch (cause) {
641
+ throw this.stall("the decoder refused to configure", cause);
642
+ }
643
+ this.keyPacket.rearm();
644
+ }
645
+ /** One frame at or before `boundS`, or null once the bound is passed. A frame
646
+ * decoded past the bound stays queued for the next read rather than being
647
+ * handed out or thrown away. */
648
+ async pull(boundS) {
649
+ // Every hand-out moves the decoder forward past that frame.
650
+ for (;;) {
651
+ if (this.closed)
652
+ return null;
653
+ if (this.stalledError)
654
+ throw this.stalledError;
655
+ const failed = this.anchorError;
656
+ if (failed) {
657
+ await this.enterFurtherBack(failed);
658
+ continue;
659
+ }
660
+ await this.fill();
661
+ const ready = this.decoded[0];
662
+ if (ready) {
663
+ // Re-entering further back re-decodes ground the walk already
664
+ // covered, and the caller has seen those pictures.
665
+ if (ready.timestampS <= this.servedS + BOUND_EPSILON_S) {
666
+ this.decoded.shift();
667
+ ready.frame.close();
668
+ continue;
669
+ }
670
+ if (ready.timestampS > boundS + BOUND_EPSILON_S)
671
+ return null;
672
+ this.decoded.shift();
673
+ this.reachable = ready.timestampS;
674
+ this.servedS = ready.timestampS;
675
+ return ready;
676
+ }
677
+ // The pipeline holds requests a failed decoder will never answer, so
678
+ // waiting on them is waiting out the ceiling for nothing.
679
+ if (this.anchorError)
680
+ continue;
681
+ if (this.pending.length === 0)
682
+ return null;
683
+ // Nothing is left to submit at end of stream, so a flush is the only
684
+ // way to get the last frames out.
685
+ if (this.exhausted)
686
+ await this.drain();
687
+ else
688
+ await this.awaitOutput();
689
+ }
690
+ }
691
+ /**
692
+ * Re-opens the decode from the last entry point ahead of the failed one,
693
+ * which for an open GOP means the previous IDR plus a walk to the target.
694
+ *
695
+ * The failed anchor is struck off for the life of the session, so the cost is
696
+ * paid once per bad entry point rather than once per seek into it. When there
697
+ * is nothing further back to enter from, the failure is the source's and it
698
+ * latches here.
699
+ */
700
+ async enterFurtherBack(failure) {
701
+ const entry = this.entry;
702
+ if (!entry?.hasFallback)
703
+ throw this.latch(failure);
704
+ this.rejectedAnchors.add(entry.key);
705
+ // An errored WebCodecs decoder is already closed and cannot be
706
+ // reconfigured, so the replacement anchor needs a replacement decoder.
707
+ this.decoder = null;
708
+ await this.anchorAt(Math.max(this.entryTargetS, this.servedS));
709
+ if (!this.entry)
710
+ throw this.latch(failure);
711
+ }
712
+ /**
713
+ * Keeps the decoder's pipeline fed, regardless of which frame is being read.
714
+ * A decoder emits nothing until it holds enough pictures, so submitting only
715
+ * as far as the requested frame leaves the session waiting on output it is
716
+ * refusing to make possible. Feeding stops once the frames already decoded
717
+ * pile up, which is what bounds the memory the session holds.
718
+ */
719
+ async fill() {
720
+ while (this.decoder &&
721
+ // The error arrives between two of the reads below, and a decoder that
722
+ // has reported one is closed: every further chunk is refused, and being
723
+ // refused is what would condemn the whole session for a failure the
724
+ // anchor already owns.
725
+ !this.anchorError &&
726
+ this.pending.length < DECODER_PIPELINE_CHUNKS &&
727
+ this.decoded.length < READY_FRAMES) {
728
+ const packet = await this.peek();
729
+ if (!packet)
730
+ return;
731
+ if (this.closed || this.anchorError)
732
+ return;
733
+ this.peeked = null;
734
+ this.awaitTiming({
735
+ timestampS: packet.timestamp,
736
+ durationS: packet.duration,
737
+ });
738
+ // The chunk that opens the decode is the only one submitted as a key
739
+ // chunk: a later sync sample is a recovery point, not an IDR, and the
740
+ // decoder verifies the claim. It is the same chunk that carries the
741
+ // SEI, so one latch answers both.
742
+ const opensDecode = this.keyPacket.armed;
743
+ const data = this.keyPacket.satisfy(packet.data);
744
+ try {
745
+ this.decoder.decode({
746
+ type: opensDecode ? "key" : "delta",
747
+ timestamp: Math.round(packet.timestamp * MICROSECONDS_PER_SECOND),
748
+ duration: Math.round(packet.duration * MICROSECONDS_PER_SECOND),
749
+ data,
750
+ });
751
+ }
752
+ catch (cause) {
753
+ throw this.stall("the decoder refused a chunk", cause);
754
+ }
755
+ }
756
+ }
757
+ async peek() {
758
+ if (this.peeked)
759
+ return this.peeked;
760
+ if (!this.iterator || this.exhausted)
761
+ return null;
762
+ const result = await this.iterator.next();
763
+ if (result.done) {
764
+ this.exhausted = true;
765
+ return null;
766
+ }
767
+ this.peeked = result.value;
768
+ return this.peeked;
769
+ }
770
+ async drain() {
771
+ const decoder = this.decoder;
772
+ if (!decoder)
773
+ return;
774
+ await decoder.flush();
775
+ this.pending.length = 0;
776
+ this.keyPacket.rearm();
777
+ }
778
+ awaitOutput() {
779
+ return new Promise((resolve, reject) => {
780
+ const owed = this.pending.length;
781
+ const timer = setTimeout(() => {
782
+ this.wake = null;
783
+ // Drop the work this was waiting on before handing the failure
784
+ // out. The chunks it timed out on stay counted as in flight
785
+ // otherwise, and the next retrieval inherits a decoder that owes
786
+ // frames it will never produce, so it waits out the same timeout
787
+ // again instead of re-anchoring onto a clean one.
788
+ this.quiesce();
789
+ this.exhausted = true;
790
+ // Which failure this is turns on the output counter, not on the
791
+ // timer: a decoder that has handed frames back and then gone quiet
792
+ // is one a rebuild can recover, and a decoder that has answered a
793
+ // full pipeline of requests with nothing at all may never have been
794
+ // given a legal place to start, which is the anchor's to answer for.
795
+ if (this.framesDecodedCount === 0) {
796
+ this.anchorError ??= new VideoEngineError(VideoEngineErrorCode.DecoderStalled, `DecodeSession: the decoder acknowledged ${owed} decode requests and produced no frame`);
797
+ resolve();
798
+ return;
799
+ }
800
+ reject(new VideoEngineError(VideoEngineErrorCode.BackendCrashed, `DecodeSession: decoder produced no output in ${this.outputTimeoutMs}ms with ${owed} chunks in flight`));
801
+ }, this.outputTimeoutMs);
802
+ this.wake = () => {
803
+ this.wake = null;
804
+ clearTimeout(timer);
805
+ resolve();
806
+ };
807
+ });
808
+ }
809
+ /**
810
+ * Files a submitted chunk's timing in presentation order, which is the order
811
+ * pictures come back in and is not the order chunks go in on a B-frame
812
+ * source.
813
+ */
814
+ awaitTiming(timing) {
815
+ let at = this.pending.length;
816
+ while (at > 0 && this.pending[at - 1].timestampS > timing.timestampS)
817
+ at--;
818
+ this.pending.splice(at, 0, timing);
819
+ }
820
+ /**
821
+ * A decoder that echoes the timestamp it was handed names its own pending
822
+ * entry, which survives an output being dropped. One that counts from an
823
+ * origin of its own names nothing, and position is all that is left. Taking
824
+ * position alone would let a single dropped output shift every picture after
825
+ * it onto the wrong detections, permanently and without a symptom.
826
+ */
827
+ claimTiming(frame) {
828
+ const submittedS = frame.timestamp / MICROSECONDS_PER_SECOND;
829
+ const at = this.pending.findIndex((timing) => Math.abs(timing.timestampS - submittedS) <= TIMING_MATCH_S);
830
+ return at === -1 ? this.pending.shift() : this.pending.splice(at, 1)[0];
831
+ }
832
+ receive(frame) {
833
+ this.framesDecodedCount += 1;
834
+ const timing = this.claimTiming(frame);
835
+ if (this.closed || !timing) {
836
+ frame.close();
837
+ return;
838
+ }
839
+ this.decoded.push({ frame, ...timing });
840
+ this.wake?.();
841
+ }
842
+ fail(from, error) {
843
+ if (from !== this.decoder)
844
+ return;
845
+ this.anchorError ??= new VideoEngineError(VideoEngineErrorCode.DecoderStalled, "DecodeSession: the decoder reported an error", error);
846
+ this.wake?.();
847
+ }
848
+ /** Latches the terminal failure and returns it. First writer wins, so the
849
+ * cause a caller is handed is the one that started the failure rather than
850
+ * whichever consequence surfaced last. */
851
+ stall(what, cause) {
852
+ return this.latch(new VideoEngineError(VideoEngineErrorCode.DecoderStalled, `DecodeSession: ${what}`, cause));
853
+ }
854
+ latch(error) {
855
+ this.stalledError ??= error;
856
+ return this.stalledError;
857
+ }
858
+ discardDecoded() {
859
+ for (const picture of this.decoded)
860
+ picture.frame.close();
861
+ this.decoded.length = 0;
862
+ }
863
+ }
864
+ /** A decoded frame in the runtime's own sample vocabulary, so a session frame
865
+ * and a VideoSampleSink frame reach the renderer the same way. */
866
+ function videoFrameSample({ frame, timestampS, durationS }, rotation) {
867
+ const quarterTurn = rotation % 180 !== 0;
868
+ return {
869
+ timestamp: timestampS,
870
+ duration: durationS,
871
+ rotation,
872
+ toVideoFrame: () => frame.clone(),
873
+ draw: (ctx, dx, dy, dWidth, dHeight) => {
874
+ drawRotated(ctx, frame, rotation, dx, dy, dWidth ?? (quarterTurn ? frame.displayHeight : frame.displayWidth), dHeight ?? (quarterTurn ? frame.displayWidth : frame.displayHeight));
875
+ },
876
+ close: () => frame.close(),
877
+ };
878
+ }
879
+
880
+ var ScrubCursorState;
881
+ (function (ScrubCursorState) {
882
+ ScrubCursorState["Idle"] = "idle";
883
+ ScrubCursorState["Seeking"] = "seeking";
884
+ ScrubCursorState["Closed"] = "closed";
885
+ })(ScrubCursorState || (ScrubCursorState = {}));
886
+ /**
887
+ * Wraps a raw sample so its close() runs at most once, then no-ops. The
888
+ * lifetime is hard to keep linear: a sample is drawn into the cache, stashed for
889
+ * paint, then closed after paint, and may be closed again on teardown if a
890
+ * gesture left it unpainted. An idempotent close lets every path call it
891
+ * defensively without double-free.
892
+ */
893
+ function idempotentSample(sample) {
894
+ let closed = false;
895
+ return {
896
+ toVideoFrame: () => sample.toVideoFrame(),
897
+ rotation: sample.rotation,
898
+ draw: (ctx, dx, dy, dWidth, dHeight) => sample.draw(ctx, dx, dy, dWidth, dHeight),
899
+ close: () => {
900
+ if (closed)
901
+ return;
902
+ closed = true;
903
+ sample.close();
904
+ },
905
+ get timestamp() {
906
+ return sample.timestamp;
907
+ },
908
+ get duration() {
909
+ return sample.duration;
910
+ },
911
+ };
912
+ }
913
+
914
+ /**
915
+ * Opens a source on the CanvasSink path, the one every decodable source
916
+ * supports. Track facts are resolved here too, so the consumer receives a fully
917
+ * described handle and never touches mediabunny or the resolution math. Playback
918
+ * consumers call openScrubSource instead and take whatever path the source
919
+ * supports; this is for consumers that want canvases specifically.
920
+ */
921
+ async function openDecodeSource(options) {
922
+ return canvasHandle(await openInput(options), options.poolSize ?? SCRUB.DEFAULT_POOL_SIZE);
923
+ }
924
+ /**
925
+ * Opens a source for a batch walk over its frames, on its own input and its own
926
+ * decoder. A walk reads thousands of frames in a row and a playback cursor is
927
+ * serving a screen, so the two never share: whatever a walk does to its read
928
+ * head, no gesture is waiting behind it.
929
+ *
930
+ * The CanvasSink path is not offered here. It yields recycled canvases with no
931
+ * duration and no close, and a walk's whole contract is real frames with the
932
+ * timing they were encoded with, so a source that cannot present samples cannot
933
+ * be walked. Frames arrive at the source's native resolution; nothing downscales
934
+ * them on the way out.
935
+ */
936
+ async function openWalkSource(source) {
937
+ const opened = await openInput({ source });
938
+ const decoderConfig = await opened.videoTrack.getDecoderConfig();
939
+ if (decoderConfig &&
940
+ decodeSessionViable({
941
+ videoDecoderAvailable: detectVideoDecoder(),
942
+ decoderConfig,
943
+ })) {
944
+ const handle = sessionHandle(opened, decoderConfig);
945
+ return walkHandle(handle, (startS) => handle.session.framesFrom(startS));
946
+ }
947
+ const handle = sampleHandle(opened);
948
+ return walkHandle(handle, (startS) => handle.sampleSink.samples(startS));
949
+ }
950
+ function walkHandle(handle, stream) {
951
+ return {
952
+ track: handle.track,
953
+ async *framesFrom(startS) {
954
+ for await (const sample of stream(startS))
955
+ yield idempotentSample(sample);
956
+ },
957
+ dispose: () => handle.dispose(),
958
+ };
959
+ }
960
+ function canvasHandle(opened, poolSize) {
961
+ // Width-only: CanvasSink derives height from native aspect. This sizes the
962
+ // canvas the frame is drawn into; it does NOT reduce decode cost. The codec
963
+ // decodes the full coded frame regardless, and CanvasSink resizes after, so
964
+ // the win is paint work and cached-blit memory, not decode throughput.
965
+ const sink = new CanvasSink(opened.videoTrack, {
966
+ poolSize,
967
+ width: opened.track.decodeWidth,
968
+ });
969
+ return {
970
+ track: opened.track,
971
+ sink,
972
+ keyframeProbe: new EncodedPacketSink(opened.videoTrack),
973
+ dispose: async () => {
974
+ opened.input.dispose();
975
+ },
976
+ };
977
+ }
978
+ /** The zero-copy sample path: a VideoSampleSink in place of the CanvasSink. It
979
+ * takes no width, so frames arrive at the source's own dimensions. */
980
+ function sampleHandle(opened) {
981
+ return {
982
+ track: opened.track,
983
+ sampleSink: new VideoSampleSink(opened.videoTrack),
984
+ keyframeProbe: new EncodedPacketSink(opened.videoTrack),
985
+ dispose: async () => {
986
+ opened.input.dispose();
987
+ },
988
+ };
989
+ }
990
+ /**
991
+ * The long-lived-decoder path: an EncodedPacketSink feeding a DecodeSession that
992
+ * holds one VideoDecoder across seeks. The same sink answers the keyframe probe,
993
+ * so anchor resolution and packet reads share one reader. The session drives the
994
+ * decoder itself and every frame arrives at the source's own dimensions; the
995
+ * resolved decodeWidth still sizes the canvas and the cache blits, which scale
996
+ * the native frame down as they draw it.
997
+ */
998
+ function sessionHandle(opened, config) {
999
+ const packets = new EncodedPacketSink(opened.videoTrack);
1000
+ const session = new DecodeSession({
1001
+ packets,
1002
+ config,
1003
+ createDecoder: webCodecsDecoder,
1004
+ rotation: opened.track.rotation,
1005
+ });
1006
+ return {
1007
+ track: opened.track,
1008
+ session,
1009
+ keyframeProbe: packets,
1010
+ dispose: async () => {
1011
+ session.close();
1012
+ opened.input.dispose();
1013
+ },
1014
+ };
1015
+ }
1016
+ function webCodecsDecoder(init) {
1017
+ const decoder = new VideoDecoder(init);
1018
+ return {
1019
+ configure: (config) => decoder.configure(config),
1020
+ decode: (chunk) => decoder.decode(new EncodedVideoChunk(chunk)),
1021
+ flush: () => decoder.flush(),
1022
+ // Reporting an error closes a WebCodecs decoder, and both of these throw
1023
+ // on one that is already closed. Teardown of a decoder that failed is a
1024
+ // routine path, so the platform's rule is answered here rather than left
1025
+ // for every caller to remember.
1026
+ reset: () => {
1027
+ if (decoder.state !== "closed")
1028
+ decoder.reset();
1029
+ },
1030
+ close: () => {
1031
+ if (decoder.state !== "closed")
1032
+ decoder.close();
1033
+ },
1034
+ };
1035
+ }
1036
+ /** Opens the input, resolves the primary video track, and runs the decode
1037
+ * resolution math, the work every path shares before one is chosen.
1038
+ *
1039
+ * Four ways to get no frames out of a file, and each one throws its own code
1040
+ * so a host can say which happened: the container is not one the demuxer
1041
+ * reads (ContainerUnreadable), the container reads but nothing inside it does
1042
+ * (VideoTrackUnreadable), the tracks read and none is video (NoVideoTrack), or
1043
+ * the video track reads and the browser has no decoder for its codec
1044
+ * (DecodeUnsupported). */
1045
+ async function openInput(options) {
1046
+ const { source } = options;
1047
+ const input = new Input({
1048
+ formats: ALL_FORMATS,
1049
+ source: toMediabunnySource(source, options.sourceResidency, options.urlSource),
1050
+ });
1051
+ const videoTrack = await resolveVideoTrack(input);
1052
+ if (!(await videoTrack.canDecode())) {
1053
+ const decoderConfig = await videoTrack.getDecoderConfig();
1054
+ throw new VideoEngineError(VideoEngineErrorCode.DecodeUnsupported, `openInput: browser cannot decode this video track's codec ${decoderConfig?.codec ?? "(unknown)"}`);
1055
+ }
1056
+ const displayWidth = videoTrack.displayWidth;
1057
+ const displayHeight = videoTrack.displayHeight;
1058
+ const durationS = asSec(await readTrackDurationS(videoTrack));
1059
+ const timeline = await readFrameTimeline(videoTrack);
1060
+ // Mediabunny ships a measured native fps via computePacketStats:
1061
+ // averagePacketRate "for video tracks, equals the average frame rate". We
1062
+ // bound the packet sample to a small slice so the load promise doesn't stall
1063
+ // on long files. The result is good enough for 1/nativeFps step math;
1064
+ // sub-frame precision falls through to stepForward/stepBackward.
1065
+ const nativeFps = await readTrackFps(videoTrack);
1066
+ const strategy = options.decodeStrategy ?? nativeResolution();
1067
+ const { width: decodeWidth, height: decodeHeight } = resolveDecodeDimensions(strategy, {
1068
+ nativeWidth: displayWidth,
1069
+ nativeHeight: displayHeight,
1070
+ displayWidth: options.viewport?.displayWidth ?? null,
1071
+ devicePixelRatio: options.viewport?.devicePixelRatio ?? 1,
1072
+ });
1073
+ return {
1074
+ input,
1075
+ videoTrack,
1076
+ strategy,
1077
+ track: {
1078
+ width: displayWidth,
1079
+ height: displayHeight,
1080
+ decodeWidth,
1081
+ decodeHeight,
1082
+ rotation: videoTrack.rotation,
1083
+ nativeFps,
1084
+ durationS,
1085
+ firstTimestampS: timeline.timeAt(0),
1086
+ timeline,
1087
+ },
1088
+ };
1089
+ }
1090
+ /**
1091
+ * The primary video track, or the typed refusal that says which of the three
1092
+ * ways to reach no track this file took. Only the last of them, where the
1093
+ * demuxer listed tracks and none was video, is a statement about the file
1094
+ * itself; the other two say what this build can read.
1095
+ */
1096
+ async function resolveVideoTrack(input) {
1097
+ let videoTrack;
1098
+ try {
1099
+ videoTrack = await input.getPrimaryVideoTrack();
1100
+ }
1101
+ catch (error) {
1102
+ if (error instanceof UnsupportedInputFormatError) {
1103
+ throw new VideoEngineError(VideoEngineErrorCode.ContainerUnreadable, "openInput: the demuxer does not read this file's container", error);
1104
+ }
1105
+ throw error;
1106
+ }
1107
+ if (videoTrack)
1108
+ return videoTrack;
1109
+ const tracks = await input.getTracks();
1110
+ throw tracks.length === 0
1111
+ ? new VideoEngineError(VideoEngineErrorCode.VideoTrackUnreadable, "openInput: the container opened and the demuxer parsed no track out of it")
1112
+ : new VideoEngineError(VideoEngineErrorCode.NoVideoTrack, "openInput: the container's tracks read and none of them carries video");
1113
+ }
1114
+ /**
1115
+ * Walks the track's packets metadata-only and records each one's timestamp in
1116
+ * the container's own integer grain, which is the grain every timestamp of the
1117
+ * track is a whole multiple of.
1118
+ *
1119
+ * Decode order is not presentation order on a B-frame source, so the table is
1120
+ * sorted before it is indexed, and the trailing frame's duration comes from the
1121
+ * packet that ends up last in that order.
1122
+ */
1123
+ async function readFrameTimeline(videoTrack) {
1124
+ const track = videoTrack;
1125
+ const tickRate = typeof track.getTimeResolution === "function"
1126
+ ? await track.getTimeResolution()
1127
+ : (track.timeResolution ?? FRAME_TIMELINE.FALLBACK_TICK_RATE);
1128
+ const sink = new EncodedPacketSink(videoTrack);
1129
+ const ticks = [];
1130
+ let lastTicks = -Infinity;
1131
+ let lastDurationTicks = 0;
1132
+ for await (const packet of sink.packets(undefined, undefined, {
1133
+ metadataOnly: true,
1134
+ })) {
1135
+ const at = Math.round(packet.timestamp * tickRate);
1136
+ if (ticks.length >= FRAME_TIMELINE.MAX_FRAMES) {
1137
+ throw new VideoEngineError(VideoEngineErrorCode.DecodeUnsupported, `openInput: source video track carries more than ${FRAME_TIMELINE.MAX_FRAMES} frames`);
1138
+ }
1139
+ ticks.push(at);
1140
+ if (at >= lastTicks) {
1141
+ lastTicks = at;
1142
+ lastDurationTicks = Math.round(packet.duration * tickRate);
1143
+ }
1144
+ }
1145
+ if (ticks.length === 0) {
1146
+ throw new VideoEngineError(VideoEngineErrorCode.DecodeUnsupported, "openInput: source video track has no frames");
1147
+ }
1148
+ ticks.sort((a, b) => a - b);
1149
+ return FrameTimeline.from({
1150
+ lastDurationTicks,
1151
+ tickRate,
1152
+ ticks: Float64Array.from(ticks),
1153
+ });
1154
+ }
1155
+ /**
1156
+ * The gate. The session reaches a non-IDR anchor by declaring it a recovery
1157
+ * point, which only holds for AVCC-framed H.264 carrying its parameter sets, so
1158
+ * every other source keeps the mediabunny sinks.
1159
+ */
1160
+ function decodeSessionViable(ctx) {
1161
+ if (!ctx.videoDecoderAvailable)
1162
+ return false;
1163
+ return sessionDrivable(ctx.decoderConfig);
1164
+ }
1165
+ /** Whether this realm exposes the WebCodecs decoder the session constructs. */
1166
+ function detectVideoDecoder() {
1167
+ return (typeof globalThis.VideoDecoder ===
1168
+ "function");
1169
+ }
1170
+ function urlRequestInit(crossOrigin) {
1171
+ if (!crossOrigin)
1172
+ return undefined;
1173
+ return {
1174
+ requestInit: {
1175
+ mode: "cors",
1176
+ credentials: crossOrigin === "use-credentials" ? "include" : "omit",
1177
+ },
1178
+ };
1179
+ }
1180
+ function toMediabunnySource(source, residency, urlSource) {
1181
+ switch (source.kind) {
1182
+ case SourceKind.Url: {
1183
+ const options = {
1184
+ ...urlRequestInit(source.crossOrigin),
1185
+ ...(residency ? { fetchFn: residency.fetchFn } : {}),
1186
+ ...(urlSource?.maxCacheSize === undefined
1187
+ ? {}
1188
+ : { maxCacheSize: urlSource.maxCacheSize }),
1189
+ ...(urlSource?.parallelism === undefined
1190
+ ? {}
1191
+ : { parallelism: urlSource.parallelism }),
1192
+ };
1193
+ return new UrlSource(source.url, Object.keys(options).length > 0 ? options : undefined);
1194
+ }
1195
+ case SourceKind.Blob:
1196
+ return new BlobSource(source.blob);
1197
+ case SourceKind.Stream:
1198
+ return new ReadableStreamSource(source.stream);
1199
+ }
1200
+ }
1201
+ async function readTrackDurationS(track) {
1202
+ const t = track;
1203
+ if (typeof t.computeDuration === "function") {
1204
+ const d = await t.computeDuration();
1205
+ return Number.isFinite(d) ? d : 0;
1206
+ }
1207
+ if (typeof t.duration === "number")
1208
+ return t.duration;
1209
+ if (typeof t.durationS === "number")
1210
+ return t.durationS;
1211
+ return 0;
1212
+ }
1213
+ /**
1214
+ * Reads the native frame rate from mediabunny's computePacketStats. Bounded to a
1215
+ * small packet sample so load() does not stall on long files. Returns null when
1216
+ * the track exposes no stats API or the value is non-finite.
1217
+ */
1218
+ async function readTrackFps(track) {
1219
+ const t = track;
1220
+ if (typeof t.computePacketStats !== "function")
1221
+ return null;
1222
+ try {
1223
+ const stats = await t.computePacketStats(120);
1224
+ const fps = stats?.averagePacketRate;
1225
+ if (typeof fps !== "number" || !Number.isFinite(fps) || fps <= 0)
1226
+ return null;
1227
+ return fps;
1228
+ }
1229
+ catch {
1230
+ return null;
1231
+ }
1232
+ }
1233
+
1234
+ /**
1235
+ * Lazy index of a video track's keyframe timestamps, in seconds.
1236
+ *
1237
+ * Seeking decodes forward from the keyframe at-or-before the target, so every
1238
+ * seek needs that anchor. Resolving one costs a container round-trip, so the
1239
+ * index remembers each keyframe it discovers; repeated seeks around the same
1240
+ * region then resolve synchronously through `cachedAtOrBefore`.
1241
+ */
1242
+ // Only timestamps are read, never frame bytes, so every probe is metadata-only.
1243
+ const METADATA_ONLY = { metadataOnly: true };
1244
+ class KeyframeIndex {
1245
+ probe;
1246
+ times = [];
1247
+ probeRoundTrips = 0;
1248
+ /** Set once a full walk has completed, so a second call is a free no-op. */
1249
+ fullyIndexed = false;
1250
+ constructor(probe) {
1251
+ this.probe = probe;
1252
+ }
1253
+ /** Keyframe timestamps discovered so far, ascending and deduplicated. */
1254
+ get known() {
1255
+ return this.times;
1256
+ }
1257
+ /** Container queries the index has made; the rest resolve from memory. */
1258
+ get probeCount() {
1259
+ return this.probeRoundTrips;
1260
+ }
1261
+ /**
1262
+ * GOP-gap distribution over the keyframes discovered so far. Pass the track
1263
+ * duration so density reflects keyframes across the whole clip, not just the
1264
+ * clustered swept span; without it a few discovered keyframes in one region
1265
+ * would read as a high density that does not hold track-wide.
1266
+ */
1267
+ gopStats(durationS) {
1268
+ const t = this.times;
1269
+ const n = t.length;
1270
+ if (n < 2) {
1271
+ return {
1272
+ count: n,
1273
+ avgGopS: 0,
1274
+ maxGopS: 0,
1275
+ minGopS: 0,
1276
+ stddevS: 0,
1277
+ densityPerS: 0,
1278
+ };
1279
+ }
1280
+ const gaps = n - 1;
1281
+ let sum = 0;
1282
+ let max = -Infinity;
1283
+ let min = Infinity;
1284
+ for (let i = 1; i < n; i++) {
1285
+ const gap = t[i] - t[i - 1];
1286
+ sum += gap;
1287
+ if (gap > max)
1288
+ max = gap;
1289
+ if (gap < min)
1290
+ min = gap;
1291
+ }
1292
+ const avg = sum / gaps;
1293
+ let variance = 0;
1294
+ for (let i = 1; i < n; i++) {
1295
+ const d = t[i] - t[i - 1] - avg;
1296
+ variance += d * d;
1297
+ }
1298
+ const span = durationS && durationS > 0 ? durationS : t[n - 1] - t[0];
1299
+ return {
1300
+ count: n,
1301
+ avgGopS: avg,
1302
+ maxGopS: max,
1303
+ minGopS: min,
1304
+ stddevS: Math.sqrt(variance / gaps),
1305
+ densityPerS: span > 0 ? gaps / span : 0,
1306
+ };
1307
+ }
1308
+ /**
1309
+ * Nearest keyframe at or before `tSec`, the anchor a seek decodes forward
1310
+ * from. Null when `tSec` precedes the first keyframe.
1311
+ */
1312
+ async keyframeAtOrBefore(tSec) {
1313
+ this.probeRoundTrips++;
1314
+ const packet = await this.probe.getKeyPacket(tSec, METADATA_ONLY);
1315
+ if (!packet)
1316
+ return null;
1317
+ this.record(packet.timestamp);
1318
+ return packet.timestamp;
1319
+ }
1320
+ /**
1321
+ * First keyframe strictly after `tSec`, the boundary a forward decode runs
1322
+ * up to. Null when no keyframe follows, or when `tSec` precedes the first
1323
+ * keyframe (no anchor to step from).
1324
+ */
1325
+ async nextKeyframeAfter(tSec) {
1326
+ this.probeRoundTrips++;
1327
+ const anchor = await this.probe.getKeyPacket(tSec, METADATA_ONLY);
1328
+ if (!anchor)
1329
+ return null;
1330
+ this.record(anchor.timestamp);
1331
+ this.probeRoundTrips++;
1332
+ const next = await this.probe.getNextKeyPacket(anchor, METADATA_ONLY);
1333
+ if (!next)
1334
+ return null;
1335
+ this.record(next.timestamp);
1336
+ return next.timestamp;
1337
+ }
1338
+ /**
1339
+ * Every keyframe needed to decode the window `[startS, endS]`: the anchor at
1340
+ * or before `startS` plus each keyframe up to `endS`. Empty when `startS`
1341
+ * precedes the first keyframe.
1342
+ */
1343
+ async keyframesCovering(startS, endS) {
1344
+ this.probeRoundTrips++;
1345
+ let packet = await this.probe.getKeyPacket(startS, METADATA_ONLY);
1346
+ if (!packet)
1347
+ return [];
1348
+ const covering = [];
1349
+ while (packet && packet.timestamp <= endS) {
1350
+ this.record(packet.timestamp);
1351
+ covering.push(packet.timestamp);
1352
+ this.probeRoundTrips++;
1353
+ packet = await this.probe.getNextKeyPacket(packet, METADATA_ONLY);
1354
+ }
1355
+ return covering;
1356
+ }
1357
+ /**
1358
+ * Walks the whole track once, recording every keyframe timestamp. Metadata
1359
+ * only: it threads the same getKeyPacket / getNextKeyPacket pattern as the
1360
+ * lazy probes, so it never decodes a pixel. Idempotent: a second call after a
1361
+ * completed walk returns at once. Meant for the diagnostics lane, off the hot
1362
+ * seek path, so the purple lane reflects the whole file rather than only the
1363
+ * regions a scrub has visited.
1364
+ */
1365
+ async ensureFullyIndexed(fromS = 0) {
1366
+ if (this.fullyIndexed)
1367
+ return;
1368
+ this.probeRoundTrips++;
1369
+ let packet = await this.probe.getKeyPacket(fromS, METADATA_ONLY);
1370
+ while (packet) {
1371
+ this.record(packet.timestamp);
1372
+ this.probeRoundTrips++;
1373
+ packet = await this.probe.getNextKeyPacket(packet, METADATA_ONLY);
1374
+ }
1375
+ this.fullyIndexed = true;
1376
+ }
1377
+ /**
1378
+ * Largest discovered keyframe at or before `tSec`, resolved synchronously
1379
+ * from memory. Null when nothing at or before `tSec` has been discovered
1380
+ * yet; callers fall back to the async probes above.
1381
+ */
1382
+ cachedAtOrBefore(tSec) {
1383
+ const i = floorIndex(this.times, tSec);
1384
+ return i < 0 ? null : this.times[i];
1385
+ }
1386
+ record(t) {
1387
+ const i = lowerBound(this.times, t);
1388
+ if (i < this.times.length && this.times[i] === t)
1389
+ return;
1390
+ this.times.splice(i, 0, t);
1391
+ }
1392
+ }
1393
+ /** First index whose value is `>= target` (insertion point for `target`). */
1394
+ function lowerBound(values, target) {
1395
+ let lo = 0;
1396
+ let hi = values.length;
1397
+ while (lo < hi) {
1398
+ const mid = (lo + hi) >>> 1;
1399
+ if (values[mid] < target)
1400
+ lo = mid + 1;
1401
+ else
1402
+ hi = mid;
1403
+ }
1404
+ return lo;
1405
+ }
1406
+ /** Largest index whose value is `<= target`, or -1 when all exceed `target`. */
1407
+ function floorIndex(values, target) {
1408
+ let lo = 0;
1409
+ let hi = values.length;
1410
+ while (lo < hi) {
1411
+ const mid = (lo + hi) >>> 1;
1412
+ if (values[mid] <= target)
1413
+ lo = mid + 1;
1414
+ else
1415
+ hi = mid;
1416
+ }
1417
+ return lo - 1;
1418
+ }
1419
+
1420
+ class AnalysisSession {
1421
+ sink;
1422
+ keyframeIndex;
1423
+ track;
1424
+ disposeSource;
1425
+ closed = false;
1426
+ constructor(source) {
1427
+ this.sink = source.sink;
1428
+ this.keyframeIndex = new KeyframeIndex(source.keyframeProbe);
1429
+ this.track = source.track;
1430
+ this.disposeSource = () => source.dispose();
1431
+ }
1432
+ /** Opens a source for analysis. The returned session owns the source. */
1433
+ static async open(options) {
1434
+ return new AnalysisSession(await openDecodeSource(options));
1435
+ }
1436
+ get metadata() {
1437
+ return {
1438
+ durationS: this.track.durationS,
1439
+ width: this.track.width,
1440
+ height: this.track.height,
1441
+ frameWidth: this.track.decodeWidth,
1442
+ frameHeight: this.track.decodeHeight,
1443
+ nativeFps: this.track.nativeFps,
1444
+ };
1445
+ }
1446
+ /**
1447
+ * Keyframe timestamps (seconds) whose GOP overlaps the range, resolved
1448
+ * lazily through the container. Useful for a contact sheet that lands on
1449
+ * real I-frames, which decode without a GOP walk.
1450
+ */
1451
+ keyframeTimestamps(startS = 0, endS = this.track.durationS) {
1452
+ return this.keyframeIndex.keyframesCovering(startS, endS);
1453
+ }
1454
+ /**
1455
+ * Decodes a frame for each requested timestamp and returns a stable copy of
1456
+ * each. Timestamps are sorted so the sink decodes each packet at most once;
1457
+ * a timestamp with no frame is skipped rather than yielding a gap.
1458
+ */
1459
+ async extractFrames(timestampsS) {
1460
+ const sorted = [...timestampsS].sort((a, b) => a - b);
1461
+ const frames = [];
1462
+ for await (const frame of this.framesAtTimestamps(sorted)) {
1463
+ if (frame)
1464
+ frames.push(frame);
1465
+ }
1466
+ return frames;
1467
+ }
1468
+ /**
1469
+ * One frame per requested timestamp, in the order asked for, `null` where no
1470
+ * frame covers it, so a caller pairing frames with its own indices keeps the
1471
+ * gaps attributable. Timestamps that climb decode in a single pass over the
1472
+ * track: one seek and GOP walk for the whole set.
1473
+ *
1474
+ * A consumer that finishes with each frame before pulling the next holds one
1475
+ * frame of memory; {@link extractFrames} holds the whole set.
1476
+ */
1477
+ async *framesAtTimestamps(timestampsS) {
1478
+ if (this.closed || timestampsS.length === 0)
1479
+ return;
1480
+ const iter = this.sink.canvasesAtTimestamps(timestampsS);
1481
+ try {
1482
+ for (let result = await iter.next(); !result.done; result = await iter.next()) {
1483
+ if (this.closed)
1484
+ break;
1485
+ yield result.value ? this.copyFrame(result.value) : null;
1486
+ }
1487
+ }
1488
+ catch (error) {
1489
+ // A concurrent close() disposes the source mid-decode; end the stream on
1490
+ // the frames already yielded rather than throwing on the teardown.
1491
+ if (!this.closed)
1492
+ throw error;
1493
+ }
1494
+ finally {
1495
+ void iter.return();
1496
+ }
1497
+ }
1498
+ async close() {
1499
+ this.closed = true;
1500
+ await this.disposeSource();
1501
+ }
1502
+ copyFrame(frame) {
1503
+ const width = this.track.decodeWidth;
1504
+ const height = this.track.decodeHeight;
1505
+ const canvas = new OffscreenCanvas(width, height);
1506
+ // Frames are opaque video; alpha:false skips per-pixel blend.
1507
+ const ctx = canvas.getContext("2d", { alpha: false });
1508
+ ctx?.drawImage(frame.canvas, 0, 0, width, height);
1509
+ return { timestampS: frame.timestamp, canvas, width, height };
1510
+ }
1511
+ }
1512
+
1513
+ /**
1514
+ * A second consumer of the runtime, built only on AnalysisSession's public
1515
+ * surface, with no reach into the cursor, cache, worker, or render loop. Its
1516
+ * existence is the proof that the analysis primitive is reusable: a thumbnail
1517
+ * strip or contact sheet is just a choice of which timestamps to pull, and the
1518
+ * open/close pair shows the full source lifecycle is reachable from the public
1519
+ * API alone.
1520
+ */
1521
+ class FrameExtractor {
1522
+ session;
1523
+ constructor(session) {
1524
+ this.session = session;
1525
+ }
1526
+ /** Opens a source and returns an extractor that owns it; close() disposes it. */
1527
+ static async open(options) {
1528
+ return new FrameExtractor(await AnalysisSession.open(options));
1529
+ }
1530
+ /** Frames at exactly the given timestamps (seconds). */
1531
+ extractAt(timestampsS) {
1532
+ return this.session.extractFrames(timestampsS);
1533
+ }
1534
+ /**
1535
+ * `count` frames evenly spaced across the source, each sampled at the
1536
+ * middle of its slice so neither the first black frame nor the exact end
1537
+ * dominates the strip.
1538
+ */
1539
+ evenlySpaced(count) {
1540
+ const { durationS } = this.session.metadata;
1541
+ if (count <= 0 || durationS <= 0)
1542
+ return Promise.resolve([]);
1543
+ const step = durationS / count;
1544
+ const times = Array.from({ length: count }, (_, i) => i * step + step / 2);
1545
+ return this.session.extractFrames(times);
1546
+ }
1547
+ /** One frame per keyframe in the range: a contact sheet that decodes cheaply. */
1548
+ async atKeyframes(startS, endS) {
1549
+ const keyframes = await this.session.keyframeTimestamps(startS, endS);
1550
+ return this.session.extractFrames(keyframes);
1551
+ }
1552
+ /** Disposes the underlying analysis session. */
1553
+ close() {
1554
+ return this.session.close();
1555
+ }
1556
+ }
1557
+
1558
+ const MS_PER_SECOND = 1000;
1559
+ /** One microsecond, the grain container timestamps are stored at, so a bound
1560
+ * that lands on a frame is never pushed off it by float error. */
1561
+ const BOUND_EPSILON_MS = 1e-3;
1562
+ /**
1563
+ * Every real decoded frame of a range, in presentation order, exactly once.
1564
+ *
1565
+ * Nothing here is synthesized: the walk reads the source's own frames and counts
1566
+ * them. Timestamps built from a frame rate and resolved through at-or-before
1567
+ * extraction cannot promise that. One falling a float's width short of a sample
1568
+ * lands on the frame before it, so the caller gets that frame twice and never
1569
+ * sees the one it was aiming at.
1570
+ *
1571
+ * Every sample the walk decodes and does not yield is closed here: the frames
1572
+ * before the range, the ones stride skips, and the first one past the end.
1573
+ * Breaking out of the walk returns the underlying stream, which releases the
1574
+ * decoder's read of the source; the sample in the caller's hand at that moment
1575
+ * is still the caller's to close.
1576
+ */
1577
+ async function* walkFrames(source, range = {}) {
1578
+ const stride = range.stride ?? 1;
1579
+ if (!Number.isInteger(stride) || stride < 1) {
1580
+ throw new RangeError(`walkFrames: stride must be a positive integer, got ${stride}`);
1581
+ }
1582
+ const startMs = range.startMs ?? source.track.firstTimestampS * MS_PER_SECOND;
1583
+ const endMs = range.endMs ?? Infinity;
1584
+ if (endMs <= startMs)
1585
+ return;
1586
+ const frames = source.framesFrom(startMs / MS_PER_SECOND);
1587
+ let framesInRange = 0;
1588
+ try {
1589
+ for (let next = await frames.next(); !next.done; next = await frames.next()) {
1590
+ const sample = next.value;
1591
+ const timestampMs = sample.timestamp * MS_PER_SECOND;
1592
+ if (timestampMs < startMs - BOUND_EPSILON_MS) {
1593
+ // The stream opens on the frame covering startMs, which is a
1594
+ // frame the range does not contain.
1595
+ sample.close();
1596
+ continue;
1597
+ }
1598
+ if (timestampMs >= endMs - BOUND_EPSILON_MS) {
1599
+ sample.close();
1600
+ return;
1601
+ }
1602
+ const frameIndex = framesInRange++;
1603
+ if (frameIndex % stride !== 0) {
1604
+ sample.close();
1605
+ continue;
1606
+ }
1607
+ yield {
1608
+ frameIndex,
1609
+ timestampMs,
1610
+ durationMs: sample.duration * MS_PER_SECOND,
1611
+ sample,
1612
+ };
1613
+ }
1614
+ }
1615
+ finally {
1616
+ await frames.return();
1617
+ }
1618
+ }
1619
+ /**
1620
+ * A batch consumer's entry to the runtime: opens a source, walks its real
1621
+ * frames, and disposes what it opened. It spins no worker, binds no canvas, and
1622
+ * runs no clock, and it decodes on an input of its own, so a walk and a playing
1623
+ * engine never contend for one decoder's read head.
1624
+ *
1625
+ * A walk in flight ends when the walker is closed, the same way extraction does,
1626
+ * so a consumer that abandons a job mid-range has one call to make.
1627
+ */
1628
+ class FrameWalker {
1629
+ handle;
1630
+ closed = false;
1631
+ constructor(handle) {
1632
+ this.handle = handle;
1633
+ }
1634
+ /** Opens a source for walking; the returned walker owns it. */
1635
+ static async open(source) {
1636
+ return new FrameWalker(await openWalkSource(source));
1637
+ }
1638
+ get metadata() {
1639
+ const { track } = this.handle;
1640
+ return {
1641
+ durationS: track.durationS,
1642
+ width: track.width,
1643
+ height: track.height,
1644
+ nativeFps: track.nativeFps,
1645
+ firstTimestampS: track.firstTimestampS,
1646
+ };
1647
+ }
1648
+ async *walkFrames(range = {}) {
1649
+ if (this.closed)
1650
+ return;
1651
+ const walk = walkFrames(this.handle, range);
1652
+ try {
1653
+ for (let next = await walk.next(); !next.done; next = await walk.next()) {
1654
+ if (this.closed) {
1655
+ next.value.sample.close();
1656
+ return;
1657
+ }
1658
+ yield next.value;
1659
+ }
1660
+ }
1661
+ catch (error) {
1662
+ // A concurrent close() disposes the source mid-decode; that teardown
1663
+ // ends the walk and is not a decode failure.
1664
+ if (!this.closed)
1665
+ throw error;
1666
+ }
1667
+ finally {
1668
+ await walk.return();
1669
+ }
1670
+ }
1671
+ async close() {
1672
+ this.closed = true;
1673
+ await this.handle.dispose();
1674
+ }
1675
+ }
1676
+
1677
+ export { AnalysisSession, FrameExtractor, FrameWalker, walkFrames };
1678
+ //# sourceMappingURL=analysis.js.map