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
@@ -14,6 +14,12 @@ var DetectionBufferStatus;
14
14
  (function (DetectionBufferStatus) {
15
15
  DetectionBufferStatus["Idle"] = "idle";
16
16
  DetectionBufferStatus["Loading"] = "loading";
17
+ /**
18
+ * The playback gate is holding playback until the source produces detections
19
+ * for the requested range. {@link DetectionBufferStatus.Loading} fetches
20
+ * detections the source already has; this one waits on a producer.
21
+ */
22
+ DetectionBufferStatus["AwaitingCoverage"] = "awaitingCoverage";
17
23
  DetectionBufferStatus["Ready"] = "ready";
18
24
  DetectionBufferStatus["Error"] = "error";
19
25
  DetectionBufferStatus["Destroyed"] = "destroyed";
@@ -26,12 +32,30 @@ var DetectionFrameSelectionMode;
26
32
  /**
27
33
  * Select the frame whose `[mediaTime, endTime)` interval contains the media
28
34
  * time. This is the default for interval annotations and timestamped sources.
35
+ *
36
+ * Frame times must come from the media itself. Times reconstructed from a
37
+ * nominal frame rate drift against a clip whose real rate differs, and once
38
+ * that drift passes the half-millisecond selection tolerance a playhead
39
+ * landing on a frame boundary selects the previous frame's detections.
40
+ *
41
+ * A frame that carries no `endTime` stays active until the next frame starts,
42
+ * which bridges an index the source never produced.
43
+ * {@link DetectionFrameSelectionMode.NearestFrameIndex} bounds each frame to
44
+ * one grid step instead, so a missing index reads as no detections.
29
45
  */
30
46
  DetectionFrameSelectionMode["Interval"] = "interval";
31
47
  /**
32
- * Select from a known inference frame grid using `frameIndex`, `frameRate`,
33
- * and optional `frameIndexOriginTime`. This is useful when inference was run
34
- * on normalized frames and playback should snap detections to that grid.
48
+ * Select from a known inference frame grid using `frameIndex` and
49
+ * `frameRate`. This is useful when inference was run on normalized frames and
50
+ * playback should snap detections to that grid.
51
+ *
52
+ * A frame speaks for one grid step starting at its own media time, and an
53
+ * index the source never produced selects nothing rather than a neighbour.
54
+ *
55
+ * A frame carrying no `frameIndex` speaks for its step on the same terms,
56
+ * reached by the time it starts at rather than by an index that names it. Its
57
+ * `endTime` does not widen it past that step, so a source that labels only
58
+ * part of what it writes cannot bridge the indexes it never produced.
35
59
  */
36
60
  DetectionFrameSelectionMode["NearestFrameIndex"] = "nearestFrameIndex";
37
61
  })(DetectionFrameSelectionMode || (DetectionFrameSelectionMode = {}));
@@ -54,15 +78,32 @@ var DetectionFrameRetentionMode;
54
78
  DetectionFrameRetentionMode["PersistWindow"] = "persistWindow";
55
79
  })(DetectionFrameRetentionMode || (DetectionFrameRetentionMode = {}));
56
80
 
