supervision 0.1.7 → 0.2.0-next.1

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 (182) hide show
  1. package/dist/constants/media-renderer.d.ts +3 -0
  2. package/dist/constants/media-renderer.d.ts.map +1 -1
  3. package/dist/detections/chunked-detection-frame-source.d.ts.map +1 -1
  4. package/dist/index.d.ts +8 -4
  5. package/dist/index.d.ts.map +1 -1
  6. package/dist/index.js +9281 -5038
  7. package/dist/index.js.map +1 -1
  8. package/dist/mask-preparation.worker.js +409 -185
  9. package/dist/mask-preparation.worker.js.map +1 -1
  10. package/dist/media/display-pixel-ratio.d.ts +15 -0
  11. package/dist/media/display-pixel-ratio.d.ts.map +1 -0
  12. package/dist/media/engine-import-failure.d.ts +16 -0
  13. package/dist/media/engine-import-failure.d.ts.map +1 -0
  14. package/dist/media/media-errors.d.ts +6 -0
  15. package/dist/media/media-errors.d.ts.map +1 -1
  16. package/dist/media/media-normalization.d.ts.map +1 -1
  17. package/dist/media/media-probe.d.ts.map +1 -1
  18. package/dist/media/media-source-state.d.ts.map +1 -1
  19. package/dist/media/media-source.d.ts +17 -0
  20. package/dist/media/media-source.d.ts.map +1 -1
  21. package/dist/media/mediabunny-media-source.d.ts.map +1 -1
  22. package/dist/media/video-engine-media-source.d.ts +54 -0
  23. package/dist/media/video-engine-media-source.d.ts.map +1 -0
  24. package/dist/media/video-engine-media-source.js +3 -0
  25. package/dist/media/video-engine-media-source.js.map +1 -0
  26. package/dist/playback/media-playback-controller.d.ts +7 -1
  27. package/dist/playback/media-playback-controller.d.ts.map +1 -1
  28. package/dist/render-preparation/mask-frame-artifact.d.ts +48 -12
  29. package/dist/render-preparation/mask-frame-artifact.d.ts.map +1 -1
  30. package/dist/render-preparation/mask-frame-compositor.d.ts +12 -5
  31. package/dist/render-preparation/mask-frame-compositor.d.ts.map +1 -1
  32. package/dist/render-preparation/mask-frame-preparer.d.ts.map +1 -1
  33. package/dist/render-preparation/mask-preparation-worker-count.d.ts +4 -4
  34. package/dist/render-preparation/mask-preparation-worker-protocol.d.ts +13 -3
  35. package/dist/render-preparation/mask-preparation-worker-protocol.d.ts.map +1 -1
  36. package/dist/render-preparation/prepared-render-window.d.ts +44 -2
  37. package/dist/render-preparation/prepared-render-window.d.ts.map +1 -1
  38. package/dist/render-preparation/prepared-window-timeline.d.ts +2 -6
  39. package/dist/render-preparation/prepared-window-timeline.d.ts.map +1 -1
  40. package/dist/renderers/injected-pixi.d.ts +38 -0
  41. package/dist/renderers/injected-pixi.d.ts.map +1 -0
  42. package/dist/renderers/mask-palette.d.ts +14 -0
  43. package/dist/renderers/mask-palette.d.ts.map +1 -0
  44. package/dist/renderers/mask-vertex.d.ts +14 -0
  45. package/dist/renderers/mask-vertex.d.ts.map +1 -0
  46. package/dist/renderers/media-renderer-core.d.ts +9 -0
  47. package/dist/renderers/media-renderer-core.d.ts.map +1 -1
  48. package/dist/renderers/media-renderer-scene.d.ts +50 -2
  49. package/dist/renderers/media-renderer-scene.d.ts.map +1 -1
  50. package/dist/renderers/media-renderer-state.d.ts +20 -1
  51. package/dist/renderers/media-renderer-state.d.ts.map +1 -1
  52. package/dist/renderers/media-renderer-transport.d.ts +72 -0
  53. package/dist/renderers/media-renderer-transport.d.ts.map +1 -0
  54. package/dist/renderers/pixi-box-layer.d.ts +5 -0
  55. package/dist/renderers/pixi-box-layer.d.ts.map +1 -1
  56. package/dist/renderers/pixi-focus-layer.d.ts +19 -2
  57. package/dist/renderers/pixi-focus-layer.d.ts.map +1 -1
  58. package/dist/renderers/pixi-frame-present.d.ts +77 -0
  59. package/dist/renderers/pixi-frame-present.d.ts.map +1 -0
  60. package/dist/renderers/pixi-id-mask-shader.d.ts +6 -25
  61. package/dist/renderers/pixi-id-mask-shader.d.ts.map +1 -1
  62. package/dist/renderers/pixi-interaction-layer.d.ts.map +1 -1
  63. package/dist/renderers/pixi-interaction-presentation-layer.d.ts +12 -2
  64. package/dist/renderers/pixi-interaction-presentation-layer.d.ts.map +1 -1
  65. package/dist/renderers/pixi-mask-halo.d.ts +18 -23
  66. package/dist/renderers/pixi-mask-halo.d.ts.map +1 -1
  67. package/dist/renderers/pixi-mask-layer.d.ts +94 -32
  68. package/dist/renderers/pixi-mask-layer.d.ts.map +1 -1
  69. package/dist/renderers/pixi-media-scene.d.ts +67 -0
  70. package/dist/renderers/pixi-media-scene.d.ts.map +1 -1
  71. package/dist/renderers/pixi-polygon-layer.d.ts +9 -2
  72. package/dist/renderers/pixi-polygon-layer.d.ts.map +1 -1
  73. package/dist/renderers/pixi-region-coverage-mask.d.ts +62 -0
  74. package/dist/renderers/pixi-region-coverage-mask.d.ts.map +1 -0
  75. package/dist/renderers/pixi-region-effect.d.ts +42 -0
  76. package/dist/renderers/pixi-region-effect.d.ts.map +1 -0
  77. package/dist/renderers/pixi-region-layer.d.ts +43 -2
  78. package/dist/renderers/pixi-region-layer.d.ts.map +1 -1
  79. package/dist/renderers/pixi-shader-lifecycle.d.ts +14 -0
  80. package/dist/renderers/pixi-shader-lifecycle.d.ts.map +1 -0
  81. package/dist/renderers/pixi-vector-layer.d.ts.map +1 -1
  82. package/dist/renderers/prepared-annotation-window.d.ts +47 -0
  83. package/dist/renderers/prepared-annotation-window.d.ts.map +1 -0
  84. package/dist/renderers/presented-frame-channel.d.ts +138 -0
  85. package/dist/renderers/presented-frame-channel.d.ts.map +1 -0
  86. package/dist/renderers/scene-render-scheduler.d.ts +20 -0
  87. package/dist/renderers/scene-render-scheduler.d.ts.map +1 -0
  88. package/dist/sessions/media-session-defaults.d.ts +10 -6
  89. package/dist/sessions/media-session-defaults.d.ts.map +1 -1
  90. package/dist/sessions/media-session-media.d.ts.map +1 -1
  91. package/dist/sessions/media-session-state.d.ts.map +1 -1
  92. package/dist/sessions/media-session.d.ts.map +1 -1
  93. package/dist/tracking.worker.js +73 -4
  94. package/dist/tracking.worker.js.map +1 -1
  95. package/dist/types/media-normalization.d.ts +34 -0
  96. package/dist/types/media-normalization.d.ts.map +1 -1
  97. package/dist/types/media-renderer.d.ts +31 -1
  98. package/dist/types/media-renderer.d.ts.map +1 -1
  99. package/dist/types/media-session.d.ts +89 -15
  100. package/dist/types/media-session.d.ts.map +1 -1
  101. package/dist/types/render-preparation.d.ts +149 -5
  102. package/dist/types/render-preparation.d.ts.map +1 -1
  103. package/dist/video-engine-media-source-CUXnOJOV.js +450 -0
  104. package/dist/video-engine-media-source-CUXnOJOV.js.map +1 -0
  105. package/dist/web-video-engine/analysis-session.d.ts +75 -0
  106. package/dist/web-video-engine/analysis.d.ts +6 -0
  107. package/dist/web-video-engine/analysis.js +1850 -0
  108. package/dist/web-video-engine/cache-budget.d.ts +18 -0
  109. package/dist/web-video-engine/canvas-sink-scrub-cursor.d.ts +89 -0
  110. package/dist/web-video-engine/clock.d.ts +82 -0
  111. package/dist/web-video-engine/constants.d.ts +261 -0
  112. package/dist/web-video-engine/create-scrub-cursor.d.ts +65 -0
  113. package/dist/web-video-engine/decode-resolution.d.ts +114 -0
  114. package/dist/web-video-engine/decode-scheduler.d.ts +371 -0
  115. package/dist/web-video-engine/decode-session.d.ts +283 -0
  116. package/dist/web-video-engine/decode-source.d.ts +258 -0
  117. package/dist/web-video-engine/diagnostics-store.d.ts +17 -0
  118. package/dist/web-video-engine/diagnostics.d.ts +316 -0
  119. package/dist/web-video-engine/embedded-engine-worker.d.ts +1 -0
  120. package/dist/web-video-engine/engine-core.d.ts +269 -0
  121. package/dist/web-video-engine/engine.d.ts +25 -0
  122. package/dist/web-video-engine/engine.js +912 -0
  123. package/dist/web-video-engine/engine.worker.d.ts +1 -0
  124. package/dist/web-video-engine/engine.worker.js +33624 -0
  125. package/dist/web-video-engine/frame-cache.d.ts +236 -0
  126. package/dist/web-video-engine/frame-extractor.d.ts +27 -0
  127. package/dist/web-video-engine/frame-timeline-DreEwsRY.js +616 -0
  128. package/dist/web-video-engine/frame-timeline.d.ts +80 -0
  129. package/dist/web-video-engine/frame-walker.d.ts +73 -0
  130. package/dist/web-video-engine/index.d.ts +13 -0
  131. package/dist/web-video-engine/index.d.ts.map +1 -0
  132. package/dist/web-video-engine/index.js +3 -0
  133. package/dist/web-video-engine/index.js.map +1 -0
  134. package/dist/web-video-engine/key-packet.d.ts +64 -0
  135. package/dist/web-video-engine/keyframe-index.d.ts +90 -0
  136. package/dist/web-video-engine/mirror-store.d.ts +55 -0
  137. package/dist/web-video-engine/renderer.d.ts +93 -0
  138. package/dist/web-video-engine/rotation.d.ts +34 -0
  139. package/dist/web-video-engine/scrub-controller.d.ts +375 -0
  140. package/dist/web-video-engine/scrub-cursor.d.ts +347 -0
  141. package/dist/web-video-engine/scrub-trajectory.d.ts +29 -0
  142. package/dist/web-video-engine/source-residency.d.ts +91 -0
  143. package/dist/web-video-engine/trace-recorder.d.ts +163 -0
  144. package/dist/web-video-engine/types.d.ts +200 -0
  145. package/dist/web-video-engine/video-engine.d.ts +437 -0
  146. package/dist/web-video-engine/webgpu-renderer.d.ts +61 -0
  147. package/dist/web-video-engine/worker-bridge.d.ts +11 -0
  148. package/dist/web-video-engine/worker-dispatch.d.ts +16 -0
  149. package/dist/web-video-engine/worker-protocol.d.ts +282 -0
  150. package/node_modules/supervision-js-core/dist/detections/buffered-detection-timeline.d.ts.map +1 -1
  151. package/node_modules/supervision-js-core/dist/detections/composite-detection-frame-source.d.ts.map +1 -1
  152. package/node_modules/supervision-js-core/dist/index.d.ts +5 -4
  153. package/node_modules/supervision-js-core/dist/index.d.ts.map +1 -1
  154. package/node_modules/supervision-js-core/dist/index.js +1167 -280
  155. package/node_modules/supervision-js-core/dist/index.js.map +1 -1
  156. package/node_modules/supervision-js-core/dist/styles/default-annotation-presentation.d.ts.map +1 -1
  157. package/node_modules/supervision-js-core/dist/styles/interaction-style.d.ts +4 -36
  158. package/node_modules/supervision-js-core/dist/styles/interaction-style.d.ts.map +1 -1
  159. package/node_modules/supervision-js-core/dist/styles/polyline-style.d.ts +5 -0
  160. package/node_modules/supervision-js-core/dist/styles/polyline-style.d.ts.map +1 -1
  161. package/node_modules/supervision-js-core/dist/types/annotation-renderer.d.ts +92 -7
  162. package/node_modules/supervision-js-core/dist/types/annotation-renderer.d.ts.map +1 -1
  163. package/node_modules/supervision-js-core/dist/types/detection-timeline.d.ts +191 -17
  164. package/node_modules/supervision-js-core/dist/types/detection-timeline.d.ts.map +1 -1
  165. package/node_modules/supervision-js-core/dist/types/media-rendering.d.ts +83 -5
  166. package/node_modules/supervision-js-core/dist/types/media-rendering.d.ts.map +1 -1
  167. package/node_modules/supervision-js-core/dist/types/polyline-style.d.ts +2 -0
  168. package/node_modules/supervision-js-core/dist/types/polyline-style.d.ts.map +1 -1
  169. package/node_modules/supervision-js-core/dist/types/session-lifecycle.d.ts +32 -0
  170. package/node_modules/supervision-js-core/dist/types/session-lifecycle.d.ts.map +1 -1
  171. package/node_modules/supervision-js-core/dist/utils/detection-conversions.d.ts.map +1 -1
  172. package/node_modules/supervision-js-core/dist/utils/detection-frames.d.ts +5 -0
  173. package/node_modules/supervision-js-core/dist/utils/detection-frames.d.ts.map +1 -1
  174. package/node_modules/supervision-js-core/dist/utils/detection-masks.d.ts +9 -0
  175. package/node_modules/supervision-js-core/dist/utils/detection-masks.d.ts.map +1 -1
  176. package/node_modules/supervision-js-core/dist/utils/detection-ranges.d.ts +18 -0
  177. package/node_modules/supervision-js-core/dist/utils/detection-ranges.d.ts.map +1 -0
  178. package/node_modules/supervision-js-core/dist/utils/id-mask-frame.d.ts +34 -2
  179. package/node_modules/supervision-js-core/dist/utils/id-mask-frame.d.ts.map +1 -1
  180. package/node_modules/supervision-js-core/dist/utils/wait-bound.d.ts +7 -0
  181. package/node_modules/supervision-js-core/dist/utils/wait-bound.d.ts.map +1 -0
  182. package/package.json +24 -3