81
+ /**
82
+ * A playhead compared by time can arrive perturbed by whatever plane it crossed
83
+ * to get here: a producer that publishes whole milliseconds reports a source
84
+ * frame at 5.033333s as 5.033s, and even an exact producer loses a bit to a
85
+ * seconds-to-milliseconds-and-back trip. A frame starting within that reach may
86
+ * already be on screen while one starting past it cannot.
87
+ *
88
+ * Selection by identity carries none of this, which is why it lives in its own
89
+ * function and not in a smaller number here.
90
+ */
91
+ const PLAYHEAD_QUANTIZATION_TOLERANCE_SECONDS = 0.0005;
57
92
  function copySortedDetectionFrames(detectionFrames) {
58
93
  validateDetectionFrames(detectionFrames ?? []);
59
94
  return (detectionFrames ?? [])
60
- .map((frame) => ({
95
+ .map(copyDetectionFrame)
96
+ .sort((left, right) => left.mediaTime - right.mediaTime);
97
+ }
98
+ /**
99
+ * One frame's deep copy, for a caller that already knows which frames it is
100
+ * keeping and only needs the ones it does not hold.
101
+ */
102
+ function copyDetectionFrame(frame) {
103
+ return {
61
104
  detections: frame.detections.map((detection) => ({
62
105
  ...detection,
63
- attributes: detection.attributes
64
- ? [...detection.attributes]
65
- : undefined,
106
+ attributes: detection.attributes ? [...detection.attributes] : undefined,
66
107
  keypoints: detection.keypoints
67
108
  ? {
68
109
  boxRelative: detection.keypoints.boxRelative
@@ -97,16 +138,56 @@ function copySortedDetectionFrames(detectionFrames) {
97
138
  endTime: frame.endTime,
98
139
  frameIndex: frame.frameIndex,
99
140
  mediaTime: frame.mediaTime,
100
- }))
101
- .sort((left, right) => left.mediaTime - right.mediaTime);
141
+ };
102
142
  }
143
+ /** Returned by the plain copier when it meets a value only structuredClone can
144
+ * carry, so the caller pays for that only when the metadata needs it. */
145
+ const NOT_PLAIN = Symbol("not-plain-metadata");
103
146
  function copyDetectionMetadata(metadata) {
147
+ // Detection metadata is almost always a handful of strings and numbers, and a
148
+ // window rebuild copies it once per detection, tens of thousands of times a
149
+ // second while scrubbing. structuredClone serialises; walking the object does
150
+ // not, so it is the fallback rather than the default.
151
+ const plain = copyPlainMetadata(metadata);
152
+ if (plain !== NOT_PLAIN) {
153
+ return plain;
154
+ }
104
155
  const clone = globalThis.structuredClone;
105
156
  if (clone) {
106
157
  return clone(metadata);
107
158
  }
108
159
  return copyMetadataValue(metadata);
109
160
  }
161
+ function copyPlainMetadata(value) {
162
+ if (value === null || typeof value !== "object") {
163
+ return typeof value === "function" || typeof value === "symbol"
164
+ ? NOT_PLAIN
165
+ : value;
166
+ }
167
+ if (Array.isArray(value)) {
168
+ const copied = [];
169
+ for (const entry of value) {
170
+ const child = copyPlainMetadata(entry);
171
+ if (child === NOT_PLAIN) {
172
+ return NOT_PLAIN;
173
+ }
174
+ copied.push(child);
175
+ }
176
+ return copied;
177
+ }
178
+ if (Object.getPrototypeOf(value) !== Object.prototype) {
179
+ return NOT_PLAIN;
180
+ }
181
+ const copied = {};
182
+ for (const [key, entry] of Object.entries(value)) {
183
+ const child = copyPlainMetadata(entry);
184
+ if (child === NOT_PLAIN) {
185
+ return NOT_PLAIN;
186
+ }
187
+ copied[key] = child;
188
+ }
189
+ return copied;
190
+ }
110
191
  function copyMetadataValue(value) {
111
192
  if (Array.isArray(value)) {
112
193
  return value.map(copyMetadataValue);
@@ -120,95 +201,85 @@ function copyMetadataValue(value) {
120
201
  return value;
121
202
  }
122
203
  function validateDetectionFrames(detectionFrames) {
123
- for (const [frameOffset, frame] of detectionFrames.entries()) {
124
- validateNumber(frame.mediaTime, `frames[${frameOffset}].mediaTime`, {
204
+ for (let frameOffset = 0; frameOffset < detectionFrames.length; frameOffset += 1) {
205
+ const frame = detectionFrames[frameOffset];
206
+ const framePath = () => `frames[${frameOffset}]`;
207
+ validateNumber(frame.mediaTime, () => `${framePath()}.mediaTime`, {
125
208
  min: 0,
126
209
  });
127
210
  if (frame.endTime !== undefined) {
128
- validateNumber(frame.endTime, `frames[${frameOffset}].endTime`, {
211
+ validateNumber(frame.endTime, () => `${framePath()}.endTime`, {
129
212
  exclusiveMin: frame.mediaTime,
130
213
  });
131
214
  }
132
215
  if (frame.frameIndex !== undefined) {
133
- validateNumber(frame.frameIndex, `frames[${frameOffset}].frameIndex`, {
216
+ validateNumber(frame.frameIndex, () => `${framePath()}.frameIndex`, {
134
217
  integer: true,
135
218
  min: 0,
136
219
  });
137
220
  }
138
221
  if (frame.coordinateSpace !== undefined) {
139
- validateNumber(frame.coordinateSpace.width, `frames[${frameOffset}].coordinateSpace.width`, { exclusiveMin: 0 });
140
- validateNumber(frame.coordinateSpace.height, `frames[${frameOffset}].coordinateSpace.height`, { exclusiveMin: 0 });
222
+ validateNumber(frame.coordinateSpace.width, () => `${framePath()}.coordinateSpace.width`, { exclusiveMin: 0 });
223
+ validateNumber(frame.coordinateSpace.height, () => `${framePath()}.coordinateSpace.height`, { exclusiveMin: 0 });
141
224
  }
142
- for (const [detectionOffset, detection] of frame.detections.entries()) {
143
- const detectionPath = `frames[${frameOffset}].detections[${detectionOffset}]`;
225
+ for (let detectionOffset = 0; detectionOffset < frame.detections.length; detectionOffset += 1) {
226
+ const detection = frame.detections[detectionOffset];
227
+ const detectionPath = () => `${framePath()}.detections[${detectionOffset}]`;
144
228
  if (detection.confidence !== undefined) {
145
- validateNumber(detection.confidence, `${detectionPath}.confidence`, {
146
- max: 1,
147
- min: 0,
148
- });
229
+ validateNumber(detection.confidence, () => `${detectionPath()}.confidence`, { max: 1, min: 0 });
149
230
  }
150
231
  if (detection.zIndex !== undefined) {
151
- validateNumber(detection.zIndex, `${detectionPath}.zIndex`);
232
+ validateNumber(detection.zIndex, () => `${detectionPath()}.zIndex`);
152
233
  }
153
234
  if (detection.trackerId !== undefined) {
154
- validateNumber(detection.trackerId, `${detectionPath}.trackerId`, {
155
- integer: true,
156
- min: 0,
157
- });
235
+ validateNumber(detection.trackerId, () => `${detectionPath()}.trackerId`, { integer: true, min: 0 });
158
236
  }
159
237
  if (detection.sourceId !== undefined &&
160
238
  typeof detection.sourceId !== "string") {
161
- throw new Error(`${detectionPath}.sourceId must be a string.`);
239
+ throw new Error(`${detectionPath()}.sourceId must be a string.`);
162
240
  }
163
241
  if (detection.sourceDetectionIndex !== undefined) {
164
- validateNumber(detection.sourceDetectionIndex, `${detectionPath}.sourceDetectionIndex`, {
242
+ validateNumber(detection.sourceDetectionIndex, () => `${detectionPath()}.sourceDetectionIndex`, {
165
243
  integer: true,
166
244
  min: 0,
167
245
  });
168
246
  }
169
247
  if (detection.rect) {
170
- validateNumber(detection.rect.x, `${detectionPath}.rect.x`);
171
- validateNumber(detection.rect.y, `${detectionPath}.rect.y`);
172
- validateNumber(detection.rect.width, `${detectionPath}.rect.width`, {
173
- exclusiveMin: 0,
174
- });
175
- validateNumber(detection.rect.height, `${detectionPath}.rect.height`, {
176
- exclusiveMin: 0,
177
- });
248
+ validateNumber(detection.rect.x, () => `${detectionPath()}.rect.x`);
249
+ validateNumber(detection.rect.y, () => `${detectionPath()}.rect.y`);
250
+ validateNumber(detection.rect.width, () => `${detectionPath()}.rect.width`, { exclusiveMin: 0 });
251
+ validateNumber(detection.rect.height, () => `${detectionPath()}.rect.height`, { exclusiveMin: 0 });
178
252
  }
179
- validatePoints(detection.polygon?.points, `${detectionPath}.polygon`, 3);
180
- validatePoints(detection.polyline?.points, `${detectionPath}.polyline`, 2);
253
+ validatePoints(detection.polygon?.points, () => `${detectionPath()}.polygon`, 3);
254
+ validatePoints(detection.polyline?.points, () => `${detectionPath()}.polyline`, 2);
181
255
  if (detection.keypoints) {
182
- validatePoints(detection.keypoints.points, `${detectionPath}.keypoints`, 1);
256
+ validatePoints(detection.keypoints.points, () => `${detectionPath()}.keypoints`, 1);
183
257
  if (detection.keypoints.visibility !== undefined &&
184
258
  detection.keypoints.visibility.length !==
185
259
  detection.keypoints.points.length) {
186
- throw new Error(`${detectionPath}.keypoints.visibility must match points length.`);
260
+ throw new Error(`${detectionPath()}.keypoints.visibility must match points length.`);
187
261
  }
188
- for (const [edgeOffset, edge] of detection.keypoints.edges.entries()) {
189
- const edgePath = `${detectionPath}.keypoints.edges[${edgeOffset}]`;
190
- for (const [endpointOffset, endpoint] of edge.entries()) {
191
- validateNumber(endpoint, `${edgePath}[${endpointOffset}]`, {
262
+ const edges = detection.keypoints.edges;
263
+ for (let edgeOffset = 0; edgeOffset < edges.length; edgeOffset += 1) {
264
+ const edge = edges[edgeOffset];
265
+ const edgePath = () => `${detectionPath()}.keypoints.edges[${edgeOffset}]`;
266
+ for (let endpointOffset = 0; endpointOffset < edge.length; endpointOffset += 1) {
267
+ const endpoint = edge[endpointOffset];
268
+ validateNumber(endpoint, () => `${edgePath()}[${endpointOffset}]`, {
192
269
  integer: true,
193
270
  min: 0,
194
271
  });
195
272
  if (endpoint >= detection.keypoints.points.length) {
196
- throw new Error(`${edgePath}[${endpointOffset}] is out of range.`);
273
+ throw new Error(`${edgePath()}[${endpointOffset}] is out of range.`);
197
274
  }
198
275
  }
199
276
  }
200
277
  }
201
278
  if (detection.mask) {
202
- validateNumber(detection.mask.width, `${detectionPath}.mask.width`, {
203
- integer: true,
204
- exclusiveMin: 0,
205
- });
206
- validateNumber(detection.mask.height, `${detectionPath}.mask.height`, {
207
- integer: true,
208
- exclusiveMin: 0,
209
- });
279
+ validateNumber(detection.mask.width, () => `${detectionPath()}.mask.width`, { integer: true, exclusiveMin: 0 });
280
+ validateNumber(detection.mask.height, () => `${detectionPath()}.mask.height`, { integer: true, exclusiveMin: 0 });
210
281
  if (detection.mask.counts.length === 0) {
211
- throw new Error(`${detectionPath}.mask.counts must not be empty.`);
282
+ throw new Error(`${detectionPath()}.mask.counts must not be empty.`);
212
283
  }
213
284
  }
214
285
  }
@@ -219,28 +290,30 @@ function validatePoints(points, path, minimumLength) {
219
290
  return;
220
291
  }
221
292
  if (points.length < minimumLength) {
222
- throw new Error(`${path}.points must contain at least ${minimumLength} points.`);
293
+ throw new Error(`${path()}.points must contain at least ${minimumLength} points.`);
223
294
  }
224
- for (const [pointOffset, point] of points.entries()) {
225
- validateNumber(point.x, `${path}.points[${pointOffset}].x`);
226
- validateNumber(point.y, `${path}.points[${pointOffset}].y`);
295
+ for (let pointOffset = 0; pointOffset < points.length; pointOffset += 1) {
296
+ const point = points[pointOffset];
297
+ validateNumber(point.x, () => `${path()}.points[${pointOffset}].x`);
298
+ validateNumber(point.y, () => `${path()}.points[${pointOffset}].y`);
227
299
  }
228
300
  }
229
- function validateNumber(value, path, options = {}) {
301
+ const NO_BOUNDS = {};
302
+ function validateNumber(value, path, options = NO_BOUNDS) {
230
303
  if (!Number.isFinite(value)) {
231
- throw new Error(`${path} must be a finite number.`);
304
+ throw new Error(`${path()} must be a finite number.`);
232
305
  }
233
306
  if (options.integer && !Number.isInteger(value)) {
234
- throw new Error(`${path} must be an integer.`);
307
+ throw new Error(`${path()} must be an integer.`);
235
308
  }
236
309
  if (options.min !== undefined && value < options.min) {
237
- throw new Error(`${path} must be greater than or equal to ${options.min}.`);
310
+ throw new Error(`${path()} must be greater than or equal to ${options.min}.`);
238
311
  }
239
312
  if (options.exclusiveMin !== undefined && value <= options.exclusiveMin) {
240
- throw new Error(`${path} must be greater than ${options.exclusiveMin}.`);
313
+ throw new Error(`${path()} must be greater than ${options.exclusiveMin}.`);
241
314
  }
242
315
  if (options.max !== undefined && value > options.max) {
243
- throw new Error(`${path} must be less than or equal to ${options.max}.`);
316
+ throw new Error(`${path()} must be less than or equal to ${options.max}.`);
244
317
  }
245
318
  }
246
319
  function filterDetectionFramesForRange(detectionFrames, startTime, endTime) {
@@ -248,7 +321,7 @@ function filterDetectionFramesForRange(detectionFrames, startTime, endTime) {
248
321
  }
249
322
  function selectDetectionFrame(detectionFrames, mediaTime, options = {}) {
250
323
  if (options.selectionMode === DetectionFrameSelectionMode.NearestFrameIndex) {
251
- const selection = selectNearestFrameIndexDetectionFrame(detectionFrames, mediaTime, options.frameRate, options.frameIndexOriginTime);
324
+ const selection = selectNearestFrameIndexDetectionFrame(detectionFrames, mediaTime, options.frameRate);
252
325
  if (selection.isApplicable) {
253
326
  return selection.frame;
254
327
  }
@@ -285,13 +358,77 @@ function decodeCompressedRleMask(mask) {
285
358
  };
286
359
  }
287
360
  function selectIntervalDetectionFrame(detectionFrames, mediaTime) {
361
+ const selectedFrame = findLastStartedDetectionFrame(detectionFrames, mediaTime);
362
+ return selectedFrame && isDetectionFrameActive(selectedFrame, mediaTime)
363
+ ? selectedFrame
364
+ : undefined;
365
+ }
366
+ /**
367
+ * The grid frame a playhead is standing on, or nothing.
368
+ *
369
+ * A frame at index `n` speaks for one step of the inference grid and no more,
370
+ * so it is on screen from its own media time until the next index is due. An
371
+ * index the source never produced leaves that step blank: the frames on either
372
+ * side describe media the playhead is not on, and a source still being written
373
+ * has a playback gate to hold the picture rather than a neighbour to borrow.
374
+ *
375
+ * A grid the source labelled only in part is still that source describing this
376
+ * media, so the frame the playhead stands on is the last one to have started,
377
+ * whether or not an index names it. The step it is held to is measured from the
378
+ * labelled frames alone.
379
+ */
380
+ function selectNearestFrameIndexDetectionFrame(detectionFrames, mediaTime, frameRate) {
381
+ if (!frameRate || !Number.isFinite(frameRate) || frameRate <= 0) {
382
+ return { frame: undefined, isApplicable: false };
383
+ }
384
+ const firstIndexedFrame = findIndexedDetectionFrame(detectionFrames, 1);
385
+ const lastIndexedFrame = findIndexedDetectionFrame(detectionFrames, -1);
386
+ if (!firstIndexedFrame || !lastIndexedFrame) {
387
+ return { frame: undefined, isApplicable: false };
388
+ }
389
+ const gridStep = measureGridStep$1(firstIndexedFrame, lastIndexedFrame) ?? 1 / frameRate;
390
+ const startedFrame = findLastStartedDetectionFrame(detectionFrames, mediaTime);
391
+ return {
392
+ frame: startedFrame &&
393
+ mediaTime + PLAYHEAD_QUANTIZATION_TOLERANCE_SECONDS <
394
+ startedFrame.mediaTime + gridStep
395
+ ? startedFrame
396
+ : undefined,
397
+ isApplicable: true,
398
+ };
399
+ }
400
+ /**
401
+ * A grid step read off the frames instead of taken from `frameRate`.
402
+ *
403
+ * `frameRate` is what a manifest or an upload target says the grid is, and a
404
+ * clip whose real rate differs walks a step further out of position for every
405
+ * second that plays. The buffered frames carry the times the media itself
406
+ * reported, and measuring across the widest index span present divides the
407
+ * producer's millisecond rounding by that span.
408
+ */
409
+ function measureGridStep$1(firstIndexedFrame, lastIndexedFrame) {
410
+ const indexSpan = lastIndexedFrame.frameIndex - firstIndexedFrame.frameIndex;
411
+ const step = (lastIndexedFrame.mediaTime - firstIndexedFrame.mediaTime) / indexSpan;
412
+ return indexSpan > 0 && Number.isFinite(step) && step > 0 ? step : undefined;
413
+ }
414
+ function findIndexedDetectionFrame(detectionFrames, direction) {
415
+ const startOffset = direction === 1 ? 0 : detectionFrames.length - 1;
416
+ for (let offset = startOffset; offset >= 0 && offset < detectionFrames.length; offset += direction) {
417
+ const frame = detectionFrames[offset];
418
+ if (isIndexedDetectionFrame(frame)) {
419
+ return frame;
420
+ }
421
+ }
422
+ return undefined;
423
+ }
424
+ function findLastStartedDetectionFrame(detectionFrames, mediaTime) {
288
425
  let selectedFrame;
289
426
  let low = 0;
290
427
  let high = detectionFrames.length - 1;
291
428
  while (low <= high) {
292
429
  const middle = Math.floor((low + high) / 2);
293
430
  const frame = detectionFrames[middle];
294
- if (frame.mediaTime <= mediaTime) {
431
+ if (hasDetectionFrameStarted(frame, mediaTime)) {
295
432
  selectedFrame = frame;
296
433
  low = middle + 1;
297
434
  }
@@ -299,44 +436,16 @@ function selectIntervalDetectionFrame(detectionFrames, mediaTime) {
299
436
  high = middle - 1;
300
437
  }
301
438
  }
302
- return selectedFrame && isDetectionFrameActive(selectedFrame, mediaTime)
303
- ? selectedFrame
304
- : undefined;
439
+ return selectedFrame;
305
440
  }
306
- function selectNearestFrameIndexDetectionFrame(detectionFrames, mediaTime, frameRate, frameIndexOriginTime) {
307
- if (!frameRate || !Number.isFinite(frameRate) || frameRate <= 0) {
308
- return { frame: undefined, isApplicable: false };
309
- }
310
- const firstIndexedFrame = detectionFrames.find((frame) => frame.frameIndex !== undefined);
311
- if (!firstIndexedFrame || firstIndexedFrame.frameIndex === undefined) {
312
- return { frame: undefined, isApplicable: false };
313
- }
314
- const originTime = frameIndexOriginTime !== undefined
315
- ? frameIndexOriginTime
316
- : firstIndexedFrame.mediaTime - firstIndexedFrame.frameIndex / frameRate;
317
- const targetFrameIndex = Math.round((mediaTime - originTime) * frameRate);
318
- let nearestFrame;
319
- let nearestDistance = Number.POSITIVE_INFINITY;
320
- for (const frame of detectionFrames) {
321
- if (frame.frameIndex === undefined) {
322
- continue;
323
- }
324
- const distance = Math.abs(frame.frameIndex - targetFrameIndex);
325
- if (distance < nearestDistance ||
326
- (distance === nearestDistance &&
327
- nearestFrame?.frameIndex !== undefined &&
328
- frame.frameIndex > nearestFrame.frameIndex)) {
329
- nearestFrame = frame;
330
- nearestDistance = distance;
331
- }
332
- }
333
- return {
334
- frame: nearestDistance <= 1 ? nearestFrame : undefined,
335
- isApplicable: true,
336
- };
441
+ function isIndexedDetectionFrame(frame) {
442
+ return frame.frameIndex !== undefined;
443
+ }
444
+ function hasDetectionFrameStarted(frame, mediaTime) {
445
+ return frame.mediaTime - PLAYHEAD_QUANTIZATION_TOLERANCE_SECONDS <= mediaTime;
337
446
  }
338
447
  function isDetectionFrameActive(frame, mediaTime) {
339
- return (frame.mediaTime <= mediaTime &&
448
+ return (hasDetectionFrameStarted(frame, mediaTime) &&
340
449
  (frame.endTime === undefined || mediaTime < frame.endTime));
341
450
  }
342
451
  function detectionFrameOverlapsRange(frame, startTime, endTime) {
@@ -398,8 +507,84 @@ function createArrayDetectionFrameSource(frames) {
398
507
  };
399
508
  }
400
509
 
510
+ /**
511
+ * Slack allowed when comparing two range ends.
512
+ *
513
+ * Range bounds are computed from frame times and durations, so an end that
514
+ * should meet another exactly can miss it by a float's last bits.
515
+ */
516
+ const RANGE_EPSILON_SECONDS = 1e-6;
517
+ /**
518
+ * Whether the available ranges together span the whole of `range`.
519
+ *
520
+ * Detections arrive a batch at a time, and a source that has not coalesced its
521
+ * ranges holds several that abut. Asking each range on its own whether it spans
522
+ * the window reports a window that is entirely covered as uncovered, so the
523
+ * walk below spends ranges in order and asks how far their union reaches.
524
+ */
525
+ function isRangeCovered(range, availableRanges) {
526
+ let reach = null;
527
+ for (const availableRange of inStartOrder(availableRanges)) {
528
+ if (reach === null) {
529
+ if (availableRange.startTime > range.startTime + RANGE_EPSILON_SECONDS) {
530
+ return false;
531
+ }
532
+ if (availableRange.endTime + RANGE_EPSILON_SECONDS < range.startTime) {
533
+ continue;
534
+ }
535
+ reach = availableRange.endTime;
536
+ }
537
+ else {
538
+ if (availableRange.startTime > reach + RANGE_EPSILON_SECONDS) {
539
+ return false;
540
+ }
541
+ reach = Math.max(reach, availableRange.endTime);
542
+ }
543
+ if (reach + RANGE_EPSILON_SECONDS >= range.endTime) {
544
+ return true;
545
+ }
546
+ }
547
+ return false;
548
+ }
549
+ function inStartOrder(ranges) {
550
+ for (let index = 1; index < ranges.length; index += 1) {
551
+ if (ranges[index].startTime < ranges[index - 1].startTime) {
552
+ return [...ranges].sort((left, right) => left.startTime - right.startTime);
553
+ }
554
+ }
555
+ return ranges;
556
+ }
557
+
558
+ function startWaitBound(timeoutMs) {
559
+ let handle;
560
+ const expired = new Promise((resolve) => {
561
+ handle = setTimeout(() => resolve(false), timeoutMs);
562
+ });
563
+ return { cancel: () => clearTimeout(handle), expired };
564
+ }
565
+
401
566
  const DEFAULT_BUFFER_AHEAD_SECONDS = 5;
402
567
  const DEFAULT_BUFFER_BEHIND_SECONDS = 0.5;
568
+ /**
569
+ * Share of the ahead window that must still lead the playhead when the next
570
+ * window is fetched.
571
+ *
572
+ * A window fetched only once the playhead reaches its end leaves the playhead
573
+ * uncovered for exactly as long as the fetch takes, every time, so annotations
574
+ * blink out once per window at a steady rate. Fetching while the current window
575
+ * still answers means the load lands behind a picture that never lost them.
576
+ */
577
+ const REFILL_LEAD_FRACTION = 0.5;
578
+ const MIN_REFILL_LEAD_SECONDS = 1;
579
+ /**
580
+ * How long a playback gate holds before it gives up on coverage.
581
+ *
582
+ * The gate waits on a producer, and a producer that has failed, stalled, or
583
+ * fallen far behind is indistinguishable from one that is about to answer. Past
584
+ * this a frozen picture is the worse of the two outcomes, so the frame is
585
+ * presented with whatever detections exist.
586
+ */
587
+ const DEFAULT_PLAYBACK_GATE_MAX_WAIT_SECONDS = 10;
403
588
  const bufferedFrameSnapshots = new WeakMap();
404
589
  /**
405
590
  * Returns the timeline-owned hot-buffer snapshot without copying it.
@@ -417,6 +602,9 @@ function createBufferedDetectionTimeline(options) {
417
602
  ? null
418
603
  : Math.max(0, options.refreshIntervalSeconds);
419
604
  const playbackGate = options.playbackGate;
605
+ const refillLeadSeconds = bufferAheadSeconds <= 0
606
+ ? 0
607
+ : Math.min(bufferAheadSeconds, Math.max(MIN_REFILL_LEAD_SECONDS, bufferAheadSeconds * REFILL_LEAD_FRACTION));
420
608
  let buffer = [];
421
609
  let state = createIdleDetectionBufferState();
422
610
  let destroyed = false;
@@ -429,30 +617,53 @@ function createBufferedDetectionTimeline(options) {
429
617
  };
430
618
  let inFlight;
431
619
  let incrementalRefresh;
620
+ /** Source version the gate last gave up on, or null while it still waits. */
621
+ let abandonedGateSourceVersion = null;
432
622
  let pendingPrefetch;
433
623
  let prefetchPump;
624
+ const listeners = new Set();
625
+ const notifyBufferChanged = () => {
626
+ for (const listener of listeners) {
627
+ listener();
628
+ }
629
+ };
434
630
  const getSourceVersion = (ranges) => {
435
631
  if (!ranges) {
436
632
  return options.source.getVersion?.() ?? 0;
437
633
  }
438
634
  return ranges.reduce((version, range) => Math.max(version, options.source.getVersion?.(range) ?? 0), 0);
439
635
  };
636
+ const isLoadingEnabled = () => typeof options.enabled === "function"
637
+ ? options.enabled()
638
+ : options.enabled !== false;
440
639
  const isBufferFresh = () => bufferedVersionRange !== null &&
441
640
  bufferedSourceVersion === getSourceVersion(getBufferedSourceRanges());
442
- const getLoadRange = (mediaTime) => {
641
+ const getLoadRange = (mediaTime, lead = "full") => {
443
642
  const comparableMediaTime = getComparableMediaTime(mediaTime);
444
643
  const startTime = comparableMediaTime - bufferBehindSeconds;
445
- const endTime = comparableMediaTime + bufferAheadSeconds;
644
+ const endTime = comparableMediaTime + (lead === "full" ? bufferAheadSeconds : 0);
446
645
  return createLoadPlan(startTime, endTime);
447
646
  };
448
- const loadWindow = (mediaTime) => {
449
- const { endTime, sourceRanges, startTime } = getLoadRange(mediaTime);
647
+ const loadWindow = (mediaTime, lead = "full") => {
648
+ const comparableMediaTime = getComparableMediaTime(mediaTime);
649
+ const { endTime, sourceRanges, startTime } = getLoadRange(mediaTime, lead);
450
650
  const versionRange = { endTime, startTime };
451
651
  const sourceVersion = getSourceVersion(sourceRanges);
452
- if (inFlight &&
453
- inFlight.sourceVersion === sourceVersion &&
454
- rangeContains(inFlight.startTime, inFlight.endTime, startTime, endTime)) {
455
- return inFlight.promise;
652
+ if (inFlight && inFlight.sourceVersion === sourceVersion) {
653
+ if (rangeContains(inFlight.startTime, inFlight.endTime, startTime, endTime)) {
654
+ return inFlight.promise;
655
+ }
656
+ // A load already in flight that still covers where the playhead is going
657
+ // answers the same question a fresh one would. Playback moves the anchor
658
+ // every frame, so a window superseded on anchor equality alone is
659
+ // superseded on every frame of its own flight: the fetch is thrown away
660
+ // and the wait restarts, which is how a gap grows instead of closing.
661
+ if (refillLeadSeconds > 0 &&
662
+ comparableMediaTime >= inFlight.mediaTime &&
663
+ comparableMediaTime + refillLeadSeconds <=
664
+ inFlight.mediaTime + bufferAheadSeconds) {
665
+ return inFlight.promise;
666
+ }
456
667
  }
457
668
  const currentLoadId = loadId + 1;
458
669
  loadId = currentLoadId;
@@ -469,12 +680,12 @@ function createBufferedDetectionTimeline(options) {
469
680
  return;
470
681
  }
471
682
  const committedSourceVersion = getSourceVersion(sourceRanges);
472
- const loadedBuffer = copySortedDetectionFrames(frameRanges.flat());
683
+ const loadedFrames = frameRanges.flat();
473
684
  buffer =
474
685
  bufferedSourceVersion !== null &&
475
686
  bufferedSourceVersion === committedSourceVersion
476
- ? reuseBufferedFrameSnapshots(buffer, loadedBuffer)
477
- : loadedBuffer;
687
+ ? reuseBufferedFrameSnapshots(buffer, loadedFrames)
688
+ : copySortedDetectionFrames(loadedFrames);
478
689
  bufferedVersionRange = versionRange;
479
690
  bufferedSourceVersion = committedSourceVersion;
480
691
  state = {
@@ -487,6 +698,7 @@ function createBufferedDetectionTimeline(options) {
487
698
  requestedStartTime: startTime,
488
699
  status: DetectionBufferStatus.Ready,
489
700
  };
701
+ notifyBufferChanged();
490
702
  })
491
703
  .catch((error) => {
492
704
  if (!destroyed && currentLoadId === loadId) {
@@ -503,15 +715,56 @@ function createBufferedDetectionTimeline(options) {
503
715
  inFlight = undefined;
504
716
  }
505
717
  });
718
+ if (lead === "covering") {
719
+ // Chained past the clearing of `inFlight`, which would otherwise be
720
+ // handed back to the widening load as a window that already answers.
721
+ void promise
722
+ .then(() => {
723
+ if (!destroyed && currentLoadId === loadId) {
724
+ void loadWindow(mediaTime).catch(() => undefined);
725
+ }
726
+ })
727
+ .catch(() => undefined);
728
+ }
506
729
  inFlight = {
507
730
  endTime,
508
731
  id: currentLoadId,
732
+ mediaTime: comparableMediaTime,
509
733
  promise,
510
734
  sourceVersion,
511
735
  startTime,
512
736
  };
513
737
  return promise;
514
738
  };
739
+ /**
740
+ * Restates the window in the lap the playhead is on.
741
+ *
742
+ * A window planned near the end of a looping source runs past the loop
743
+ * point, so once playback wraps, a host comparing the window against the
744
+ * media clock reads a range that starts after the time it is holding. The
745
+ * offset removed here is a whole number of laps, which is what
746
+ * `getComparableMediaTime` already treats as the same position, so
747
+ * membership and the frames on screen are untouched.
748
+ */
749
+ const anchorWindowToPlayhead = (mediaTime) => {
750
+ const duration = timelineContext.duration;
751
+ if (!isLoopingTimeline() ||
752
+ duration === null ||
753
+ state.bufferStartTime === null ||
754
+ state.bufferEndTime === null) {
755
+ return;
756
+ }
757
+ const laps = Math.round((getComparableMediaTime(mediaTime) - mediaTime) / duration);
758
+ if (laps === 0) {
759
+ return;
760
+ }
761
+ state = {
762
+ ...state,
763
+ bufferEndTime: state.bufferEndTime - laps * duration,
764
+ bufferStartTime: state.bufferStartTime - laps * duration,
765
+ };
766
+ notifyBufferChanged();
767
+ };
515
768
  const isBuffered = (mediaTime) => {
516
769
  const comparableMediaTime = getComparableMediaTime(mediaTime);
517
770
  return (isBufferFresh() &&
@@ -528,7 +781,7 @@ function createBufferedDetectionTimeline(options) {
528
781
  comparableMediaTime >= state.bufferStartTime &&
529
782
  comparableMediaTime <= state.bufferEndTime);
530
783
  };
531
- const refreshBuffer = async (mediaTime) => {
784
+ const refreshBuffer = async (mediaTime, lead = "full") => {
532
785
  if (isBuffered(mediaTime)) {
533
786
  return;
534
787
  }
@@ -544,11 +797,18 @@ function createBufferedDetectionTimeline(options) {
544
797
  if (isBuffered(mediaTime)) {
545
798
  return;
546
799
  }
547
- return refreshBuffer(mediaTime);
800
+ return refreshBuffer(mediaTime, lead);
548
801
  }
549
- await loadWindow(mediaTime);
802
+ await loadWindow(mediaTime, lead);
550
803
  };
804
+ /**
805
+ * Whether the next window is worth fetching while the current one still
806
+ * answers, so the load lands behind annotations that never went away.
807
+ */
551
808
  const shouldPrefetch = (mediaTime) => {
809
+ if (!isLoadingEnabled()) {
810
+ return false;
811
+ }
552
812
  if (!isBuffered(mediaTime)) {
553
813
  return true;
554
814
  }
@@ -558,12 +818,19 @@ function createBufferedDetectionTimeline(options) {
558
818
  if (state.bufferEndTime === null || bufferAheadSeconds <= 0) {
559
819
  return false;
560
820
  }
561
- return (getComparableMediaTime(mediaTime) + bufferAheadSeconds / 2 >=
562
- state.bufferEndTime);
821
+ if (getComparableMediaTime(mediaTime) + bufferAheadSeconds / 2 <
822
+ state.bufferEndTime) {
823
+ return false;
824
+ }
825
+ const { endTime, startTime } = getLoadRange(mediaTime);
826
+ // A window already spanning everything the source can offer has nowhere to
827
+ // advance to, so its lead only shrinks from here. Refetching it would
828
+ // repeat for every remaining frame of playback and buy no coverage.
829
+ return (startTime !== state.bufferStartTime || endTime !== state.bufferEndTime);
563
830
  };
564
831
  const timeline = {
565
832
  async prepare(mediaTime, prepareOptions) {
566
- if (destroyed) {
833
+ if (destroyed || !isLoadingEnabled()) {
567
834
  return;
568
835
  }
569
836
  if (shouldWaitForPlaybackGate(prepareOptions)) {
@@ -573,9 +840,26 @@ function createBufferedDetectionTimeline(options) {
573
840
  }
574
841
  }
575
842
  await refreshBuffer(mediaTime);
843
+ anchorWindowToPlayhead(mediaTime);
844
+ },
845
+ needsPlaybackGateWait(mediaTime, prepareOptions) {
846
+ if (destroyed ||
847
+ !isLoadingEnabled() ||
848
+ !playbackGate?.enabled ||
849
+ !options.source.waitForRange ||
850
+ !options.source.getAvailableRanges ||
851
+ hasAbandonedGate()) {
852
+ return false;
853
+ }
854
+ const availableRanges = options.source.getAvailableRanges();
855
+ return !createPlaybackGateCoveragePlan(mediaTime, prepareOptions).sourceRanges.every((range) => isRangeCovered(range, availableRanges));
576
856
  },
577
857
  prefetch(mediaTime) {
578
- if (destroyed || !shouldPrefetch(mediaTime)) {
858
+ if (destroyed || !isLoadingEnabled()) {
859
+ return;
860
+ }
861
+ anchorWindowToPlayhead(mediaTime);
862
+ if (!shouldPrefetch(mediaTime)) {
579
863
  return;
580
864
  }
581
865
  pendingPrefetch = { loadId, mediaTime };
@@ -598,11 +882,18 @@ function createBufferedDetectionTimeline(options) {
598
882
  getState() {
599
883
  return { ...state };
600
884
  },
885
+ subscribe(listener) {
886
+ listeners.add(listener);
887
+ return () => {
888
+ listeners.delete(listener);
889
+ };
890
+ },
601
891
  destroy() {
602
892
  if (destroyed) {
603
893
  return;
604
894
  }
605
895
  destroyed = true;
896
+ listeners.clear();
606
897
  pendingPrefetch = undefined;
607
898
  buffer = [];
608
899
  bufferedSourceVersion = null;
@@ -646,9 +937,16 @@ function createBufferedDetectionTimeline(options) {
646
937
  if (!shouldPrefetch(mediaTime)) {
647
938
  continue;
648
939
  }
649
- await (shouldRefreshRollingWindow(mediaTime)
650
- ? loadWindow(mediaTime)
651
- : refreshBuffer(mediaTime)).catch(() => undefined);
940
+ // A playhead the window does not reach is a jump rather than the window
941
+ // rolling forward, and the frame under it is the one thing the picture
942
+ // cannot draw without. A window that still spans it, however stale, is
943
+ // missing no such frame.
944
+ const lead = isInsideBufferedRange(mediaTime)
945
+ ? "full"
946
+ : "covering";
947
+ await (isBuffered(mediaTime) || shouldRefreshRollingWindow(mediaTime)
948
+ ? loadWindow(mediaTime, lead)
949
+ : refreshBuffer(mediaTime, lead)).catch(() => undefined);
652
950
  }
653
951
  }
654
952
  async function applyIncrementalChanges() {
@@ -690,6 +988,7 @@ function createBufferedDetectionTimeline(options) {
690
988
  frameCount: buffer.length,
691
989
  status: DetectionBufferStatus.Ready,
692
990
  };
991
+ notifyBufferChanged();
693
992
  }
694
993
  catch (error) {
695
994
  if (!destroyed) {
@@ -707,31 +1006,44 @@ function createBufferedDetectionTimeline(options) {
707
1006
  playbackGate?.enabled === true &&
708
1007
  Boolean(options.source.waitForRange));
709
1008
  }
710
- async function waitForPlaybackGate(mediaTime, prepareOptions) {
711
- if (!playbackGate?.enabled || !options.source.waitForRange) {
712
- return;
713
- }
714
- const requiredAheadSeconds = Math.max(0, playbackGate.requiredAheadSeconds ?? 0);
1009
+ function createPlaybackGateCoveragePlan(mediaTime, prepareOptions) {
1010
+ const requiredAheadSeconds = Math.max(0, playbackGate?.requiredAheadSeconds ?? 0);
715
1011
  const comparableMediaTime = getComparableMediaTime(mediaTime);
716
1012
  const endTime = getRequiredCoverageEndTime({
1013
+ // A looping window counts past the end of media and wraps into the
1014
+ // replay, so clamping it to duration would ask for less than it needs.
717
1015
  duration: isLoopingTimeline() ? null : prepareOptions?.duration,
718
1016
  firstTimestamp: prepareOptions?.firstTimestamp,
719
1017
  mediaTime: comparableMediaTime,
720
1018
  requiredAheadSeconds,
721
1019
  });
722
- if (endTime <= comparableMediaTime) {
1020
+ // A lead clamped away at the end of media, or asked for as zero, still
1021
+ // leaves the frame under the playhead to wait for.
1022
+ return createLoadPlan(comparableMediaTime, Math.max(comparableMediaTime, endTime));
1023
+ }
1024
+ function hasAbandonedGate() {
1025
+ return (abandonedGateSourceVersion !== null &&
1026
+ getSourceVersion() <= abandonedGateSourceVersion);
1027
+ }
1028
+ async function waitForPlaybackGate(mediaTime, prepareOptions) {
1029
+ if (!playbackGate?.enabled || !options.source.waitForRange) {
1030
+ return;
1031
+ }
1032
+ if (hasAbandonedGate()) {
723
1033
  return;
724
1034
  }
725
- const coveragePlan = createLoadPlan(comparableMediaTime, endTime);
1035
+ const coveragePlan = createPlaybackGateCoveragePlan(mediaTime, prepareOptions);
726
1036
  state = {
727
1037
  ...state,
728
1038
  errorMessage: null,
729
1039
  requestedEndTime: coveragePlan.endTime,
730
1040
  requestedStartTime: coveragePlan.startTime,
731
- status: DetectionBufferStatus.Loading,
1041
+ status: DetectionBufferStatus.AwaitingCoverage,
732
1042
  };
733
1043
  try {
734
- await Promise.all(coveragePlan.sourceRanges.map((range) => options.source.waitForRange?.(range)));
1044
+ const covered = await waitForSourceCoverage(coveragePlan.sourceRanges, Math.max(0, playbackGate.maxWaitSeconds ?? DEFAULT_PLAYBACK_GATE_MAX_WAIT_SECONDS));
1045
+ abandonedGateSourceVersion =
1046
+ covered || !options.source.getVersion ? null : getSourceVersion();
735
1047
  }
736
1048
  catch (error) {
737
1049
  if (!destroyed) {
@@ -744,6 +1056,33 @@ function createBufferedDetectionTimeline(options) {
744
1056
  throw error;
745
1057
  }
746
1058
  }
1059
+ /**
1060
+ * Resolves true once the source covers every range, false once the wait has
1061
+ * run longer than `maxWaitSeconds`.
1062
+ */
1063
+ async function waitForSourceCoverage(sourceRanges, maxWaitSeconds) {
1064
+ const covered = whenRangesCovered(sourceRanges);
1065
+ if (!Number.isFinite(maxWaitSeconds)) {
1066
+ return covered;
1067
+ }
1068
+ const bound = startWaitBound(maxWaitSeconds * 1000);
1069
+ try {
1070
+ const result = await Promise.race([covered, bound.expired]);
1071
+ if (!result) {
1072
+ // The abandoned wait outlives this call, and a rejection it reaches
1073
+ // afterwards has nobody left to hand it to.
1074
+ void covered.catch(() => undefined);
1075
+ }
1076
+ return result;
1077
+ }
1078
+ finally {
1079
+ bound.cancel();
1080
+ }
1081
+ }
1082
+ async function whenRangesCovered(sourceRanges) {
1083
+ await Promise.all(sourceRanges.map((range) => options.source.waitForRange?.(range)));
1084
+ return true;
1085
+ }
747
1086
  function shouldRefreshRollingWindow(mediaTime) {
748
1087
  if (refreshIntervalSeconds === null ||
749
1088
  refreshIntervalSeconds <= 0 ||
@@ -752,8 +1091,24 @@ function createBufferedDetectionTimeline(options) {
752
1091
  return false;
753
1092
  }
754
1093
  const { endTime, startTime } = getLoadRange(mediaTime);
755
- return (Math.abs(startTime - state.bufferStartTime) >= refreshIntervalSeconds ||
756
- Math.abs(endTime - state.bufferEndTime) >= refreshIntervalSeconds);
1094
+ return (getWindowDrift(startTime, state.bufferStartTime) >=
1095
+ refreshIntervalSeconds ||
1096
+ getWindowDrift(endTime, state.bufferEndTime) >= refreshIntervalSeconds);
1097
+ }
1098
+ /**
1099
+ * How far a planned edge sits from the window's, counted the short way around
1100
+ * a looping timeline. A plan is stated on the first lap and the window on the
1101
+ * playhead's, so across the loop point the two state the same edge a whole lap
1102
+ * apart; measured straight, that reads as a window a full lap stale and
1103
+ * rebuilds it on every frame until the plan stops reaching behind zero.
1104
+ */
1105
+ function getWindowDrift(planTime, bufferTime) {
1106
+ const drift = Math.abs(planTime - bufferTime);
1107
+ if (!isLoopingTimeline() || timelineContext.duration === null) {
1108
+ return drift;
1109
+ }
1110
+ const wrappedDrift = modulo(drift, timelineContext.duration);
1111
+ return Math.min(wrappedDrift, timelineContext.duration - wrappedDrift);
757
1112
  }
758
1113
  function createLoadPlan(requestedStartTime, requestedEndTime) {
759
1114
  const startTime = Math.min(requestedStartTime, requestedEndTime);
@@ -780,10 +1135,15 @@ function createBufferedDetectionTimeline(options) {
780
1135
  startTime: 0,
781
1136
  };
782
1137
  }
1138
+ // Both ends move by the same whole number of laps, so the window keeps its
1139
+ // span and its source ranges while its start reads on the media clock: a
1140
+ // window counted in the laps playback accumulated is one no host can hold
1141
+ // against a current time.
1142
+ const laps = Math.floor(startTime / duration);
783
1143
  return {
784
- endTime,
1144
+ endTime: endTime - laps * duration,
785
1145
  sourceRanges: getLoopingSourceRanges(startTime, endTime, duration),
786
- startTime,
1146
+ startTime: startTime - laps * duration,
787
1147
  };
788
1148
  }
789
1149
  function getBufferedSourceRanges() {
@@ -805,14 +1165,13 @@ function createBufferedDetectionTimeline(options) {
805
1165
  return mediaTime;
806
1166
  }
807
1167
  const duration = timelineContext.duration;
808
- let comparableMediaTime = mediaTime;
809
- while (comparableMediaTime < state.bufferStartTime) {
810
- comparableMediaTime += duration;
811
- }
812
- while (comparableMediaTime > state.bufferEndTime) {
813
- comparableMediaTime -= duration;
814
- }
815
- return comparableMediaTime;
1168
+ // The representative of mediaTime (mod duration) anchored at the window
1169
+ // start. The mapping must depend only on where the window sits, never on
1170
+ // how many laps playback has accumulated, or membership drifts away from
1171
+ // what the buffer actually holds. A time less than one lap past the
1172
+ // anchor never wraps, so ordinary forward playback cannot ratchet.
1173
+ return (state.bufferStartTime +
1174
+ modulo(mediaTime - state.bufferStartTime, duration));
816
1175
  }
817
1176
  function getSourceMediaTime(mediaTime) {
818
1177
  if (!isLoopingTimeline() || timelineContext.duration === null) {
@@ -900,9 +1259,19 @@ function mergeIncrementalFrames(currentFrames, changedFrames, changedRanges) {
900
1259
  }
901
1260
  return Array.from(framesByIdentity.values()).sort(compareDetectionFrames$1);
902
1261
  }
1262
+ /**
1263
+ * Keeps the frame already held wherever the source returned one this buffer
1264
+ * knows, and copies only what is new. Copying everything first and then
1265
+ * discarding it is the same result for a great deal more work: a window rebuilt
1266
+ * while a gesture moves inside it re-derives hundreds of frames it already has.
1267
+ */
903
1268
  function reuseBufferedFrameSnapshots(currentFrames, loadedFrames) {
1269
+ validateDetectionFrames(loadedFrames);
904
1270
  const currentFramesByIdentity = new Map(currentFrames.map((frame) => [getDetectionFrameIdentity(frame), frame]));
905
- return loadedFrames.map((frame) => currentFramesByIdentity.get(getDetectionFrameIdentity(frame)) ?? frame);
1271
+ return loadedFrames
1272
+ .map((frame) => currentFramesByIdentity.get(getDetectionFrameIdentity(frame)) ??
1273
+ copyDetectionFrame(frame))
1274
+ .sort(compareDetectionFrames$1);
906
1275
  }
907
1276
  function getDetectionFrameIdentity(frame) {
908
1277
  return frame.frameIndex === undefined
@@ -1043,7 +1412,7 @@ function createCompositeDetectionFrameSource(options) {
1043
1412
  },
1044
1413
  async waitForRange(range) {
1045
1414
  await Promise.all(sources
1046
- .filter((source) => source.requiredForPlayback)
1415
+ .filter((source) => source.requiredForCoverage)
1047
1416
  .map((source) => source.source.waitForRange?.(range)));
1048
1417
  },
1049
1418
  getAvailableRanges() {
@@ -1078,7 +1447,7 @@ function normalizeCompositeSources(entries) {
1078
1447
  declarationIndex,
1079
1448
  id: entry.id,
1080
1449
  order: entry.order ?? 0,
1081
- requiredForPlayback: entry.requiredForPlayback !== false,
1450
+ requiredForCoverage: entry.requiredForCoverage !== false,
1082
1451
  source: entry.source ?? createArrayDetectionFrameSource(entry.frames ?? []),
1083
1452
  sync: entry.sync,
1084
1453
  };
@@ -1108,47 +1477,181 @@ function composeIntervalFrames(sources, startTime, endTime, options, coordinateS
1108
1477
  }
1109
1478
  const nextBoundaryTime = sortedBoundaryTimes[boundaryIndex + 1] ?? endTime;
1110
1479
  const endTimeForFrame = Math.min(nextBoundaryTime, endTime);
1111
- const frame = composeFrameAtTime(sources, mediaTime, endTimeForFrame, { ...options, selectionMode: DetectionFrameSelectionMode.Interval }, undefined, coordinateSpace);
1480
+ const frame = composeFrameAtTime(sources, mediaTime, endTimeForFrame, { ...options, selectionMode: DetectionFrameSelectionMode.Interval }, coordinateSpace);
1112
1481
  if (frame) {
1113
1482
  frames.push(frame);
1114
1483
  }
1115
1484
  }
1116
1485
  return frames;
1117
1486
  }
1487
+ /**
1488
+ * One composed frame per inference grid index the children wrote, and one per
1489
+ * child frame that carries no index.
1490
+ *
1491
+ * Children are paired on the index their producer labelled, and the composed
1492
+ * frame keeps the media time they reported for it. `frameRate` states the grid
1493
+ * a producer was asked for, not the one the clip plays at, and a slower clip
1494
+ * answers consecutive indexes with the same decoded sample: a timeline placed
1495
+ * by that rate then asks for a time two indexes share, which can only ever
1496
+ * reach one of them and leaves the other's detections stranded. Pairing on the
1497
+ * index reaches every one and keeps each frame's detections on the frame they
1498
+ * were computed for.
1499
+ *
1500
+ * A frame carrying no index has no grid position to be paired on, so it
1501
+ * composes at its own media time, and every child answers there with whatever
1502
+ * it has standing. That is how a child sitting on a grid index keeps its
1503
+ * detections on screen while an unindexed frame from another child overlaps it.
1504
+ * The composed frame carries no index of its own, because only part of what it
1505
+ * holds sits on the grid.
1506
+ */
1118
1507
  function composeNearestFrameIndexFrames(sources, startTime, endTime, options, coordinateSpace) {
1119
1508
  const frameRate = options.frameRate;
1120
1509
  if (!frameRate || !Number.isFinite(frameRate) || frameRate <= 0) {
1121
1510
  return null;
1122
1511
  }
1123
- const indexedFrames = sources.flatMap((source) => source.frames.filter((frame) => frame.frameIndex !== undefined));
1124
- const firstIndexedFrame = indexedFrames[0];
1125
- if (!firstIndexedFrame || firstIndexedFrame.frameIndex === undefined) {
1512
+ const gridSources = sources.map((source) => ({
1513
+ ...source,
1514
+ framesByIndex: indexFramesByFrameIndex(source, options),
1515
+ }));
1516
+ const indexedSlots = mergeGridSlots(gridSources);
1517
+ if (indexedSlots.length === 0) {
1126
1518
  return null;
1127
1519
  }
1128
- const originTime = options.frameIndexOriginTime ??
1129
- firstIndexedFrame.mediaTime - firstIndexedFrame.frameIndex / frameRate;
1130
- const frameIndexes = [
1131
- ...new Set(indexedFrames
1132
- .map((frame) => frame.frameIndex)
1133
- .filter((frameIndex) => frameIndex !== undefined)),
1134
- ].sort((left, right) => left - right);
1520
+ const gridStep = measureGridStep(indexedSlots) ?? 1 / frameRate;
1521
+ const slots = [
1522
+ ...indexedSlots,
1523
+ ...mergeUnindexedGridSlots(gridSources, indexedSlots),
1524
+ ];
1135
1525
  const frames = [];
1136
- for (const frameIndex of frameIndexes) {
1137
- const mediaTime = originTime + frameIndex / frameRate;
1138
- if (mediaTime < startTime || mediaTime >= endTime) {
1526
+ for (const slot of slots) {
1527
+ if (slot.mediaTime < startTime || slot.mediaTime >= endTime) {
1139
1528
  continue;
1140
1529
  }
1141
- const frame = composeFrameAtTime(sources, mediaTime, Math.min(mediaTime + 1 / frameRate, endTime), {
1530
+ const frame = composeFrameAtGridSlot(gridSources, slot, Math.min(slot.mediaTime + gridStep, endTime), {
1142
1531
  ...options,
1143
1532
  selectionMode: DetectionFrameSelectionMode.NearestFrameIndex,
1144
- }, frameIndex, coordinateSpace);
1533
+ }, coordinateSpace);
1145
1534
  if (frame) {
1146
1535
  frames.push(frame);
1147
1536
  }
1148
1537
  }
1149
- return frames;
1538
+ return frames.sort((left, right) => left.mediaTime - right.mediaTime);
1539
+ }
1540
+ function indexFramesByFrameIndex(source, options) {
1541
+ const framesByIndex = new Map();
1542
+ const selectionMode = source.sync?.selectionMode ?? options.selectionMode;
1543
+ if (selectionMode !== DetectionFrameSelectionMode.NearestFrameIndex) {
1544
+ return framesByIndex;
1545
+ }
1546
+ for (const frame of source.frames) {
1547
+ if (frame.frameIndex !== undefined) {
1548
+ framesByIndex.set(frame.frameIndex, frame);
1549
+ }
1550
+ }
1551
+ return framesByIndex;
1552
+ }
1553
+ /**
1554
+ * Where each grid index sits in the media, taken as the earliest time any child
1555
+ * reported for it. Children on one grid watched the same source frame, and the
1556
+ * earliest of their answers is where that frame starts.
1557
+ */
1558
+ function mergeGridSlots(sources) {
1559
+ const mediaTimes = new Map();
1560
+ for (const source of sources) {
1561
+ for (const [frameIndex, frame] of source.framesByIndex) {
1562
+ const mediaTime = mediaTimes.get(frameIndex);
1563
+ if (mediaTime === undefined || frame.mediaTime < mediaTime) {
1564
+ mediaTimes.set(frameIndex, frame.mediaTime);
1565
+ }
1566
+ }
1567
+ }
1568
+ return [...mediaTimes]
1569
+ .map(([frameIndex, mediaTime]) => ({ frameIndex, mediaTime }))
1570
+ .sort((left, right) => left.frameIndex - right.frameIndex);
1571
+ }
1572
+ /**
1573
+ * Where a frame no producer labelled sits: at the media time it reported, which
1574
+ * is the only handle it has.
1575
+ */
1576
+ function mergeUnindexedGridSlots(sources, indexedSlots) {
1577
+ const indexedSlotTimes = new Set(indexedSlots.map((slot) => slot.mediaTime));
1578
+ const mediaTimes = new Set();
1579
+ for (const source of sources) {
1580
+ for (const frame of source.frames) {
1581
+ if (frame.frameIndex === undefined &&
1582
+ !indexedSlotTimes.has(frame.mediaTime)) {
1583
+ mediaTimes.add(frame.mediaTime);
1584
+ }
1585
+ }
1586
+ }
1587
+ return [...mediaTimes]
1588
+ .sort((left, right) => left - right)
1589
+ .map((mediaTime) => ({ mediaTime }));
1590
+ }
1591
+ /**
1592
+ * How long one grid index stands, read off the slots across the widest index
1593
+ * span they cover. A clip whose real rate differs from `frameRate` would end
1594
+ * every composed frame a little short or a little long of where the next index
1595
+ * is due, and that error accumulates for as long as the clip plays.
1596
+ */
1597
+ function measureGridStep(gridSlots) {
1598
+ const firstSlot = gridSlots[0];
1599
+ const lastSlot = gridSlots[gridSlots.length - 1];
1600
+ const indexSpan = lastSlot.frameIndex - firstSlot.frameIndex;
1601
+ const step = (lastSlot.mediaTime - firstSlot.mediaTime) / indexSpan;
1602
+ return indexSpan > 0 && Number.isFinite(step) && step > 0 ? step : undefined;
1603
+ }
1604
+ function composeFrameAtGridSlot(sources, slot, endTime, options, coordinateSpace) {
1605
+ const detections = [];
1606
+ for (const source of sources) {
1607
+ const activeFrame = selectGridSlotFrame(source, slot, options);
1608
+ if (!activeFrame) {
1609
+ continue;
1610
+ }
1611
+ activeFrame.detections.forEach((detection, sourceDetectionIndex) => {
1612
+ detections.push(copyDetectionWithSource(detection, source.id, sourceDetectionIndex));
1613
+ });
1614
+ }
1615
+ if (detections.length === 0) {
1616
+ return undefined;
1617
+ }
1618
+ return {
1619
+ detections,
1620
+ endTime,
1621
+ frameIndex: slot.frameIndex,
1622
+ mediaTime: slot.mediaTime,
1623
+ // Children were projected before composition, so a composed frame is
1624
+ // already in the coordinate space the renderer presents.
1625
+ ...(coordinateSpace ? { coordinateSpace } : {}),
1626
+ };
1627
+ }
1628
+ /**
1629
+ * What one child has to say at a slot.
1630
+ *
1631
+ * A child that wrote this index answers with the frame it wrote for it, however
1632
+ * far its own media time sits from the slot's. A child on the grid that never
1633
+ * wrote this index answers only with a frame of its own that carries no index
1634
+ * and is standing here: its neighbouring indexes describe media the slot is not
1635
+ * on, and lending one of those would put those detections on a frame they were
1636
+ * not computed for.
1637
+ */
1638
+ function selectGridSlotFrame(source, slot, options) {
1639
+ const pairedFrame = slot.frameIndex === undefined
1640
+ ? undefined
1641
+ : source.framesByIndex.get(slot.frameIndex);
1642
+ if (pairedFrame) {
1643
+ return pairedFrame;
1644
+ }
1645
+ const selectedFrame = selectDetectionFrame(source.frames, slot.mediaTime, {
1646
+ ...options,
1647
+ ...source.sync,
1648
+ });
1649
+ const isGridChildAtIndexedSlot = source.framesByIndex.size > 0 && slot.frameIndex !== undefined;
1650
+ return isGridChildAtIndexedSlot && selectedFrame?.frameIndex !== undefined
1651
+ ? undefined
1652
+ : selectedFrame;
1150
1653
  }
1151
- function composeFrameAtTime(sources, mediaTime, endTime, options, frameIndex, coordinateSpace) {
1654
+ function composeFrameAtTime(sources, mediaTime, endTime, options, coordinateSpace) {
1152
1655
  const detections = [];
1153
1656
  const activeFrameIndexes = [];
1154
1657
  for (const source of sources) {
@@ -1172,7 +1675,7 @@ function composeFrameAtTime(sources, mediaTime, endTime, options, frameIndex, co
1172
1675
  return {
1173
1676
  detections,
1174
1677
  endTime,
1175
- frameIndex: frameIndex ?? resolveComposedFrameIndex(activeFrameIndexes) ?? undefined,
1678
+ frameIndex: resolveComposedFrameIndex(activeFrameIndexes) ?? undefined,
1176
1679
  mediaTime,
1177
1680
  // Children were projected before composition, so a composed frame is
1178
1681
  // already in the coordinate space the renderer presents.
@@ -1645,7 +2148,6 @@ function createProjectedDetectionFrameSource(source, resolveTarget) {
1645
2148
  };
1646
2149
  }
1647
2150
 
1648
- const RANGE_EPSILON_SECONDS = 1e-6;
1649
2151
  const MAX_CHANGED_RANGE_JOURNAL_LENGTH = 512;
1650
2152
  const DEFAULT_LIVE_HOLD_SECONDS = 60;
1651
2153
  const NO_COVERAGE_INVALIDATION = { ranges: [] };
@@ -1786,8 +2288,8 @@ function createWritableDetectionFrameSource(options) {
1786
2288
  // earlier, though, so report it rather than assume.
1787
2289
  invalidation);
1788
2290
  // Closing a held live frame shortens coverage. Reported availability
1789
- // has to shrink with it, or a playback gate would still believe the
1790
- // source covers time past the end of media.
2291
+ // has to shrink with it, or `waitForRange` and the buffered window
2292
+ // would still believe the source covers time past the end of media.
1791
2293
  clipAvailableRangesAfter(endTime);
1792
2294
  return finalSummary;
1793
2295
  });
@@ -2252,10 +2754,6 @@ function recordAvailableRange(range, availableRanges) {
2252
2754
  }
2253
2755
  availableRanges.length = writeIndex;
2254
2756
  }
2255
- function isRangeCovered(range, availableRanges) {
2256
- return availableRanges.some((availableRange) => availableRange.startTime <= range.startTime + RANGE_EPSILON_SECONDS &&
2257
- availableRange.endTime + RANGE_EPSILON_SECONDS >= range.endTime);
2258
- }
2259
2757
 
2260
2758
  /**
2261
2759
  * Creates one stateful SORT tracker for a single ordered media sequence.
@@ -3587,53 +4085,230 @@ function extractMaskContour(data, width, height) {
3587
4085
  }
3588
4086
  function extractMaskRectRuns(data, width, height) {
3589
4087
  assertMaskDimensions(data, width, height);
4088
+ const rects = mergeRowSpansIntoRects(data, width, height, 0, 0);
4089
+ return rects.length > 0 ? rects : undefined;
4090
+ }
4091
+ /**
4092
+ * The rect runs of a compressed-RLE mask, without holding the mask's raster.
4093
+ *
4094
+ * A mask is the size of the whole frame, so reading a silhouette that covers a
4095
+ * fraction of one percent of it otherwise costs a megabytes-wide allocation and
4096
+ * a pass over every pixel. The counts carry the occupied box, so the raster and
4097
+ * the pass are that box.
4098
+ */
4099
+ function extractDetectionMaskRectRuns(mask) {
4100
+ if (mask.encoding !== DetectionMaskEncoding.CompressedRle) {
4101
+ throw new Error(`Unsupported detection mask encoding: ${mask.encoding}`);
4102
+ }
4103
+ const { height, width } = mask;
4104
+ assertMaskSize(width, height);
4105
+ const counts = decodeCompressedRleCounts(mask.counts);
4106
+ const bounds = computeCompressedRleBounds(counts, width, height);
4107
+ if (!bounds) {
4108
+ return undefined;
4109
+ }
4110
+ const { left, top } = bounds;
4111
+ const boundsWidth = bounds.width;
4112
+ const boundsHeight = bounds.height;
4113
+ const boundsBuffer = new Uint8Array(boundsWidth * boundsHeight);
4114
+ const pixels = width * height;
4115
+ let offset = 0;
4116
+ for (let index = 0; index < counts.length; index += 1) {
4117
+ const start = offset;
4118
+ offset += counts[index] ?? 0;
4119
+ if (!isForegroundRun(index) || start >= pixels) {
4120
+ continue;
4121
+ }
4122
+ const end = Math.min(offset, pixels);
4123
+ let cursor = start;
4124
+ while (cursor < end) {
4125
+ const column = Math.floor(cursor / height);
4126
+ const columnEnd = Math.min(end, (column + 1) * height);
4127
+ let target = (cursor - column * height - top) * boundsWidth + (column - left);
4128
+ for (let step = columnEnd - cursor; step > 0; step -= 1) {
4129
+ boundsBuffer[target] = 1;
4130
+ target += boundsWidth;
4131
+ }
4132
+ cursor = columnEnd;
4133
+ }
4134
+ }
4135
+ const rects = mergeRowSpansIntoRects(boundsBuffer, boundsWidth, boundsHeight, left, top);
4136
+ return rects.length > 0 ? rects : undefined;
4137
+ }
4138
+ /** Counts alternate background, foreground, starting on background. */
4139
+ function isForegroundRun(index) {
4140
+ return index % 2 === 1;
4141
+ }
4142
+ /**
4143
+ * The occupied box of a compressed-RLE mask, read from the counts alone.
4144
+ *
4145
+ * COCO runs are column-major, so a run confined to one column bounds its own
4146
+ * rows, and a run that reaches the next column has already spanned a full
4147
+ * column top to bottom.
4148
+ */
4149
+ function computeCompressedRleBounds(counts, width, height) {
4150
+ const pixels = width * height;
4151
+ let left = width;
4152
+ let top = height;
4153
+ let right = -1;
4154
+ let bottom = -1;
4155
+ let offset = 0;
4156
+ for (let index = 0; index < counts.length; index += 1) {
4157
+ const start = offset;
4158
+ offset += counts[index] ?? 0;
4159
+ if (!isForegroundRun(index) || start >= pixels) {
4160
+ continue;
4161
+ }
4162
+ const end = Math.min(offset, pixels) - 1;
4163
+ const startColumn = Math.floor(start / height);
4164
+ const endColumn = Math.floor(end / height);
4165
+ if (startColumn < left) {
4166
+ left = startColumn;
4167
+ }
4168
+ if (endColumn > right) {
4169
+ right = endColumn;
4170
+ }
4171
+ if (startColumn !== endColumn) {
4172
+ top = 0;
4173
+ bottom = height - 1;
4174
+ continue;
4175
+ }
4176
+ const startRow = start - startColumn * height;
4177
+ const endRow = end - endColumn * height;
4178
+ if (startRow < top) {
4179
+ top = startRow;
4180
+ }
4181
+ if (endRow > bottom) {
4182
+ bottom = endRow;
4183
+ }
4184
+ }
4185
+ return right < 0
4186
+ ? null
4187
+ : {
4188
+ height: bottom - top + 1,
4189
+ left,
4190
+ top,
4191
+ width: right - left + 1,
4192
+ };
4193
+ }
4194
+ function createOpenMaskSpans(capacity) {
4195
+ return {
4196
+ openedAt: new Int32Array(capacity),
4197
+ runWidth: new Int32Array(capacity),
4198
+ startX: new Int32Array(capacity),
4199
+ topY: new Int32Array(capacity),
4200
+ };
4201
+ }
4202
+ /**
4203
+ * Emits rects in the coordinates of the raster the given window was cut from,
4204
+ * not the window's own.
4205
+ *
4206
+ * A span continues the rect above it when its start and width both match, and
4207
+ * the row above holds exactly one open rect per span, so walking both in
4208
+ * ascending x pairs them without a lookup.
4209
+ */
4210
+ function mergeRowSpansIntoRects(data, width, height, offsetX, offsetY) {
3590
4211
  const rects = [];
3591
- const openRects = new Map();
4212
+ // Spans need a gap between them, so a row holds at most half its width.
4213
+ const capacity = (width >> 1) + 2;
4214
+ const closed = new Int32Array(capacity);
4215
+ let open = createOpenMaskSpans(capacity);
4216
+ let next = createOpenMaskSpans(capacity);
4217
+ let openCount = 0;
4218
+ let opened = 0;
3592
4219
  for (let y = 0; y < height; y += 1) {
3593
- const activeSpans = new Set();
4220
+ const row = y * width;
4221
+ let nextCount = 0;
4222
+ let closedCount = 0;
4223
+ let cursor = 0;
3594
4224
  let x = 0;
3595
4225
  while (x < width) {
3596
- while (x < width && !data[y * width + x]) {
4226
+ while (x < width && data[row + x] === 0) {
3597
4227
  x += 1;
3598
4228
  }
3599
4229
  if (x >= width) {
3600
4230
  break;
3601
4231
  }
3602
4232
  const startX = x;
3603
- while (x < width && data[y * width + x]) {
4233
+ while (x < width && data[row + x] !== 0) {
3604
4234
  x += 1;
3605
4235
  }
3606
4236
  const runWidth = x - startX;
3607
- const key = `${startX}:${runWidth}`;
3608
- const openRect = openRects.get(key);
3609
- activeSpans.add(key);
3610
- if (openRect && openRect.y + openRect.height === y) {
3611
- openRects.set(key, { ...openRect, height: openRect.height + 1 });
4237
+ while (cursor < openCount && open.startX[cursor] < startX) {
4238
+ closed[closedCount] = cursor;
4239
+ closedCount += 1;
4240
+ cursor += 1;
4241
+ }
4242
+ if (cursor < openCount &&
4243
+ open.startX[cursor] === startX &&
4244
+ open.runWidth[cursor] === runWidth) {
4245
+ next.openedAt[nextCount] = open.openedAt[cursor];
4246
+ next.topY[nextCount] = open.topY[cursor];
4247
+ cursor += 1;
3612
4248
  }
3613
4249
  else {
3614
- if (openRect) {
3615
- rects.push(openRect);
4250
+ if (cursor < openCount && open.startX[cursor] === startX) {
4251
+ closed[closedCount] = cursor;
4252
+ closedCount += 1;
4253
+ cursor += 1;
3616
4254
  }
3617
- openRects.set(key, { height: 1, width: runWidth, x: startX, y });
4255
+ next.openedAt[nextCount] = opened;
4256
+ next.topY[nextCount] = y;
4257
+ opened += 1;
3618
4258
  }
3619
- }
3620
- for (const [key, openRect] of openRects) {
3621
- if (!activeSpans.has(key)) {
3622
- rects.push(openRect);
3623
- openRects.delete(key);
3624
- }
3625
- }
4259
+ next.runWidth[nextCount] = runWidth;
4260
+ next.startX[nextCount] = startX;
4261
+ nextCount += 1;
4262
+ }
4263
+ while (cursor < openCount) {
4264
+ closed[closedCount] = cursor;
4265
+ closedCount += 1;
4266
+ cursor += 1;
4267
+ }
4268
+ pushClosedRects(rects, open, closed, closedCount, y, offsetX, offsetY);
4269
+ const carried = open;
4270
+ open = next;
4271
+ next = carried;
4272
+ openCount = nextCount;
4273
+ }
4274
+ for (let index = 0; index < openCount; index += 1) {
4275
+ closed[index] = index;
4276
+ }
4277
+ pushClosedRects(rects, open, closed, openCount, height, offsetX, offsetY);
4278
+ return rects;
4279
+ }
4280
+ /** Rects leave in the order they opened, which is not their order across a row. */
4281
+ function pushClosedRects(rects, open, closed, closedCount, bottomY, offsetX, offsetY) {
4282
+ for (let index = 1; index < closedCount; index += 1) {
4283
+ const span = closed[index];
4284
+ const openedAt = open.openedAt[span];
4285
+ let sorted = index - 1;
4286
+ while (sorted >= 0 && open.openedAt[closed[sorted]] > openedAt) {
4287
+ closed[sorted + 1] = closed[sorted];
4288
+ sorted -= 1;
4289
+ }
4290
+ closed[sorted + 1] = span;
4291
+ }
4292
+ for (let index = 0; index < closedCount; index += 1) {
4293
+ const span = closed[index];
4294
+ rects.push({
4295
+ height: bottomY - open.topY[span],
4296
+ width: open.runWidth[span],
4297
+ x: open.startX[span] + offsetX,
4298
+ y: open.topY[span] + offsetY,
4299
+ });
3626
4300
  }
3627
- rects.push(...openRects.values());
3628
- return rects.length > 0 ? rects : undefined;
3629
4301
  }
3630
- function assertMaskDimensions(data, width, height) {
4302
+ function assertMaskSize(width, height) {
3631
4303
  if (!Number.isInteger(width) || width <= 0) {
3632
4304
  throw new Error("Mask width must be a positive integer.");
3633
4305
  }
3634
4306
  if (!Number.isInteger(height) || height <= 0) {
3635
4307
  throw new Error("Mask height must be a positive integer.");
3636
4308
  }
4309
+ }
4310
+ function assertMaskDimensions(data, width, height) {
4311
+ assertMaskSize(width, height);
3637
4312
  if (data.length !== width * height) {
3638
4313
  throw new Error("Mask data length must equal width * height.");
3639
4314
  }
@@ -5314,8 +5989,8 @@ const DEFAULT_SELECTED_STROKE_WIDTH = 4;
5314
5989
  * Default configurable interaction style.
5315
5990
  *
5316
5991
  * It resolves hover and selected picks into state-specific presentations using
5317
- * the same style contracts as normal boxes, masks, and labels. The older
5318
- * rectangle options are kept as compatibility sugar and resolve to a box style.
5992
+ * the same style contracts as normal boxes, masks, and labels. A state left
5993
+ * unconfigured falls back to a highlight rectangle over the detection.
5319
5994
  */
5320
5995
  class BaseInteractionStyle {
5321
5996
  options;
@@ -5337,60 +6012,22 @@ class BaseInteractionStyle {
5337
6012
  return undefined;
5338
6013
  }
5339
6014
  return {
5340
- boxStyle: createResolvedBoxStyle(this.resolveBoxInstruction(detection, context, rect)),
5341
- };
5342
- }
5343
- resolveBoxInstruction(detection, context, rect) {
5344
- const shape = this.resolveShape(detection, context);
5345
- const cornerRadius = this.resolveCornerRadius(detection, context, shape);
5346
- const instruction = {
5347
- fill: this.resolveFill(detection, context),
5348
- rect,
5349
- shape,
5350
- stroke: this.resolveStroke(detection, context),
6015
+ boxStyle: createDefaultHighlightBoxStyle(context.state, rect),
5351
6016
  };
5352
- if (cornerRadius !== undefined) {
5353
- return {
5354
- ...instruction,
5355
- cornerRadius,
5356
- };
5357
- }
5358
- return instruction;
5359
- }
5360
- resolveShape(detection, context) {
5361
- return (resolveStyleValue(this.options.shape, detection, context) ?? BoxShape.Rect);
5362
- }
5363
- resolveCornerRadius(detection, context, shape) {
5364
- if (shape !== BoxShape.RoundedRect) {
5365
- return undefined;
5366
- }
5367
- return resolveStyleValue(this.options.cornerRadius, detection, context);
5368
6017
  }
5369
- resolveStroke(detection, context) {
5370
- const stroke = resolveStyleValue(this.options.stroke, detection, context);
5371
- if (stroke === null) {
5372
- return undefined;
5373
- }
5374
- const defaults = getStateDefaults(context.state);
5375
- return resolveStrokeStyle(stroke, {
6018
+ }
6019
+ function createDefaultHighlightBoxStyle(state, rect) {
6020
+ const defaults = getStateDefaults(state);
6021
+ const instruction = {
6022
+ fill: { alpha: defaults.fillAlpha, color: defaults.fillColor },
6023
+ rect,
6024
+ shape: BoxShape.Rect,
6025
+ stroke: {
5376
6026
  alpha: defaults.strokeAlpha,
5377
6027
  color: defaults.strokeColor,
5378
6028
  width: defaults.strokeWidth,
5379
- });
5380
- }
5381
- resolveFill(detection, context) {
5382
- const fill = resolveStyleValue(this.options.fill, detection, context);
5383
- if (fill === null) {
5384
- return undefined;
5385
- }
5386
- const defaults = getStateDefaults(context.state);
5387
- return {
5388
- alpha: fill?.alpha ?? defaults.fillAlpha,
5389
- color: fill?.color ?? defaults.fillColor,
5390
- };
5391
- }
5392
- }
5393
- function createResolvedBoxStyle(instruction) {
6029
+ },
6030
+ };
5394
6031
  return {
5395
6032
  resolve() {
5396
6033
  return instruction;
@@ -5790,8 +6427,18 @@ class BasePolylineStyle {
5790
6427
  if (stroke === null) {
5791
6428
  return undefined;
5792
6429
  }
6430
+ const shadowStroke = resolveStyleValue(this.options.shadowStroke, detection, context);
5793
6431
  return {
5794
6432
  points: detection.polyline.points,
6433
+ ...(shadowStroke === undefined || shadowStroke === null
6434
+ ? {}
6435
+ : {
6436
+ shadowStroke: resolveStrokeStyle(shadowStroke, {
6437
+ alpha: 0.65,
6438
+ color: 0x000000,
6439
+ width: 4,
6440
+ }),
6441
+ }),
5795
6442
  stroke: resolveStrokeStyle(stroke, {
5796
6443
  alpha: 1,
5797
6444
  color: 0x00ff66,
@@ -5961,6 +6608,8 @@ const DEFAULT_KEYPOINT_EDGE_WIDTH = 1.5;
5961
6608
  const DEFAULT_KEYPOINT_SHADOW_WIDTH = 3;
5962
6609
  const DEFAULT_KEYPOINT_SHADOW_ALPHA = 0.25;
5963
6610
  const DEFAULT_KEYPOINT_RADIUS = 3.5;
6611
+ const DEFAULT_POLYLINE_SHADOW_WIDTH = DEFAULT_OUTLINE_WIDTH + 1;
6612
+ const DEFAULT_POLYLINE_SHADOW_ALPHA = 0.55;
5964
6613
  const DEFAULT_LABEL_CORNER_RADIUS = 4;
5965
6614
  const DEFAULT_LABEL_PADDING_X = 6;
5966
6615
  const DEFAULT_LABEL_PADDING_Y = 3;
@@ -6149,6 +6798,11 @@ function createDefaultPolygonStyle(options = {}) {
6149
6798
  function createDefaultPolylineStyle(options = {}) {
6150
6799
  const getClassColor = createClassColorResolver(options);
6151
6800
  return new BasePolylineStyle({
6801
+ shadowStroke: {
6802
+ alpha: DEFAULT_POLYLINE_SHADOW_ALPHA,
6803
+ color: 0x000000,
6804
+ width: DEFAULT_POLYLINE_SHADOW_WIDTH,
6805
+ },
6152
6806
  stroke: (detection) => ({
6153
6807
  alpha: 1,
6154
6808
  color: getClassColor(detection),
@@ -6188,12 +6842,28 @@ const annotationRendererKinds = [
6188
6842
  /** Sources currently supported by a region renderer. */
6189
6843
  const RegionRendererSourceKind = {
6190
6844
  Asset: "asset",
6845
+ Media: "media",
6191
6846
  };
6192
6847
  /** Media-space regions currently supported by a region renderer. */
6193
6848
  const RegionRendererRegionKind = {
6194
6849
  Bounds: "bounds",
6195
6850
  KeypointAnchor: "keypoint-anchor",
6196
6851
  };
6852
+ /** Semantic coverage used to clip pixels sampled from the current media. */
6853
+ const RegionRendererCoverageKind = {
6854
+ Mask: "mask",
6855
+ Polygon: "polygon",
6856
+ };
6857
+ /** Filter effects that can be applied to a current-frame media region. */
6858
+ const RegionRendererMediaEffectKind = {
6859
+ Blur: "blur",
6860
+ Pixelate: "pixelate",
6861
+ };
6862
+ /** Coordinate space used by an explicit region-renderer size. */
6863
+ const RegionRendererSizeSpace = {
6864
+ Media: "media",
6865
+ Screen: "screen",
6866
+ };
6197
6867
  /** Composition modes currently supported by a region renderer. */
6198
6868
  const RegionRendererComposeMode = {
6199
6869
  Over: "over",
@@ -6572,10 +7242,10 @@ function rasterizeRectToMask(rect, dimensions) {
6572
7242
  const topLeft = centerRectToTopLeftRect(rect);
6573
7243
  const left = Math.max(0, Math.round(topLeft.x));
6574
7244
  const top = Math.max(0, Math.round(topLeft.y));
6575
- const right = Math.min(dimensions.width - 1, Math.round(rect.x + rect.width / 2));
6576
- const bottom = Math.min(dimensions.height - 1, Math.round(rect.y + rect.height / 2));
6577
- for (let y = top; y <= bottom; y += 1) {
6578
- for (let x = left; x <= right; x += 1) {
7245
+ const right = Math.min(dimensions.width, Math.round(topLeft.x + rect.width));
7246
+ const bottom = Math.min(dimensions.height, Math.round(topLeft.y + rect.height));
7247
+ for (let y = top; y < bottom; y += 1) {
7248
+ for (let x = left; x < right; x += 1) {
6579
7249
  data[y * dimensions.width + x] = 1;
6580
7250
  }
6581
7251
  }
@@ -6603,9 +7273,9 @@ function rasterizePolygonToMask(points, dimensions) {
6603
7273
  }
6604
7274
  intersections.sort((left, right) => left - right);
6605
7275
  for (let index = 0; index < intersections.length - 1; index += 2) {
6606
- const left = Math.max(0, Math.ceil(intersections[index]));
6607
- const right = Math.min(dimensions.width - 1, Math.floor(intersections[index + 1]));
6608
- for (let x = left; x <= right; x += 1) {
7276
+ const left = Math.max(0, Math.round(intersections[index]));
7277
+ const right = Math.min(dimensions.width, Math.round(intersections[index + 1]));
7278
+ for (let x = left; x < right; x += 1) {
6609
7279
  data[y * dimensions.width + x] = 1;
6610
7280
  }
6611
7281
  }
@@ -6745,14 +7415,28 @@ function resolveMaskStyleOpacity(maskStyle) {
6745
7415
  return Number.isFinite(opacity) ? Math.max(0, Math.min(opacity, 1)) : 1;
6746
7416
  }
6747
7417
 
6748
- const MAX_ID_MASK_PALETTE_ENTRIES = 64;
7418
+ /**
7419
+ * Palette slots an id raster can name, one of them the background. A GLSL
7420
+ * fragment stage is guaranteed only 224 uniform vectors, and a palette entry
7421
+ * costs 2.25 of them, so this stays a multiple of four and well inside that
7422
+ * budget; past it a frame draws from the RGBA composite instead.
7423
+ */
7424
+ const MAX_ID_MASK_PALETTE_ENTRIES = 80;
6749
7425
  const MAX_ID_MASK_STROKE_WIDTH = 16;
6750
- function createIdMaskFrame(instructions) {
7426
+ function createIdMaskFrame(instructions, options = {}) {
6751
7427
  if (instructions.length === 0) {
6752
7428
  return undefined;
6753
7429
  }
6754
- const width = Math.max(...instructions.map(({ mask }) => mask.width));
6755
- const height = Math.max(...instructions.map(({ mask }) => mask.height));
7430
+ const maskWidth = Math.max(...instructions.map(({ mask }) => mask.width));
7431
+ const maskHeight = Math.max(...instructions.map(({ mask }) => mask.height));
7432
+ const width = resolveRasterWidth(maskWidth, options.maxWidth);
7433
+ const height = width === maskWidth
7434
+ ? maskHeight
7435
+ : Math.max(1, Math.round((width * maskHeight) / maskWidth));
7436
+ const strokeScale = width / maskWidth;
7437
+ const scaledAxes = width === maskWidth
7438
+ ? undefined
7439
+ : createScaledMaskAxes({ height, maskHeight, maskWidth, width });
6756
7440
  const data = new Uint8Array(new ArrayBuffer(width * height));
6757
7441
  const fillPalette = new Float32Array(new ArrayBuffer(MAX_ID_MASK_PALETTE_ENTRIES * 4 * 4));
6758
7442
  const strokePalette = new Float32Array(new ArrayBuffer(MAX_ID_MASK_PALETTE_ENTRIES * 4 * 4));
@@ -6767,20 +7451,17 @@ function createIdMaskFrame(instructions) {
6767
7451
  }
6768
7452
  writePaletteEntry(fillPalette, detectionMaskId, instruction.color, instruction.alpha);
6769
7453
  if (instruction.stroke && instruction.stroke.width > 0) {
6770
- const strokeWidth = Math.min(Math.max(0, instruction.stroke.width), MAX_ID_MASK_STROKE_WIDTH);
7454
+ const strokeWidth = Math.min(resolveStrokeTexels(instruction.stroke.width, strokeScale), MAX_ID_MASK_STROKE_WIDTH);
6771
7455
  hasStroke = true;
6772
7456
  strokeWidths[detectionMaskId] = strokeWidth;
6773
7457
  maxStrokeWidth = Math.max(maxStrokeWidth, strokeWidth);
6774
7458
  writePaletteEntry(strokePalette, detectionMaskId, instruction.stroke.color, instruction.stroke.alpha);
6775
7459
  }
6776
- const decodedMask = decodeCompressedRleMask(instruction.mask);
6777
- for (let y = 0; y < decodedMask.height; y += 1) {
6778
- for (let x = 0; x < decodedMask.width; x += 1) {
6779
- const maskOffset = y * decodedMask.width + x;
6780
- if (decodedMask.data[maskOffset]) {
6781
- data[y * width + x] = detectionMaskId;
6782
- }
6783
- }
7460
+ if (scaledAxes) {
7461
+ writeScaledMaskRuns(data, scaledAxes, instruction.mask, detectionMaskId);
7462
+ }
7463
+ else {
7464
+ writeMaskRuns(data, width, instruction.mask, detectionMaskId);
6784
7465
  }
6785
7466
  }
6786
7467
  return {
@@ -6794,6 +7475,103 @@ function createIdMaskFrame(instructions) {
6794
7475
  width,
6795
7476
  };
6796
7477
  }
7478
+ /**
7479
+ * Compressed RLE counts runs down each column in turn, so a foreground run is
7480
+ * a contiguous walk down one column that wraps into the next.
7481
+ */
7482
+ function writeMaskRuns(data, frameWidth, mask, detectionMaskId) {
7483
+ const counts = decodeCompressedRleCounts(mask.counts);
7484
+ const maskWidth = mask.width;
7485
+ const maskHeight = mask.height;
7486
+ let maskOffset = 0;
7487
+ for (let index = 0; index < counts.length; index += 1) {
7488
+ const runLength = counts[index] ?? 0;
7489
+ if (index % 2 === 0 || runLength <= 0) {
7490
+ maskOffset += runLength;
7491
+ continue;
7492
+ }
7493
+ let x = Math.floor(maskOffset / maskHeight);
7494
+ let y = maskOffset - x * maskHeight;
7495
+ let frameOffset = y * frameWidth + x;
7496
+ for (let step = 0; step < runLength; step += 1) {
7497
+ if (x >= maskWidth) {
7498
+ break;
7499
+ }
7500
+ data[frameOffset] = detectionMaskId;
7501
+ y += 1;
7502
+ frameOffset += frameWidth;
7503
+ if (y === maskHeight) {
7504
+ y = 0;
7505
+ x += 1;
7506
+ frameOffset = x;
7507
+ }
7508
+ }
7509
+ maskOffset += runLength;
7510
+ }
7511
+ }
7512
+ /**
7513
+ * The scaled twin of writeMaskRuns. Every source pixel marks the texel it lands
7514
+ * in, so a mask keeps every part of itself the smaller raster can hold and
7515
+ * gains up to a texel at its edges.
7516
+ */
7517
+ function writeScaledMaskRuns(data, axes, mask, detectionMaskId) {
7518
+ const counts = decodeCompressedRleCounts(mask.counts);
7519
+ const maskWidth = mask.width;
7520
+ const maskHeight = mask.height;
7521
+ let maskOffset = 0;
7522
+ for (let index = 0; index < counts.length; index += 1) {
7523
+ const runLength = counts[index] ?? 0;
7524
+ if (index % 2 === 0 || runLength <= 0) {
7525
+ maskOffset += runLength;
7526
+ continue;
7527
+ }
7528
+ let x = Math.floor(maskOffset / maskHeight);
7529
+ let y = maskOffset - x * maskHeight;
7530
+ let column = axes.columns[x];
7531
+ for (let step = 0; step < runLength; step += 1) {
7532
+ if (x >= maskWidth) {
7533
+ break;
7534
+ }
7535
+ data[axes.rows[y] + column] = detectionMaskId;
7536
+ y += 1;
7537
+ if (y === maskHeight) {
7538
+ y = 0;
7539
+ x += 1;
7540
+ column = axes.columns[x];
7541
+ }
7542
+ }
7543
+ maskOffset += runLength;
7544
+ }
7545
+ }
7546
+ function resolveRasterWidth(maskWidth, maxWidth) {
7547
+ return maxWidth !== undefined && maxWidth > 0 && maxWidth < maskWidth
7548
+ ? Math.max(1, Math.floor(maxWidth))
7549
+ : maskWidth;
7550
+ }
7551
+ /**
7552
+ * A stroke is measured in texels of the raster it is drawn on, so a coarser
7553
+ * raster measures it in coarser texels. A stroke of a texel or more keeps at
7554
+ * least one, the thinnest line the shader can draw; a narrower one keeps its
7555
+ * own width, which the shader draws as an inner boundary at any scale.
7556
+ */
7557
+ function resolveStrokeTexels(strokeWidth, scale) {
7558
+ return Math.max(strokeWidth * scale, Math.min(strokeWidth, 1));
7559
+ }
7560
+ function createScaledMaskAxes(frame) {
7561
+ return {
7562
+ columns: createMaskAxisMap(frame.maskWidth, frame.width, 1),
7563
+ rows: createMaskAxisMap(frame.maskHeight, frame.height, frame.width),
7564
+ };
7565
+ }
7566
+ /** Rows hold their destination offset, so writing a run is two lookups. */
7567
+ function createMaskAxisMap(sourceLength, targetLength, stride) {
7568
+ const map = new Int32Array(sourceLength);
7569
+ const scale = targetLength / sourceLength;
7570
+ for (let index = 0; index < sourceLength; index += 1) {
7571
+ map[index] = Math.min(targetLength - 1, Math.floor(index * scale)) * stride;
7572
+ }
7573
+ return map;
7574
+ }
6797
7575
  function writePaletteEntry(palette, id, color, alpha) {
6798
7576
  const offset = id * 4;
6799
7577
  palette[offset] = ((color >> 16) & 0xff) / 255;
@@ -7058,6 +7836,30 @@ var MediaSourceStatus;
7058
7836
  MediaSourceStatus["Error"] = "error";
7059
7837
  MediaSourceStatus["Destroyed"] = "destroyed";
7060
7838
  })(MediaSourceStatus || (MediaSourceStatus = {}));
7839
+ /**
7840
+ * Current renderer state.
7841
+ */
7842
+ /**
7843
+ * What a playback gate holds back.
7844
+ *
7845
+ * A source the renderer pulls samples from can be held between any two frames,
7846
+ * because the renderer decides when each one is drawn. A source that presents
7847
+ * its own frames owns the playhead, so holding it means stopping the producer
7848
+ * and starting it again, which the detection gate does and the
7849
+ * render-preparation gate does not.
7850
+ */
7851
+ var PlaybackGateReach;
7852
+ (function (PlaybackGateReach) {
7853
+ /** No gate: the picture moves and unprepared layers are absent from it. */
7854
+ PlaybackGateReach["Off"] = "off";
7855
+ /**
7856
+ * Playback waits to begin, and stops again at any frame whose artifacts are
7857
+ * missing, until they arrive or the gate's own wait bound gives up on them.
7858
+ */
7859
+ PlaybackGateReach["EveryFrame"] = "everyFrame";
7860
+ /** Playback waits to begin; frames after that are not held. */
7861
+ PlaybackGateReach["StartOfPlayback"] = "startOfPlayback";
7862
+ })(PlaybackGateReach || (PlaybackGateReach = {}));
7061
7863
 
7062
7864
  /**
7063
7865
  * Media-session operating mode.
@@ -7098,6 +7900,13 @@ var MediaSessionStatus;
7098
7900
  */
7099
7901
  var MediaSessionActivityKind;
7100
7902
  (function (MediaSessionActivityKind) {
7903
+ /**
7904
+ * Playback is held waiting for a producer to emit detections for the frame
7905
+ * about to be shown, which is a wait on inference rather than on transfer or
7906
+ * decode. `DetectionsBuffering` is the transfer of detections that already
7907
+ * exist, and `PlaybackBuffering` is the wait for media bytes.
7908
+ */
7909
+ MediaSessionActivityKind["DetectionsAwaitingCoverage"] = "detectionsAwaitingCoverage";
7101
7910
  MediaSessionActivityKind["DetectionsBuffering"] = "detectionsBuffering";
7102
7911
  MediaSessionActivityKind["DetectionsLoading"] = "detectionsLoading";
7103
7912
  MediaSessionActivityKind["Error"] = "error";
@@ -7116,5 +7925,5 @@ var MediaSessionActivityStatus;
7116
7925
  MediaSessionActivityStatus["Waiting"] = "waiting";
7117
7926
  })(MediaSessionActivityStatus || (MediaSessionActivityStatus = {}));
7118
7927
 
7119
- export { AnnotationFrameMutationKind, AnnotationGeometryKind, AnnotationGestureStateKind, AnnotationHandleKind, BaseBoxCornerStyle, BaseBoxStyle, BaseFocusStyle, BaseInteractionStyle, BaseKeypointStyle, BaseLabelStyle, BaseMarkerStyle, BaseMaskStyle, BasePolygonStyle, BasePolylineStyle, BoxShape, StrokeAlignment as BoxStrokeAlignment, DEFAULT_DETECTION_CLASS_STYLES, DEFAULT_DETECTION_COLOR_SEQUENCE, DetectionBufferStatus, DetectionFrameRetentionMode, DetectionFrameSelectionMode, DetectionInteractionState, DetectionMaskEncoding, DetectionMaskPayloadFormat, DetectionPickTarget, FocusTargetMode, KeypointMarkerShape, KeypointVisibility, LabelPlacement, LabelVisibilityMode, MAX_ID_MASK_PALETTE_ENTRIES, MAX_ID_MASK_STROKE_WIDTH, MarkerShape, MarkerSizeSpace, MaskRenderMode, MediaErrorKind, MediaInteractionMode, MediaRendererFit, MediaRendererPlaybackState, MediaSessionActivityKind, MediaSessionActivityStatus, MediaSessionMode, MediaSessionStatus, MediaSourceStatus, RegionRendererComposeMode, RegionRendererRegionKind, RegionRendererSourceKind, SUPERVISION_ROBOFLOW_COLOR, ShapeInstructionKind, StrokeAlignment, TrackingGeometry, annotationRendererKinds, annotationRenderers, applyAnnotationHandleDrag, canReuseMaskStyleArtifacts, centerRectToTopLeftRect, computeDetectionMaskRect, computeMaskBounds, containsPoint, convertDetectionBoxToMask, convertDetectionBoxToPolygon, convertDetectionMaskToBox, convertDetectionMaskToPolygon, convertDetectionPolygonToBox, convertDetectionPolygonToMask, copySortedDetectionFrames, createAnnotationEditingEngine, createArrayDetectionFrameSource, createBufferedDetectionTimeline, createByteTrackTracker, createCBIoUTracker, createColdDetectionFrameSource, createCompositeDetectionFrameSource, createDefaultAnnotationPresentation, createDetectionPickKey, createEditableAnnotationFrameSession, createIdMaskFrame, createIdleDetectionBufferState, createMemoryColdDetectionFrameStore, createOCSortTracker, createProjectedDetectionFrameSource, createSortTracker, createSourceAwarePresentation, createViewportController, createWritableDetectionFrameSource, decodeCompressedRleCounts, decodeCompressedRleMask, decodeDetectionMaskPayload, deleteAnnotationVertex, detectMaskBorders, detectionFrameOverlapsRange, detectionPostProcessors, distanceToSegment, encodeBinaryMask, encodeBinaryMaskWithBounds, encodeCompressedRleCounts, encodeDetectionMaskPayload, extractMaskContour, extractMaskRectRuns, filterDetectionFramesForRange, findClosestAnnotationSegment, followDetectionPickAcrossFrames, getAnnotationHandles, getBufferedDetectionTimelineFrameSnapshot, getDetectionRect, getPointsRect, haveSameDetectionPickIdentities, includeDefined, isDeflatedBase64DetectionMaskPayload, lightenColor, mediaToScreen, mergeDetectionMasks, mergeDetectionPolygonsByClass, normalizeDetectionClassName, offsetDetection, pickAnnotationHandle, pickDetectionAtPoint, pickDetectionByMaskId, pointInPolygon, polygonArea, polygonToRect, projectDetectionFrame, projectDetectionFrameForTracking, projectDetectionFrames, rasterizePolygonToMask, rasterizeRectToMask, rebaseDetectionPickToFrame, rectArea, rectToPolygon, resolveAnnotationRendererPresentation, resolveAnnotationStyleState, resolveContrastTextColor, resolveDetectionClassColorStyle, resolveEllipseSegmentCount, resolveMarkerGeometry, resolveMaskStyleOpacity, resolveStyleValue, sampleEllipseArc, screenToMedia, selectDetectionFrame, topLeftRectToCenterRect, validateDetectionFrames };
7928
+ export { AnnotationFrameMutationKind, AnnotationGeometryKind, AnnotationGestureStateKind, AnnotationHandleKind, BaseBoxCornerStyle, BaseBoxStyle, BaseFocusStyle, BaseInteractionStyle, BaseKeypointStyle, BaseLabelStyle, BaseMarkerStyle, BaseMaskStyle, BasePolygonStyle, BasePolylineStyle, BoxShape, StrokeAlignment as BoxStrokeAlignment, DEFAULT_DETECTION_CLASS_STYLES, DEFAULT_DETECTION_COLOR_SEQUENCE, DetectionBufferStatus, DetectionFrameRetentionMode, DetectionFrameSelectionMode, DetectionInteractionState, DetectionMaskEncoding, DetectionMaskPayloadFormat, DetectionPickTarget, FocusTargetMode, KeypointMarkerShape, KeypointVisibility, LabelPlacement, LabelVisibilityMode, MAX_ID_MASK_PALETTE_ENTRIES, MAX_ID_MASK_STROKE_WIDTH, MarkerShape, MarkerSizeSpace, MaskRenderMode, MediaErrorKind, MediaInteractionMode, MediaRendererFit, MediaRendererPlaybackState, MediaSessionActivityKind, MediaSessionActivityStatus, MediaSessionMode, MediaSessionStatus, MediaSourceStatus, PlaybackGateReach, RegionRendererComposeMode, RegionRendererCoverageKind, RegionRendererMediaEffectKind, RegionRendererRegionKind, RegionRendererSizeSpace, RegionRendererSourceKind, SUPERVISION_ROBOFLOW_COLOR, ShapeInstructionKind, StrokeAlignment, TrackingGeometry, annotationRendererKinds, annotationRenderers, applyAnnotationHandleDrag, canReuseMaskStyleArtifacts, centerRectToTopLeftRect, computeDetectionMaskRect, computeMaskBounds, containsPoint, convertDetectionBoxToMask, convertDetectionBoxToPolygon, convertDetectionMaskToBox, convertDetectionMaskToPolygon, convertDetectionPolygonToBox, convertDetectionPolygonToMask, copySortedDetectionFrames, createAnnotationEditingEngine, createArrayDetectionFrameSource, createBufferedDetectionTimeline, createByteTrackTracker, createCBIoUTracker, createColdDetectionFrameSource, createCompositeDetectionFrameSource, createDefaultAnnotationPresentation, createDetectionPickKey, createEditableAnnotationFrameSession, createIdMaskFrame, createIdleDetectionBufferState, createMemoryColdDetectionFrameStore, createOCSortTracker, createProjectedDetectionFrameSource, createSortTracker, createSourceAwarePresentation, createViewportController, createWritableDetectionFrameSource, decodeCompressedRleCounts, decodeCompressedRleMask, decodeDetectionMaskPayload, deleteAnnotationVertex, detectMaskBorders, detectionFrameOverlapsRange, detectionPostProcessors, distanceToSegment, encodeBinaryMask, encodeBinaryMaskWithBounds, encodeCompressedRleCounts, encodeDetectionMaskPayload, extractDetectionMaskRectRuns, extractMaskContour, extractMaskRectRuns, filterDetectionFramesForRange, findClosestAnnotationSegment, followDetectionPickAcrossFrames, getAnnotationHandles, getBufferedDetectionTimelineFrameSnapshot, getDetectionRect, getPointsRect, haveSameDetectionPickIdentities, includeDefined, isDeflatedBase64DetectionMaskPayload, lightenColor, mediaToScreen, mergeDetectionMasks, mergeDetectionPolygonsByClass, normalizeDetectionClassName, offsetDetection, pickAnnotationHandle, pickDetectionAtPoint, pickDetectionByMaskId, pointInPolygon, polygonArea, polygonToRect, projectDetectionFrame, projectDetectionFrameForTracking, projectDetectionFrames, rasterizePolygonToMask, rasterizeRectToMask, rebaseDetectionPickToFrame, rectArea, rectToPolygon, resolveAnnotationRendererPresentation, resolveAnnotationRendererStyleFields, resolveAnnotationStyleState, resolveContrastTextColor, resolveDetectionClassColorStyle, resolveEllipseSegmentCount, resolveMarkerGeometry, resolveMaskStyleOpacity, resolveStyleValue, sampleEllipseArc, screenToMedia, selectDetectionFrame, topLeftRectToCenterRect, validateDetectionFrames };
7120
7929
  //# sourceMappingURL=index.js.map