@@ -0,0 +1,1850 @@
1
+ import { CanvasSink, EncodedPacketSink, Input, ALL_FORMATS, VideoSampleSink, ReadableStreamSource, BlobSource, UrlSource, UnsupportedInputFormatError } from 'mediabunny';
2
+ import { W as WebVideoEngineError, a as WebVideoEngineErrorCode, 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-DreEwsRY.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
+ outputWidth;
376
+ outputHeight;
377
+ snapshotFrame;
378
+ constructor(options) {
379
+ this.options = options;
380
+ this.outputTimeoutMs = options.outputTimeoutMs ?? OUTPUT_TIMEOUT_MS;
381
+ this.rotation = options.rotation;
382
+ this.outputWidth = options.outputWidth;
383
+ this.outputHeight = options.outputHeight;
384
+ this.snapshotFrame = options.snapshotFrame ?? createVideoFrameSnapshotter();
385
+ const prefixWidth = drivablePrefixWidth(options.config);
386
+ if (prefixWidth === null) {
387
+ throw new WebVideoEngineError(WebVideoEngineErrorCode.DecodeUnsupported, `DecodeSession: ${options.config.codec} is not AVCC-framed H.264`);
388
+ }
389
+ this.prefixWidth = prefixWidth;
390
+ this.keyPacket = new KeyPacketRequirement(prefixWidth);
391
+ }
392
+ /** Times the session has configured the decoder onto an anchor. */
393
+ get anchorCount() {
394
+ return this.anchors;
395
+ }
396
+ /** Every frame the decoder has ever output, including walk pre-roll that is
397
+ * discarded before the target: the honest denominator for what a paint
398
+ * actually cost. Monotonic for the session's lifetime. */
399
+ get framesDecoded() {
400
+ return this.framesDecodedCount;
401
+ }
402
+ /** See SessionFrameSource. The oldest frame decoded but not yet handed out
403
+ * when there is one, since those are still servable, else the last position
404
+ * handed out. */
405
+ get reachableFromS() {
406
+ const queued = this.decoded[0];
407
+ if (queued)
408
+ return queued.timestampS;
409
+ return this.reachable;
410
+ }
411
+ /** The frame at or before `targetS`, or null when nothing precedes it. */
412
+ async frameAt(targetS) {
413
+ const landed = await this.exclusive(() => this.land(targetS));
414
+ return landed ? videoFrameSample(landed, this.rotation) : null;
415
+ }
416
+ /**
417
+ * Frames from `startS` onward, beginning at the frame at or before it. The
418
+ * walk from the anchor up to `startS` is decoded but not yielded, since the
419
+ * caller asked to start there.
420
+ *
421
+ * Playback rides this for the length of a session, so it outlives any number
422
+ * of scrubs. When one of those moved the decoder, the walk resumes from the
423
+ * last frame it handed out rather than ending.
424
+ */
425
+ async *framesFrom(startS) {
426
+ let handedOutS = null;
427
+ let seenEpoch = this.epoch;
428
+ for (;;) {
429
+ const picture = await this.exclusive(() => {
430
+ if (handedOutS === null)
431
+ return this.land(startS);
432
+ if (seenEpoch !== this.epoch)
433
+ return this.resumeAfter(handedOutS);
434
+ return this.pull(Infinity);
435
+ });
436
+ if (!picture)
437
+ return;
438
+ handedOutS = picture.timestampS;
439
+ seenEpoch = this.epoch;
440
+ yield videoFrameSample(picture, this.rotation);
441
+ }
442
+ }
443
+ /**
444
+ * Every frame decoded while covering `[startS, endS]`, including the walk
445
+ * from the anchor. Those prefix frames cost the same decode either way, so
446
+ * yielding them hands the caller a span of frames for the price of the one
447
+ * it asked for.
448
+ *
449
+ * This serves speculative sweeps, so a re-anchor by anyone else ends it:
450
+ * chasing the span back would spend a foreground decode's worth of decoder
451
+ * time on frames nobody is waiting for.
452
+ */
453
+ async *framesCovering(startS, endS) {
454
+ let positioned = false;
455
+ let seenEpoch = 0;
456
+ for (;;) {
457
+ const picture = await this.exclusive(async () => {
458
+ if (!positioned) {
459
+ await this.positionFor(startS);
460
+ positioned = true;
461
+ }
462
+ else if (seenEpoch !== this.epoch) {
463
+ return null;
464
+ }
465
+ return this.pull(endS);
466
+ });
467
+ if (!picture)
468
+ return;
469
+ seenEpoch = this.epoch;
470
+ yield videoFrameSample(picture, this.rotation);
471
+ }
472
+ }
473
+ /**
474
+ * Runs `op` with the decoder to itself. Retrievals interleave at frame
475
+ * granularity, which bounds how long a foreground seek waits behind a
476
+ * background sweep at one frame, while keeping any one walk's view of the
477
+ * packet iterator, the in-flight count, and the decoded queue consistent.
478
+ */
479
+ exclusive(op) {
480
+ const run = this.tail.then(op, op);
481
+ this.tail = run.then(() => undefined, () => undefined);
482
+ return run;
483
+ }
484
+ /**
485
+ * The first frame after `afterS` once another reader has moved the decoder.
486
+ * Re-positions and discards the frames already handed out, so a walk picks
487
+ * up exactly where it left off however far away the decoder was taken.
488
+ */
489
+ async resumeAfter(afterS) {
490
+ await this.positionFor(afterS);
491
+ for (;;) {
492
+ const picture = await this.pull(Infinity);
493
+ if (!picture)
494
+ return null;
495
+ if (picture.timestampS > afterS + BOUND_EPSILON_S)
496
+ return picture;
497
+ picture.frame.close();
498
+ }
499
+ }
500
+ close() {
501
+ if (this.closed)
502
+ return;
503
+ this.closed = true;
504
+ void this.iterator?.return();
505
+ this.iterator = null;
506
+ this.peeked = null;
507
+ this.exhausted = true;
508
+ this.discardDecoded();
509
+ this.decoder?.close();
510
+ this.decoder = null;
511
+ this.wake?.();
512
+ }
513
+ /** Decodes through `targetS` and returns the last frame at or before it,
514
+ * closing the frames walked past. */
515
+ async land(targetS) {
516
+ await this.positionFor(targetS);
517
+ let landed = null;
518
+ for (;;) {
519
+ const picture = await this.pull(targetS);
520
+ if (!picture)
521
+ return landed;
522
+ landed?.frame.close();
523
+ landed = picture;
524
+ }
525
+ }
526
+ async positionFor(targetS) {
527
+ if (this.closed)
528
+ return;
529
+ if (this.stalledError)
530
+ throw this.stalledError;
531
+ this.entryTargetS = targetS;
532
+ if (this.decoder && !this.exhausted) {
533
+ const headS = await this.readHeadS();
534
+ if (headS !== null && targetS >= headS && targetS < this.spanEndS)
535
+ return;
536
+ }
537
+ this.servedS = -Infinity;
538
+ await this.anchorAt(targetS);
539
+ }
540
+ /** The earliest position still reachable without re-anchoring: the oldest
541
+ * frame decoded but not yet handed out, or the next packet in line when
542
+ * there is none. Decode runs ahead of the read, so the packet alone would
543
+ * put the head past frames the session can still serve. */
544
+ async readHeadS() {
545
+ const queued = this.decoded[0];
546
+ if (queued)
547
+ return queued.timestampS;
548
+ const packet = await this.peek();
549
+ return packet ? packet.timestamp : null;
550
+ }
551
+ async anchorAt(targetS) {
552
+ const entry = await this.resolveEntry(targetS);
553
+ void this.iterator?.return();
554
+ this.iterator = null;
555
+ this.peeked = null;
556
+ this.exhausted = true;
557
+ this.entry = null;
558
+ this.anchorError = null;
559
+ this.quiesce();
560
+ if (!entry || this.closed)
561
+ return;
562
+ this.spanEndS = await this.spanEndAfter(entry.anchor, targetS);
563
+ this.configureDecoder();
564
+ this.iterator = this.options.packets.packets(entry.anchor);
565
+ this.entry = entry;
566
+ this.exhausted = false;
567
+ this.anchors++;
568
+ this.epoch++;
569
+ this.reachable = entry.anchor.timestamp;
570
+ }
571
+ /**
572
+ * The packet to open a decode of `targetS` from: the container's own sync
573
+ * sample, unless a decoder has already proved that one is not a legal entry
574
+ * point, in which case the last verified IDR at or before the target.
575
+ *
576
+ * A sync sample that is itself an IDR is already the furthest-back entry
577
+ * worth reaching for, so a failure there is a failure of the source.
578
+ */
579
+ async resolveEntry(targetS) {
580
+ const sync = await this.options.packets.getKeyPacket(targetS, ANCHOR_PROBE);
581
+ if (sync) {
582
+ const key = anchorKey(sync.timestamp);
583
+ if (!this.rejectedAnchors.has(key)) {
584
+ return {
585
+ anchor: sync,
586
+ key,
587
+ hasFallback: !isIdrAccessUnit(sync.data, this.prefixWidth),
588
+ };
589
+ }
590
+ }
591
+ const idr = await this.options.packets.getKeyPacket(targetS, IDR_PROBE);
592
+ if (!idr)
593
+ return null;
594
+ return { anchor: idr, key: anchorKey(idr.timestamp), hasFallback: false };
595
+ }
596
+ /**
597
+ * Where the span a forward seek can reach without re-anchoring ends: the
598
+ * first sync sample past the target that is still worth anchoring at.
599
+ *
600
+ * Skipping the rejected ones is what keeps the pre-roll paid for once. Ending
601
+ * the span at an entry point already known not to decode would send the very
602
+ * next seek into that GOP back to the same rejected anchor and back through
603
+ * the same walk to recover from it.
604
+ */
605
+ async spanEndAfter(anchor, targetS) {
606
+ let key = anchor;
607
+ for (;;) {
608
+ const next = await this.options.packets.getNextKeyPacket(key, SPAN_END_PROBE);
609
+ if (!next)
610
+ return Infinity;
611
+ if (next.timestamp > targetS + BOUND_EPSILON_S &&
612
+ !this.rejectedAnchors.has(anchorKey(next.timestamp))) {
613
+ return next.timestamp;
614
+ }
615
+ key = next;
616
+ }
617
+ }
618
+ /** Drops the work in flight and the frames it produced, in one turn, so no
619
+ * output from the old anchor can land against the new one. */
620
+ quiesce() {
621
+ this.decoder?.reset();
622
+ this.pending.length = 0;
623
+ this.discardDecoded();
624
+ }
625
+ configureDecoder() {
626
+ try {
627
+ if (!this.decoder) {
628
+ // Named so the callback can check that the decoder reporting the
629
+ // failure is still the one driving the session: a decoder dropped for
630
+ // erroring may report again afterwards, and that report must not
631
+ // condemn the anchor built to replace it.
632
+ const built = this.options.createDecoder({
633
+ output: (frame) => this.receive(frame),
634
+ error: (error) => this.fail(built, error),
635
+ });
636
+ this.decoder = built;
637
+ }
638
+ // Without prompt per-frame emission a session that never flushes has
639
+ // no way to get its frames out, so the whole flush-free design rests
640
+ // here.
641
+ this.decoder.configure({
642
+ ...this.options.config,
643
+ optimizeForLatency: true,
644
+ });
645
+ }
646
+ catch (cause) {
647
+ throw this.stall("the decoder refused to configure", cause);
648
+ }
649
+ this.keyPacket.rearm();
650
+ }
651
+ /** One frame at or before `boundS`, or null once the bound is passed. A frame
652
+ * decoded past the bound stays queued for the next read rather than being
653
+ * handed out or thrown away. */
654
+ async pull(boundS) {
655
+ // Every hand-out moves the decoder forward past that frame.
656
+ for (;;) {
657
+ if (this.closed)
658
+ return null;
659
+ if (this.stalledError)
660
+ throw this.stalledError;
661
+ const failed = this.anchorError;
662
+ if (failed) {
663
+ await this.enterFurtherBack(failed);
664
+ continue;
665
+ }
666
+ await this.fill();
667
+ const ready = this.decoded[0];
668
+ if (ready) {
669
+ // Re-entering further back re-decodes ground the walk already
670
+ // covered, and the caller has seen those pictures.
671
+ if (ready.timestampS <= this.servedS + BOUND_EPSILON_S) {
672
+ this.decoded.shift();
673
+ ready.frame.close();
674
+ continue;
675
+ }
676
+ if (ready.timestampS > boundS + BOUND_EPSILON_S)
677
+ return null;
678
+ this.decoded.shift();
679
+ this.reachable = ready.timestampS;
680
+ this.servedS = ready.timestampS;
681
+ return ready;
682
+ }
683
+ // The pipeline holds requests a failed decoder will never answer, so
684
+ // waiting on them is waiting out the ceiling for nothing.
685
+ if (this.anchorError)
686
+ continue;
687
+ if (this.pending.length === 0)
688
+ return null;
689
+ // Nothing is left to submit at end of stream, so a flush is the only
690
+ // way to get the last frames out.
691
+ if (this.exhausted)
692
+ await this.drain();
693
+ else
694
+ await this.awaitOutput();
695
+ }
696
+ }
697
+ /**
698
+ * Re-opens the decode from the last entry point ahead of the failed one,
699
+ * which for an open GOP means the previous IDR plus a walk to the target.
700
+ *
701
+ * The failed anchor is struck off for the life of the session, so the cost is
702
+ * paid once per bad entry point rather than once per seek into it. When there
703
+ * is nothing further back to enter from, the failure is the source's and it
704
+ * latches here.
705
+ */
706
+ async enterFurtherBack(failure) {
707
+ const entry = this.entry;
708
+ if (!entry?.hasFallback)
709
+ throw this.latch(failure);
710
+ this.rejectedAnchors.add(entry.key);
711
+ // An errored WebCodecs decoder is already closed and cannot be
712
+ // reconfigured, so the replacement anchor needs a replacement decoder.
713
+ this.decoder = null;
714
+ await this.anchorAt(Math.max(this.entryTargetS, this.servedS));
715
+ if (!this.entry)
716
+ throw this.latch(failure);
717
+ }
718
+ /**
719
+ * Keeps the decoder's pipeline fed, regardless of which frame is being read.
720
+ * A decoder emits nothing until it holds enough pictures, so submitting only
721
+ * as far as the requested frame leaves the session waiting on output it is
722
+ * refusing to make possible. Feeding stops once the frames already decoded
723
+ * pile up, which is what bounds the memory the session holds.
724
+ */
725
+ async fill() {
726
+ while (this.decoder &&
727
+ // The error arrives between two of the reads below, and a decoder that
728
+ // has reported one is closed: every further chunk is refused, and being
729
+ // refused is what would condemn the whole session for a failure the
730
+ // anchor already owns.
731
+ !this.anchorError &&
732
+ this.pending.length < DECODER_PIPELINE_CHUNKS &&
733
+ this.decoded.length < READY_FRAMES) {
734
+ const packet = await this.peek();
735
+ if (!packet)
736
+ return;
737
+ if (this.closed || this.anchorError)
738
+ return;
739
+ this.peeked = null;
740
+ this.awaitTiming({
741
+ timestampS: packet.timestamp,
742
+ durationS: packet.duration,
743
+ });
744
+ // The chunk that opens the decode is the only one submitted as a key
745
+ // chunk: a later sync sample is a recovery point, not an IDR, and the
746
+ // decoder verifies the claim. It is the same chunk that carries the
747
+ // SEI, so one latch answers both.
748
+ const opensDecode = this.keyPacket.armed;
749
+ const data = this.keyPacket.satisfy(packet.data);
750
+ try {
751
+ this.decoder.decode({
752
+ type: opensDecode ? "key" : "delta",
753
+ timestamp: Math.round(packet.timestamp * MICROSECONDS_PER_SECOND),
754
+ duration: Math.round(packet.duration * MICROSECONDS_PER_SECOND),
755
+ data,
756
+ });
757
+ }
758
+ catch (cause) {
759
+ throw this.stall("the decoder refused a chunk", cause);
760
+ }
761
+ }
762
+ }
763
+ async peek() {
764
+ if (this.peeked)
765
+ return this.peeked;
766
+ if (!this.iterator || this.exhausted)
767
+ return null;
768
+ const result = await this.iterator.next();
769
+ if (result.done) {
770
+ this.exhausted = true;
771
+ return null;
772
+ }
773
+ this.peeked = result.value;
774
+ return this.peeked;
775
+ }
776
+ async drain() {
777
+ const decoder = this.decoder;
778
+ if (!decoder)
779
+ return;
780
+ await decoder.flush();
781
+ this.pending.length = 0;
782
+ this.keyPacket.rearm();
783
+ }
784
+ awaitOutput() {
785
+ return new Promise((resolve, reject) => {
786
+ const owed = this.pending.length;
787
+ const timer = setTimeout(() => {
788
+ this.wake = null;
789
+ // Drop the work this was waiting on before handing the failure
790
+ // out. The chunks it timed out on stay counted as in flight
791
+ // otherwise, and the next retrieval inherits a decoder that owes
792
+ // frames it will never produce, so it waits out the same timeout
793
+ // again instead of re-anchoring onto a clean one.
794
+ this.quiesce();
795
+ this.exhausted = true;
796
+ // Which failure this is turns on the output counter, not on the
797
+ // timer: a decoder that has handed frames back and then gone quiet
798
+ // is one a rebuild can recover, and a decoder that has answered a
799
+ // full pipeline of requests with nothing at all may never have been
800
+ // given a legal place to start, which is the anchor's to answer for.
801
+ if (this.framesDecodedCount === 0) {
802
+ this.anchorError ??= new WebVideoEngineError(WebVideoEngineErrorCode.DecoderStalled, `DecodeSession: the decoder acknowledged ${owed} decode requests and produced no frame`);
803
+ resolve();
804
+ return;
805
+ }
806
+ reject(new WebVideoEngineError(WebVideoEngineErrorCode.BackendCrashed, `DecodeSession: decoder produced no output in ${this.outputTimeoutMs}ms with ${owed} chunks in flight`));
807
+ }, this.outputTimeoutMs);
808
+ this.wake = () => {
809
+ this.wake = null;
810
+ clearTimeout(timer);
811
+ resolve();
812
+ };
813
+ });
814
+ }
815
+ /**
816
+ * Files a submitted chunk's timing in presentation order, which is the order
817
+ * pictures come back in and is not the order chunks go in on a B-frame
818
+ * source.
819
+ */
820
+ awaitTiming(timing) {
821
+ let at = this.pending.length;
822
+ while (at > 0 && this.pending[at - 1].timestampS > timing.timestampS)
823
+ at--;
824
+ this.pending.splice(at, 0, timing);
825
+ }
826
+ /**
827
+ * A decoder that echoes the timestamp it was handed names its own pending
828
+ * entry, which survives an output being dropped. One that counts from an
829
+ * origin of its own names nothing, and position is all that is left. Taking
830
+ * position alone would let a single dropped output shift every picture after
831
+ * it onto the wrong detections, permanently and without a symptom.
832
+ */
833
+ claimTiming(frame) {
834
+ const submittedS = frame.timestamp / MICROSECONDS_PER_SECOND;
835
+ const at = this.pending.findIndex((timing) => Math.abs(timing.timestampS - submittedS) <= TIMING_MATCH_S);
836
+ return at === -1 ? this.pending.shift() : this.pending.splice(at, 1)[0];
837
+ }
838
+ receive(frame) {
839
+ this.framesDecodedCount += 1;
840
+ const timing = this.claimTiming(frame);
841
+ if (this.closed || !timing) {
842
+ frame.close();
843
+ return;
844
+ }
845
+ let stable = frame;
846
+ if (this.outputWidth !== undefined && this.outputHeight !== undefined) {
847
+ try {
848
+ stable = this.snapshotFrame(frame, this.outputWidth, this.outputHeight);
849
+ }
850
+ catch (error) {
851
+ this.stall("could not own decoder output pixels", error);
852
+ this.wake?.();
853
+ return;
854
+ }
855
+ finally {
856
+ frame.close();
857
+ }
858
+ }
859
+ this.decoded.push({
860
+ frame: stable,
861
+ ...timing,
862
+ independentPixels: stable !== frame,
863
+ });
864
+ this.wake?.();
865
+ }
866
+ fail(from, error) {
867
+ if (from !== this.decoder)
868
+ return;
869
+ this.anchorError ??= new WebVideoEngineError(WebVideoEngineErrorCode.DecoderStalled, "DecodeSession: the decoder reported an error", error);
870
+ this.wake?.();
871
+ }
872
+ /** Latches the terminal failure and returns it. First writer wins, so the
873
+ * cause a caller is handed is the one that started the failure rather than
874
+ * whichever consequence surfaced last. */
875
+ stall(what, cause) {
876
+ return this.latch(new WebVideoEngineError(WebVideoEngineErrorCode.DecoderStalled, `DecodeSession: ${what}`, cause));
877
+ }
878
+ latch(error) {
879
+ this.stalledError ??= error;
880
+ return this.stalledError;
881
+ }
882
+ discardDecoded() {
883
+ for (const picture of this.decoded)
884
+ picture.frame.close();
885
+ this.decoded.length = 0;
886
+ }
887
+ }
888
+ function createVideoFrameSnapshotter() {
889
+ return (frame, width, height) => {
890
+ const canvas = new OffscreenCanvas(width, height);
891
+ const context = canvas.getContext("2d", { alpha: false });
892
+ if (!context)
893
+ throw new Error("2D canvas unavailable for frame snapshot");
894
+ context.drawImage(frame, 0, 0, width, height);
895
+ context.getImageData(0, 0, 1, 1);
896
+ return new VideoFrame(canvas, {
897
+ timestamp: frame.timestamp,
898
+ ...(frame.duration === null ? {} : { duration: frame.duration }),
899
+ });
900
+ };
901
+ }
902
+ /** A decoded frame in the runtime's own sample vocabulary, so a session frame
903
+ * and a VideoSampleSink frame reach the renderer the same way. */
904
+ function videoFrameSample({ frame, timestampS, durationS, independentPixels }, rotation) {
905
+ const quarterTurn = rotation % 180 !== 0;
906
+ return {
907
+ timestamp: timestampS,
908
+ duration: durationS,
909
+ rotation,
910
+ ...(independentPixels ? { independentPixels: true } : {}),
911
+ toVideoFrame: () => frame.clone(),
912
+ draw: (ctx, dx, dy, dWidth, dHeight) => {
913
+ drawRotated(ctx, frame, rotation, dx, dy, dWidth ?? (quarterTurn ? frame.displayHeight : frame.displayWidth), dHeight ?? (quarterTurn ? frame.displayWidth : frame.displayHeight));
914
+ },
915
+ close: () => frame.close(),
916
+ };
917
+ }
918
+
919
+ var ScrubCursorState;
920
+ (function (ScrubCursorState) {
921
+ ScrubCursorState["Idle"] = "idle";
922
+ ScrubCursorState["Seeking"] = "seeking";
923
+ ScrubCursorState["Closed"] = "closed";
924
+ })(ScrubCursorState || (ScrubCursorState = {}));
925
+ /**
926
+ * Wraps a raw sample so its close() runs at most once, then no-ops. The
927
+ * lifetime is hard to keep linear: a sample is drawn into the cache, stashed for
928
+ * paint, then closed after paint, and may be closed again on teardown if a
929
+ * gesture left it unpainted. An idempotent close lets every path call it
930
+ * defensively without double-free.
931
+ */
932
+ function idempotentSample(sample) {
933
+ let closed = false;
934
+ return {
935
+ toVideoFrame: () => sample.toVideoFrame(),
936
+ independentPixels: sample.independentPixels,
937
+ rotation: sample.rotation,
938
+ draw: (ctx, dx, dy, dWidth, dHeight) => sample.draw(ctx, dx, dy, dWidth, dHeight),
939
+ close: () => {
940
+ if (closed)
941
+ return;
942
+ closed = true;
943
+ sample.close();
944
+ },
945
+ get timestamp() {
946
+ return sample.timestamp;
947
+ },
948
+ get duration() {
949
+ return sample.duration;
950
+ },
951
+ };
952
+ }
953
+
954
+ function sampleAtPresentationTime(sample, timestamp) {
955
+ const owned = idempotentSample(sample);
956
+ return {
957
+ toVideoFrame: () => owned.toVideoFrame(),
958
+ independentPixels: owned.independentPixels,
959
+ rotation: owned.rotation,
960
+ draw: (ctx, dx, dy, dWidth, dHeight) => owned.draw(ctx, dx, dy, dWidth, dHeight),
961
+ close: () => owned.close(),
962
+ timestamp,
963
+ duration: owned.duration,
964
+ };
965
+ }
966
+ function presentationCanvasSource(source, timeline) {
967
+ const wrap = (frame) => frame
968
+ ? {
969
+ canvas: frame.canvas,
970
+ timestamp: timeline.fromSourceTime(frame.timestamp),
971
+ }
972
+ : null;
973
+ return {
974
+ async getCanvas(timestampS) {
975
+ return wrap(await source.getCanvas(timeline.toSourceTime(timestampS)));
976
+ },
977
+ async *canvases(startS) {
978
+ for await (const frame of source.canvases(timeline.toSourceTime(startS))) {
979
+ yield wrap(frame);
980
+ }
981
+ },
982
+ async *canvasesAtTimestamps(timestamps) {
983
+ const sourceTimestamps = Array.from(timestamps, (timestamp) => timeline.toSourceTime(timestamp));
984
+ for await (const frame of source.canvasesAtTimestamps(sourceTimestamps)) {
985
+ yield wrap(frame);
986
+ }
987
+ },
988
+ };
989
+ }
990
+ function presentationSampleSource(source, timeline) {
991
+ const wrap = (sample) => sample
992
+ ? sampleAtPresentationTime(sample, timeline.fromSourceTime(sample.timestamp))
993
+ : null;
994
+ return {
995
+ async getSample(timestampS) {
996
+ return wrap(await source.getSample(timeline.toSourceTime(timestampS)));
997
+ },
998
+ async *samples(startS) {
999
+ for await (const sample of source.samples(timeline.toSourceTime(startS))) {
1000
+ yield wrap(sample);
1001
+ }
1002
+ },
1003
+ async *samplesAtTimestamps(timestamps) {
1004
+ const sourceTimestamps = Array.from(timestamps, (timestamp) => timeline.toSourceTime(timestamp));
1005
+ for await (const sample of source.samplesAtTimestamps(sourceTimestamps)) {
1006
+ yield wrap(sample);
1007
+ }
1008
+ },
1009
+ };
1010
+ }
1011
+ function presentationSessionSource(source, timeline) {
1012
+ const wrap = (sample) => sample
1013
+ ? sampleAtPresentationTime(sample, timeline.fromSourceTime(sample.timestamp))
1014
+ : null;
1015
+ return {
1016
+ async frameAt(targetS) {
1017
+ return wrap(await source.frameAt(timeline.toSourceTime(targetS)));
1018
+ },
1019
+ async *framesFrom(startS) {
1020
+ for await (const sample of source.framesFrom(timeline.toSourceTime(startS))) {
1021
+ yield wrap(sample);
1022
+ }
1023
+ },
1024
+ async *framesCovering(startS, endS) {
1025
+ for await (const sample of source.framesCovering(timeline.toSourceTime(startS), timeline.toSourceTime(endS))) {
1026
+ yield wrap(sample);
1027
+ }
1028
+ },
1029
+ get reachableFromS() {
1030
+ return source.reachableFromS === -Infinity
1031
+ ? -Infinity
1032
+ : timeline.fromSourceTime(source.reachableFromS);
1033
+ },
1034
+ get framesDecoded() {
1035
+ return source.framesDecoded;
1036
+ },
1037
+ };
1038
+ }
1039
+ function presentationKeyframeProbe(source, timeline) {
1040
+ const sourcePackets = new WeakMap();
1041
+ const wrap = (packet) => {
1042
+ if (!packet)
1043
+ return null;
1044
+ const mapped = {
1045
+ timestamp: timeline.fromSourceTime(packet.timestamp),
1046
+ };
1047
+ sourcePackets.set(mapped, packet);
1048
+ return mapped;
1049
+ };
1050
+ return {
1051
+ async getKeyPacket(timestamp, options) {
1052
+ return wrap(await source.getKeyPacket(timeline.toSourceTime(timestamp), options));
1053
+ },
1054
+ async getNextKeyPacket(packet, options) {
1055
+ const sourcePacket = sourcePackets.get(packet) ?? packet;
1056
+ return wrap(await source.getNextKeyPacket(sourcePacket, options));
1057
+ },
1058
+ };
1059
+ }
1060
+ /**
1061
+ * Opens a source on the CanvasSink path, the one every decodable source
1062
+ * supports. Track facts are resolved here too, so the consumer receives a fully
1063
+ * described handle and never touches mediabunny or the resolution math. Playback
1064
+ * consumers call openScrubSource instead and take whatever path the source
1065
+ * supports; this is for consumers that want canvases specifically.
1066
+ */
1067
+ async function openDecodeSource(options) {
1068
+ return canvasHandle(await openInput(options), options.poolSize ?? SCRUB.DEFAULT_POOL_SIZE);
1069
+ }
1070
+ /**
1071
+ * Opens a source for a batch walk over its frames, on its own input and its own
1072
+ * decoder. A walk reads thousands of frames in a row and a playback cursor is
1073
+ * serving a screen, so the two never share: whatever a walk does to its read
1074
+ * head, no gesture is waiting behind it.
1075
+ *
1076
+ * The CanvasSink path is not offered here. It yields recycled canvases with no
1077
+ * duration and no close, and a walk's whole contract is real frames with the
1078
+ * timing they were encoded with, so a source that cannot present samples cannot
1079
+ * be walked. Frames arrive at the source's native resolution; nothing downscales
1080
+ * them on the way out.
1081
+ */
1082
+ async function openWalkSource(source) {
1083
+ const opened = await openInput({ source });
1084
+ const decoderConfig = await opened.videoTrack.getDecoderConfig();
1085
+ if (decoderConfig &&
1086
+ decodeSessionViable({
1087
+ videoDecoderAvailable: detectVideoDecoder(),
1088
+ decoderConfig,
1089
+ })) {
1090
+ const handle = sessionHandle(opened, decoderConfig);
1091
+ return walkHandle(handle, (startS) => handle.session.framesFrom(startS));
1092
+ }
1093
+ const handle = sampleHandle(opened);
1094
+ return walkHandle(handle, (startS) => handle.sampleSink.samples(startS));
1095
+ }
1096
+ function walkHandle(handle, stream) {
1097
+ return {
1098
+ track: handle.track,
1099
+ async *framesFrom(startS) {
1100
+ for await (const sample of stream(startS))
1101
+ yield idempotentSample(sample);
1102
+ },
1103
+ dispose: () => handle.dispose(),
1104
+ };
1105
+ }
1106
+ function canvasHandle(opened, poolSize) {
1107
+ // Width-only: CanvasSink derives height from native aspect. This sizes the
1108
+ // canvas the frame is drawn into; it does NOT reduce decode cost. The codec
1109
+ // decodes the full coded frame regardless, and CanvasSink resizes after, so
1110
+ // the win is paint work and cached-blit memory, not decode throughput.
1111
+ const source = new CanvasSink(opened.videoTrack, {
1112
+ poolSize,
1113
+ width: opened.track.decodeWidth,
1114
+ });
1115
+ const { timeline } = opened.track;
1116
+ return {
1117
+ track: opened.track,
1118
+ sink: presentationCanvasSource(source, timeline),
1119
+ keyframeProbe: presentationKeyframeProbe(new EncodedPacketSink(opened.videoTrack), timeline),
1120
+ dispose: async () => {
1121
+ opened.input.dispose();
1122
+ },
1123
+ };
1124
+ }
1125
+ /** The zero-copy sample path: a VideoSampleSink in place of the CanvasSink. It
1126
+ * takes no width, so frames arrive at the source's own dimensions. */
1127
+ function sampleHandle(opened, optimizeForLatency = false) {
1128
+ const { timeline } = opened.track;
1129
+ return {
1130
+ track: opened.track,
1131
+ sampleSink: presentationSampleSource(new VideoSampleSink(opened.videoTrack, optimizeForLatency ? { optimizeForLatency: true } : undefined), timeline),
1132
+ keyframeProbe: presentationKeyframeProbe(new EncodedPacketSink(opened.videoTrack), timeline),
1133
+ dispose: async () => {
1134
+ opened.input.dispose();
1135
+ },
1136
+ };
1137
+ }
1138
+ /**
1139
+ * The long-lived-decoder path: an EncodedPacketSink feeding a DecodeSession that
1140
+ * holds one VideoDecoder across seeks. The same sink answers the keyframe probe,
1141
+ * so anchor resolution and packet reads share one reader. The session drives the
1142
+ * decoder itself and every frame arrives at the source's own dimensions; the
1143
+ * resolved decodeWidth still sizes the canvas and the cache blits, which scale
1144
+ * the native frame down as they draw it.
1145
+ */
1146
+ function sessionHandle(opened, config) {
1147
+ const packets = new EncodedPacketSink(opened.videoTrack);
1148
+ const ownDecoderOutputPixels = detectDecodeSessionPixelOwnership();
1149
+ const session = new DecodeSession({
1150
+ packets,
1151
+ config,
1152
+ createDecoder: webCodecsDecoder,
1153
+ rotation: opened.track.rotation,
1154
+ ...(ownDecoderOutputPixels
1155
+ ? {
1156
+ outputHeight: opened.track.decodeHeight,
1157
+ outputWidth: opened.track.decodeWidth,
1158
+ }
1159
+ : {}),
1160
+ });
1161
+ const { timeline } = opened.track;
1162
+ return {
1163
+ track: opened.track,
1164
+ session: presentationSessionSource(session, timeline),
1165
+ keyframeProbe: presentationKeyframeProbe(packets, timeline),
1166
+ dispose: async () => {
1167
+ session.close();
1168
+ opened.input.dispose();
1169
+ },
1170
+ };
1171
+ }
1172
+ function webCodecsDecoder(init) {
1173
+ const decoder = new VideoDecoder(init);
1174
+ return {
1175
+ configure: (config) => decoder.configure(config),
1176
+ decode: (chunk) => decoder.decode(new EncodedVideoChunk(chunk)),
1177
+ flush: () => decoder.flush(),
1178
+ // Reporting an error closes a WebCodecs decoder, and both of these throw
1179
+ // on one that is already closed. Teardown of a decoder that failed is a
1180
+ // routine path, so the platform's rule is answered here rather than left
1181
+ // for every caller to remember.
1182
+ reset: () => {
1183
+ if (decoder.state !== "closed")
1184
+ decoder.reset();
1185
+ },
1186
+ close: () => {
1187
+ if (decoder.state !== "closed")
1188
+ decoder.close();
1189
+ },
1190
+ };
1191
+ }
1192
+ /** Opens the input, resolves the primary video track, and runs the decode
1193
+ * resolution math, the work every path shares before one is chosen.
1194
+ *
1195
+ * Four ways to get no frames out of a file, and each one throws its own code
1196
+ * so a host can say which happened: the container is not one the demuxer
1197
+ * reads (ContainerUnreadable), the container reads but nothing inside it does
1198
+ * (VideoTrackUnreadable), the tracks read and none is video (NoVideoTrack), or
1199
+ * the video track reads and the browser has no decoder for its codec
1200
+ * (DecodeUnsupported). */
1201
+ async function openInput(options) {
1202
+ const { source } = options;
1203
+ const input = new Input({
1204
+ formats: ALL_FORMATS,
1205
+ source: toMediabunnySource(source, options.sourceResidency, options.urlSource),
1206
+ });
1207
+ const videoTrack = await resolveVideoTrack(input);
1208
+ if (!(await videoTrack.canDecode())) {
1209
+ const decoderConfig = await videoTrack.getDecoderConfig();
1210
+ throw new WebVideoEngineError(WebVideoEngineErrorCode.DecodeUnsupported, `openInput: browser cannot decode this video track's codec ${decoderConfig?.codec ?? "(unknown)"}`);
1211
+ }
1212
+ const displayWidth = videoTrack.displayWidth;
1213
+ const displayHeight = videoTrack.displayHeight;
1214
+ const durationS = asSec(await readTrackDurationS(videoTrack));
1215
+ const timeline = await readFrameTimeline(videoTrack);
1216
+ // Mediabunny ships a measured native fps via computePacketStats:
1217
+ // averagePacketRate "for video tracks, equals the average frame rate". We
1218
+ // bound the packet sample to a small slice so the load promise doesn't stall
1219
+ // on long files. The result is good enough for 1/nativeFps step math;
1220
+ // sub-frame precision falls through to stepForward/stepBackward.
1221
+ const nativeFps = await readTrackFps(videoTrack);
1222
+ const strategy = options.decodeStrategy ?? nativeResolution();
1223
+ const { width: decodeWidth, height: decodeHeight } = resolveDecodeDimensions(strategy, {
1224
+ nativeWidth: displayWidth,
1225
+ nativeHeight: displayHeight,
1226
+ displayWidth: options.viewport?.displayWidth ?? null,
1227
+ devicePixelRatio: options.viewport?.devicePixelRatio ?? 1,
1228
+ });
1229
+ return {
1230
+ input,
1231
+ videoTrack,
1232
+ strategy,
1233
+ track: {
1234
+ width: displayWidth,
1235
+ height: displayHeight,
1236
+ decodeWidth,
1237
+ decodeHeight,
1238
+ rotation: videoTrack.rotation,
1239
+ nativeFps,
1240
+ durationS,
1241
+ firstTimestampS: timeline.timeAt(0),
1242
+ timeline,
1243
+ },
1244
+ };
1245
+ }
1246
+ /**
1247
+ * The primary video track, or the typed refusal that says which of the three
1248
+ * ways to reach no track this file took. Only the last of them, where the
1249
+ * demuxer listed tracks and none was video, is a statement about the file
1250
+ * itself; the other two say what this build can read.
1251
+ */
1252
+ async function resolveVideoTrack(input) {
1253
+ let videoTrack;
1254
+ try {
1255
+ videoTrack = await input.getPrimaryVideoTrack();
1256
+ }
1257
+ catch (error) {
1258
+ if (error instanceof UnsupportedInputFormatError) {
1259
+ throw new WebVideoEngineError(WebVideoEngineErrorCode.ContainerUnreadable, "openInput: the demuxer does not read this file's container", error);
1260
+ }
1261
+ throw error;
1262
+ }
1263
+ if (videoTrack)
1264
+ return videoTrack;
1265
+ const tracks = await input.getTracks();
1266
+ throw tracks.length === 0
1267
+ ? new WebVideoEngineError(WebVideoEngineErrorCode.VideoTrackUnreadable, "openInput: the container opened and the demuxer parsed no track out of it")
1268
+ : new WebVideoEngineError(WebVideoEngineErrorCode.NoVideoTrack, "openInput: the container's tracks read and none of them carries video");
1269
+ }
1270
+ /**
1271
+ * Walks the track's packets metadata-only and records each one's timestamp in
1272
+ * the container's own integer grain, which is the grain every timestamp of the
1273
+ * track is a whole multiple of.
1274
+ *
1275
+ * Decode order is not presentation order on a B-frame source, so the table is
1276
+ * sorted before it is indexed, and the trailing frame's duration comes from the
1277
+ * packet that ends up last in that order.
1278
+ */
1279
+ async function readFrameTimeline(videoTrack) {
1280
+ const track = videoTrack;
1281
+ const tickRate = typeof track.getTimeResolution === "function"
1282
+ ? await track.getTimeResolution()
1283
+ : (track.timeResolution ?? FRAME_TIMELINE.FALLBACK_TICK_RATE);
1284
+ const sink = new EncodedPacketSink(videoTrack);
1285
+ const ticks = [];
1286
+ let lastTicks = -Infinity;
1287
+ let lastDurationTicks = 0;
1288
+ for await (const packet of sink.packets(undefined, undefined, {
1289
+ metadataOnly: true,
1290
+ })) {
1291
+ const at = Math.round(packet.timestamp * tickRate);
1292
+ if (ticks.length >= FRAME_TIMELINE.MAX_FRAMES) {
1293
+ throw new WebVideoEngineError(WebVideoEngineErrorCode.DecodeUnsupported, `openInput: source video track carries more than ${FRAME_TIMELINE.MAX_FRAMES} frames`);
1294
+ }
1295
+ ticks.push(at);
1296
+ if (at >= lastTicks) {
1297
+ lastTicks = at;
1298
+ lastDurationTicks = Math.round(packet.duration * tickRate);
1299
+ }
1300
+ }
1301
+ if (ticks.length === 0) {
1302
+ throw new WebVideoEngineError(WebVideoEngineErrorCode.DecodeUnsupported, "openInput: source video track has no frames");
1303
+ }
1304
+ ticks.sort((a, b) => a - b);
1305
+ return FrameTimeline.from({
1306
+ lastDurationTicks,
1307
+ tickRate,
1308
+ ticks: Float64Array.from(ticks),
1309
+ });
1310
+ }
1311
+ function shouldOwnDecodeSessionPixels(context) {
1312
+ if (!context.offscreenCanvasAvailable)
1313
+ return false;
1314
+ const clientPlatform = context.clientPlatform?.trim();
1315
+ if (clientPlatform)
1316
+ return clientPlatform.toLowerCase() === "android";
1317
+ return /\bAndroid\b/i.test(context.userAgent ?? "");
1318
+ }
1319
+ function detectDecodeSessionPixelOwnership() {
1320
+ const navigator = globalThis.navigator;
1321
+ return shouldOwnDecodeSessionPixels({
1322
+ clientPlatform: navigator?.userAgentData?.platform,
1323
+ offscreenCanvasAvailable: typeof globalThis.OffscreenCanvas ===
1324
+ "function",
1325
+ userAgent: navigator?.userAgent,
1326
+ });
1327
+ }
1328
+ /**
1329
+ * The gate. The session reaches a non-IDR anchor by declaring it a recovery
1330
+ * point, which only holds for AVCC-framed H.264 carrying its parameter sets, so
1331
+ * every other source keeps the mediabunny sinks.
1332
+ */
1333
+ function decodeSessionViable(ctx) {
1334
+ if (!ctx.videoDecoderAvailable)
1335
+ return false;
1336
+ return sessionDrivable(ctx.decoderConfig);
1337
+ }
1338
+ /** Whether this realm exposes the WebCodecs decoder the session constructs. */
1339
+ function detectVideoDecoder() {
1340
+ return (typeof globalThis.VideoDecoder ===
1341
+ "function");
1342
+ }
1343
+ function urlRequestInit(crossOrigin) {
1344
+ if (!crossOrigin)
1345
+ return undefined;
1346
+ return {
1347
+ requestInit: {
1348
+ mode: "cors",
1349
+ credentials: crossOrigin === "use-credentials" ? "include" : "omit",
1350
+ },
1351
+ };
1352
+ }
1353
+ function toMediabunnySource(source, residency, urlSource) {
1354
+ switch (source.kind) {
1355
+ case SourceKind.Url: {
1356
+ const options = {
1357
+ ...urlRequestInit(source.crossOrigin),
1358
+ ...(residency ? { fetchFn: residency.fetchFn } : {}),
1359
+ ...(urlSource?.maxCacheSize === undefined
1360
+ ? {}
1361
+ : { maxCacheSize: urlSource.maxCacheSize }),
1362
+ ...(urlSource?.parallelism === undefined
1363
+ ? {}
1364
+ : { parallelism: urlSource.parallelism }),
1365
+ };
1366
+ return new UrlSource(source.url, Object.keys(options).length > 0 ? options : undefined);
1367
+ }
1368
+ case SourceKind.Blob:
1369
+ return new BlobSource(source.blob);
1370
+ case SourceKind.Stream:
1371
+ return new ReadableStreamSource(source.stream);
1372
+ }
1373
+ }
1374
+ async function readTrackDurationS(track) {
1375
+ const t = track;
1376
+ if (typeof t.computeDuration === "function") {
1377
+ const d = await t.computeDuration();
1378
+ return Number.isFinite(d) ? d : 0;
1379
+ }
1380
+ if (typeof t.duration === "number")
1381
+ return t.duration;
1382
+ if (typeof t.durationS === "number")
1383
+ return t.durationS;
1384
+ return 0;
1385
+ }
1386
+ /**
1387
+ * Reads the native frame rate from mediabunny's computePacketStats. Bounded to a
1388
+ * small packet sample so load() does not stall on long files. Returns null when
1389
+ * the track exposes no stats API or the value is non-finite.
1390
+ */
1391
+ async function readTrackFps(track) {
1392
+ const t = track;
1393
+ if (typeof t.computePacketStats !== "function")
1394
+ return null;
1395
+ try {
1396
+ const stats = await t.computePacketStats(120);
1397
+ const fps = stats?.averagePacketRate;
1398
+ if (typeof fps !== "number" || !Number.isFinite(fps) || fps <= 0)
1399
+ return null;
1400
+ return fps;
1401
+ }
1402
+ catch {
1403
+ return null;
1404
+ }
1405
+ }
1406
+
1407
+ /**
1408
+ * Lazy index of a video track's keyframe timestamps, in seconds.
1409
+ *
1410
+ * Seeking decodes forward from the keyframe at-or-before the target, so every
1411
+ * seek needs that anchor. Resolving one costs a container round-trip, so the
1412
+ * index remembers each keyframe it discovers; repeated seeks around the same
1413
+ * region then resolve synchronously through `cachedAtOrBefore`.
1414
+ */
1415
+ // Only timestamps are read, never frame bytes, so every probe is metadata-only.
1416
+ const METADATA_ONLY = { metadataOnly: true };
1417
+ class KeyframeIndex {
1418
+ probe;
1419
+ times = [];
1420
+ probeRoundTrips = 0;
1421
+ /** Set once a full walk has completed, so a second call is a free no-op. */
1422
+ fullyIndexed = false;
1423
+ constructor(probe) {
1424
+ this.probe = probe;
1425
+ }
1426
+ /** Keyframe timestamps discovered so far, ascending and deduplicated. */
1427
+ get known() {
1428
+ return this.times;
1429
+ }
1430
+ /** Container queries the index has made; the rest resolve from memory. */
1431
+ get probeCount() {
1432
+ return this.probeRoundTrips;
1433
+ }
1434
+ /**
1435
+ * GOP-gap distribution over the keyframes discovered so far. Pass the track
1436
+ * duration so density reflects keyframes across the whole clip, not just the
1437
+ * clustered swept span; without it a few discovered keyframes in one region
1438
+ * would read as a high density that does not hold track-wide.
1439
+ */
1440
+ gopStats(durationS) {
1441
+ const t = this.times;
1442
+ const n = t.length;
1443
+ if (n < 2) {
1444
+ return {
1445
+ count: n,
1446
+ avgGopS: 0,
1447
+ maxGopS: 0,
1448
+ minGopS: 0,
1449
+ stddevS: 0,
1450
+ densityPerS: 0,
1451
+ };
1452
+ }
1453
+ const gaps = n - 1;
1454
+ let sum = 0;
1455
+ let max = -Infinity;
1456
+ let min = Infinity;
1457
+ for (let i = 1; i < n; i++) {
1458
+ const gap = t[i] - t[i - 1];
1459
+ sum += gap;
1460
+ if (gap > max)
1461
+ max = gap;
1462
+ if (gap < min)
1463
+ min = gap;
1464
+ }
1465
+ const avg = sum / gaps;
1466
+ let variance = 0;
1467
+ for (let i = 1; i < n; i++) {
1468
+ const d = t[i] - t[i - 1] - avg;
1469
+ variance += d * d;
1470
+ }
1471
+ const span = durationS && durationS > 0 ? durationS : t[n - 1] - t[0];
1472
+ return {
1473
+ count: n,
1474
+ avgGopS: avg,
1475
+ maxGopS: max,
1476
+ minGopS: min,
1477
+ stddevS: Math.sqrt(variance / gaps),
1478
+ densityPerS: span > 0 ? gaps / span : 0,
1479
+ };
1480
+ }
1481
+ /**
1482
+ * Nearest keyframe at or before `tSec`, the anchor a seek decodes forward
1483
+ * from. Null when `tSec` precedes the first keyframe.
1484
+ */
1485
+ async keyframeAtOrBefore(tSec) {
1486
+ this.probeRoundTrips++;
1487
+ const packet = await this.probe.getKeyPacket(tSec, METADATA_ONLY);
1488
+ if (!packet)
1489
+ return null;
1490
+ this.record(packet.timestamp);
1491
+ return packet.timestamp;
1492
+ }
1493
+ /**
1494
+ * First keyframe strictly after `tSec`, the boundary a forward decode runs
1495
+ * up to. Null when no keyframe follows, or when `tSec` precedes the first
1496
+ * keyframe (no anchor to step from).
1497
+ */
1498
+ async nextKeyframeAfter(tSec) {
1499
+ this.probeRoundTrips++;
1500
+ const anchor = await this.probe.getKeyPacket(tSec, METADATA_ONLY);
1501
+ if (!anchor)
1502
+ return null;
1503
+ this.record(anchor.timestamp);
1504
+ this.probeRoundTrips++;
1505
+ const next = await this.probe.getNextKeyPacket(anchor, METADATA_ONLY);
1506
+ if (!next)
1507
+ return null;
1508
+ this.record(next.timestamp);
1509
+ return next.timestamp;
1510
+ }
1511
+ /**
1512
+ * Every keyframe needed to decode the window `[startS, endS]`: the anchor at
1513
+ * or before `startS` plus each keyframe up to `endS`. Empty when `startS`
1514
+ * precedes the first keyframe.
1515
+ */
1516
+ async keyframesCovering(startS, endS) {
1517
+ this.probeRoundTrips++;
1518
+ let packet = await this.probe.getKeyPacket(startS, METADATA_ONLY);
1519
+ if (!packet)
1520
+ return [];
1521
+ const covering = [];
1522
+ while (packet && packet.timestamp <= endS) {
1523
+ this.record(packet.timestamp);
1524
+ covering.push(packet.timestamp);
1525
+ this.probeRoundTrips++;
1526
+ packet = await this.probe.getNextKeyPacket(packet, METADATA_ONLY);
1527
+ }
1528
+ return covering;
1529
+ }
1530
+ /**
1531
+ * Walks the whole track once, recording every keyframe timestamp. Metadata
1532
+ * only: it threads the same getKeyPacket / getNextKeyPacket pattern as the
1533
+ * lazy probes, so it never decodes a pixel. Idempotent: a second call after a
1534
+ * completed walk returns at once. Meant for the diagnostics lane, off the hot
1535
+ * seek path, so the purple lane reflects the whole file rather than only the
1536
+ * regions a scrub has visited.
1537
+ */
1538
+ async ensureFullyIndexed(fromS = 0) {
1539
+ if (this.fullyIndexed)
1540
+ return;
1541
+ this.probeRoundTrips++;
1542
+ let packet = await this.probe.getKeyPacket(fromS, METADATA_ONLY);
1543
+ while (packet) {
1544
+ this.record(packet.timestamp);
1545
+ this.probeRoundTrips++;
1546
+ packet = await this.probe.getNextKeyPacket(packet, METADATA_ONLY);
1547
+ }
1548
+ this.fullyIndexed = true;
1549
+ }
1550
+ /**
1551
+ * Largest discovered keyframe at or before `tSec`, resolved synchronously
1552
+ * from memory. Null when nothing at or before `tSec` has been discovered
1553
+ * yet; callers fall back to the async probes above.
1554
+ */
1555
+ cachedAtOrBefore(tSec) {
1556
+ const i = floorIndex(this.times, tSec);
1557
+ return i < 0 ? null : this.times[i];
1558
+ }
1559
+ record(t) {
1560
+ const i = lowerBound(this.times, t);
1561
+ if (i < this.times.length && this.times[i] === t)
1562
+ return;
1563
+ this.times.splice(i, 0, t);
1564
+ }
1565
+ }
1566
+ /** First index whose value is `>= target` (insertion point for `target`). */
1567
+ function lowerBound(values, target) {
1568
+ let lo = 0;
1569
+ let hi = values.length;
1570
+ while (lo < hi) {
1571
+ const mid = (lo + hi) >>> 1;
1572
+ if (values[mid] < target)
1573
+ lo = mid + 1;
1574
+ else
1575
+ hi = mid;
1576
+ }
1577
+ return lo;
1578
+ }
1579
+ /** Largest index whose value is `<= target`, or -1 when all exceed `target`. */
1580
+ function floorIndex(values, target) {
1581
+ let lo = 0;
1582
+ let hi = values.length;
1583
+ while (lo < hi) {
1584
+ const mid = (lo + hi) >>> 1;
1585
+ if (values[mid] <= target)
1586
+ lo = mid + 1;
1587
+ else
1588
+ hi = mid;
1589
+ }
1590
+ return lo - 1;
1591
+ }
1592
+
1593
+ class AnalysisSession {
1594
+ sink;
1595
+ keyframeIndex;
1596
+ track;
1597
+ disposeSource;
1598
+ closed = false;
1599
+ constructor(source) {
1600
+ this.sink = source.sink;
1601
+ this.keyframeIndex = new KeyframeIndex(source.keyframeProbe);
1602
+ this.track = source.track;
1603
+ this.disposeSource = () => source.dispose();
1604
+ }
1605
+ /** Opens a source for analysis. The returned session owns the source. */
1606
+ static async open(options) {
1607
+ return new AnalysisSession(await openDecodeSource(options));
1608
+ }
1609
+ get metadata() {
1610
+ return {
1611
+ durationS: this.track.durationS,
1612
+ width: this.track.width,
1613
+ height: this.track.height,
1614
+ frameWidth: this.track.decodeWidth,
1615
+ frameHeight: this.track.decodeHeight,
1616
+ nativeFps: this.track.nativeFps,
1617
+ };
1618
+ }
1619
+ /**
1620
+ * Keyframe timestamps (seconds) whose GOP overlaps the range, resolved
1621
+ * lazily through the container. Useful for a contact sheet that lands on
1622
+ * real I-frames, which decode without a GOP walk.
1623
+ */
1624
+ keyframeTimestamps(startS = 0, endS = this.track.durationS) {
1625
+ return this.keyframeIndex.keyframesCovering(startS, endS);
1626
+ }
1627
+ /**
1628
+ * Decodes a frame for each requested timestamp and returns a stable copy of
1629
+ * each. Timestamps are sorted so the sink decodes each packet at most once;
1630
+ * a timestamp with no frame is skipped rather than yielding a gap.
1631
+ */
1632
+ async extractFrames(timestampsS) {
1633
+ const sorted = [...timestampsS].sort((a, b) => a - b);
1634
+ const frames = [];
1635
+ for await (const frame of this.framesAtTimestamps(sorted)) {
1636
+ if (frame)
1637
+ frames.push(frame);
1638
+ }
1639
+ return frames;
1640
+ }
1641
+ /**
1642
+ * One frame per requested timestamp, in the order asked for, `null` where no
1643
+ * frame covers it, so a caller pairing frames with its own indices keeps the
1644
+ * gaps attributable. Timestamps that climb decode in a single pass over the
1645
+ * track: one seek and GOP walk for the whole set.
1646
+ *
1647
+ * A consumer that finishes with each frame before pulling the next holds one
1648
+ * frame of memory; {@link extractFrames} holds the whole set.
1649
+ */
1650
+ async *framesAtTimestamps(timestampsS) {
1651
+ if (this.closed || timestampsS.length === 0)
1652
+ return;
1653
+ const iter = this.sink.canvasesAtTimestamps(timestampsS);
1654
+ try {
1655
+ for (let result = await iter.next(); !result.done; result = await iter.next()) {
1656
+ if (this.closed)
1657
+ break;
1658
+ yield result.value ? this.copyFrame(result.value) : null;
1659
+ }
1660
+ }
1661
+ catch (error) {
1662
+ // A concurrent close() disposes the source mid-decode; end the stream on
1663
+ // the frames already yielded rather than throwing on the teardown.
1664
+ if (!this.closed)
1665
+ throw error;
1666
+ }
1667
+ finally {
1668
+ void iter.return();
1669
+ }
1670
+ }
1671
+ async close() {
1672
+ this.closed = true;
1673
+ await this.disposeSource();
1674
+ }
1675
+ copyFrame(frame) {
1676
+ const width = this.track.decodeWidth;
1677
+ const height = this.track.decodeHeight;
1678
+ const canvas = new OffscreenCanvas(width, height);
1679
+ // Frames are opaque video; alpha:false skips per-pixel blend.
1680
+ const ctx = canvas.getContext("2d", { alpha: false });
1681
+ ctx?.drawImage(frame.canvas, 0, 0, width, height);
1682
+ return { timestampS: frame.timestamp, canvas, width, height };
1683
+ }
1684
+ }
1685
+
1686
+ /**
1687
+ * A second consumer of the runtime, built only on AnalysisSession's public
1688
+ * surface, with no reach into the cursor, cache, worker, or render loop. Its
1689
+ * existence is the proof that the analysis primitive is reusable: a thumbnail
1690
+ * strip or contact sheet is just a choice of which timestamps to pull, and the
1691
+ * open/close pair shows the full source lifecycle is reachable from the public
1692
+ * API alone.
1693
+ */
1694
+ class FrameExtractor {
1695
+ session;
1696
+ constructor(session) {
1697
+ this.session = session;
1698
+ }
1699
+ /** Opens a source and returns an extractor that owns it; close() disposes it. */
1700
+ static async open(options) {
1701
+ return new FrameExtractor(await AnalysisSession.open(options));
1702
+ }
1703
+ /** Frames at exactly the given timestamps (seconds). */
1704
+ extractAt(timestampsS) {
1705
+ return this.session.extractFrames(timestampsS);
1706
+ }
1707
+ /**
1708
+ * `count` frames evenly spaced across the source, each sampled at the
1709
+ * middle of its slice so neither the first black frame nor the exact end
1710
+ * dominates the strip.
1711
+ */
1712
+ evenlySpaced(count) {
1713
+ const { durationS } = this.session.metadata;
1714
+ if (count <= 0 || durationS <= 0)
1715
+ return Promise.resolve([]);
1716
+ const step = durationS / count;
1717
+ const times = Array.from({ length: count }, (_, i) => i * step + step / 2);
1718
+ return this.session.extractFrames(times);
1719
+ }
1720
+ /** One frame per keyframe in the range: a contact sheet that decodes cheaply. */
1721
+ async atKeyframes(startS, endS) {
1722
+ const keyframes = await this.session.keyframeTimestamps(startS, endS);
1723
+ return this.session.extractFrames(keyframes);
1724
+ }
1725
+ /** Disposes the underlying analysis session. */
1726
+ close() {
1727
+ return this.session.close();
1728
+ }
1729
+ }
1730
+
1731
+ const MS_PER_SECOND = 1000;
1732
+ /** One microsecond, the grain container timestamps are stored at, so a bound
1733
+ * that lands on a frame is never pushed off it by float error. */
1734
+ const BOUND_EPSILON_MS = 1e-3;
1735
+ /**
1736
+ * Every real decoded frame of a range, in presentation order, exactly once.
1737
+ *
1738
+ * Nothing here is synthesized: the walk reads the source's own frames and counts
1739
+ * them. Timestamps built from a frame rate and resolved through at-or-before
1740
+ * extraction cannot promise that. One falling a float's width short of a sample
1741
+ * lands on the frame before it, so the caller gets that frame twice and never
1742
+ * sees the one it was aiming at.
1743
+ *
1744
+ * Every sample the walk decodes and does not yield is closed here: the frames
1745
+ * before the range, the ones stride skips, and the first one past the end.
1746
+ * Breaking out of the walk returns the underlying stream, which releases the
1747
+ * decoder's read of the source; the sample in the caller's hand at that moment
1748
+ * is still the caller's to close.
1749
+ */
1750
+ async function* walkFrames(source, range = {}) {
1751
+ const stride = range.stride ?? 1;
1752
+ if (!Number.isInteger(stride) || stride < 1) {
1753
+ throw new RangeError(`walkFrames: stride must be a positive integer, got ${stride}`);
1754
+ }
1755
+ const startMs = range.startMs ?? source.track.firstTimestampS * MS_PER_SECOND;
1756
+ const endMs = range.endMs ?? Infinity;
1757
+ if (endMs <= startMs)
1758
+ return;
1759
+ const frames = source.framesFrom(startMs / MS_PER_SECOND);
1760
+ let framesInRange = 0;
1761
+ try {
1762
+ for (let next = await frames.next(); !next.done; next = await frames.next()) {
1763
+ const sample = next.value;
1764
+ const timestampMs = sample.timestamp * MS_PER_SECOND;
1765
+ if (timestampMs < startMs - BOUND_EPSILON_MS) {
1766
+ // The stream opens on the frame covering startMs, which is a
1767
+ // frame the range does not contain.
1768
+ sample.close();
1769
+ continue;
1770
+ }
1771
+ if (timestampMs >= endMs - BOUND_EPSILON_MS) {
1772
+ sample.close();
1773
+ return;
1774
+ }
1775
+ const frameIndex = framesInRange++;
1776
+ if (frameIndex % stride !== 0) {
1777
+ sample.close();
1778
+ continue;
1779
+ }
1780
+ yield {
1781
+ frameIndex,
1782
+ timestampMs,
1783
+ durationMs: sample.duration * MS_PER_SECOND,
1784
+ sample,
1785
+ };
1786
+ }
1787
+ }
1788
+ finally {
1789
+ await frames.return();
1790
+ }
1791
+ }
1792
+ /**
1793
+ * A batch consumer's entry to the runtime: opens a source, walks its real
1794
+ * frames, and disposes what it opened. It spins no worker, binds no canvas, and
1795
+ * runs no clock, and it decodes on an input of its own, so a walk and a playing
1796
+ * engine never contend for one decoder's read head.
1797
+ *
1798
+ * A walk in flight ends when the walker is closed, the same way extraction does,
1799
+ * so a consumer that abandons a job mid-range has one call to make.
1800
+ */
1801
+ class FrameWalker {
1802
+ handle;
1803
+ closed = false;
1804
+ constructor(handle) {
1805
+ this.handle = handle;
1806
+ }
1807
+ /** Opens a source for walking; the returned walker owns it. */
1808
+ static async open(source) {
1809
+ return new FrameWalker(await openWalkSource(source));
1810
+ }
1811
+ get metadata() {
1812
+ const { track } = this.handle;
1813
+ return {
1814
+ durationS: track.durationS,
1815
+ width: track.width,
1816
+ height: track.height,
1817
+ nativeFps: track.nativeFps,
1818
+ firstTimestampS: track.firstTimestampS,
1819
+ };
1820
+ }
1821
+ async *walkFrames(range = {}) {
1822
+ if (this.closed)
1823
+ return;
1824
+ const walk = walkFrames(this.handle, range);
1825
+ try {
1826
+ for (let next = await walk.next(); !next.done; next = await walk.next()) {
1827
+ if (this.closed) {
1828
+ next.value.sample.close();
1829
+ return;
1830
+ }
1831
+ yield next.value;
1832
+ }
1833
+ }
1834
+ catch (error) {
1835
+ // A concurrent close() disposes the source mid-decode; that teardown
1836
+ // ends the walk and is not a decode failure.
1837
+ if (!this.closed)
1838
+ throw error;
1839
+ }
1840
+ finally {
1841
+ await walk.return();
1842
+ }
1843
+ }
1844
+ async close() {
1845
+ this.closed = true;
1846
+ await this.handle.dispose();
1847
+ }
1848
+ }
1849
+
1850
+ export { AnalysisSession, FrameExtractor, FrameWalker, walkFrames };