@bubo-squared/gyroview 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +35 -15
  3. package/dist/index.d.ts +21 -2
  4. package/dist/index.js +2 -2
  5. package/dist/packages/adapters/fetch/src/HttpByteStream.js +174 -0
  6. package/dist/packages/adapters/fetch/src/HttpRangeSource.js +14 -170
  7. package/dist/packages/adapters/fetch/src/HttpResource.js +192 -0
  8. package/dist/packages/adapters/fetch/src/contentRange.js +15 -0
  9. package/dist/packages/adapters/fetch/src/httpRequest.js +5 -3
  10. package/dist/packages/adapters/fetch/src/index.js +3 -1
  11. package/dist/packages/adapters/fetch/src/passingFailures.js +24 -0
  12. package/dist/packages/adapters/fetch/src/rangeFailures.js +17 -0
  13. package/dist/packages/adapters/fetch/src/recordingVersion.js +37 -0
  14. package/dist/packages/adapters/mediabunny/src/MediabunnyAudioPackager.js +129 -0
  15. package/dist/packages/adapters/mediabunny/src/MediabunnyCodecReader.js +80 -0
  16. package/dist/packages/adapters/mediabunny/src/SegmentChannel.js +58 -6
  17. package/dist/packages/adapters/mediabunny/src/decoderConfigurations.js +28 -0
  18. package/dist/packages/adapters/mediabunny/src/index.js +3 -2
  19. package/dist/packages/adapters/webcodecs/src/WebCodecsVideoDecoderPort.js +12 -3
  20. package/dist/packages/core/src/application/download/BlockStore.js +97 -0
  21. package/dist/packages/core/src/application/download/DownloadedAudioSamples.js +50 -0
  22. package/dist/packages/core/src/application/download/DownloadedVideoTrack.js +122 -0
  23. package/dist/packages/core/src/application/download/FileDownload.js +183 -0
  24. package/dist/packages/core/src/application/download/RecordingBuffer.js +22 -0
  25. package/dist/packages/core/src/application/download/SampleCursor.js +88 -0
  26. package/dist/packages/core/src/application/download/SourceByteStream.js +46 -0
  27. package/dist/packages/core/src/application/download/Transfers.js +75 -0
  28. package/dist/packages/core/src/application/download/startFileDownload.js +37 -0
  29. package/dist/packages/core/src/application/playback/Buffering.js +53 -0
  30. package/dist/packages/core/src/application/playback/DecodePipeline.js +11 -7
  31. package/dist/packages/core/src/application/playback/DecodeRun.js +15 -0
  32. package/dist/packages/core/src/application/playback/PlaybackSession.js +18 -27
  33. package/dist/packages/core/src/application/playback/keyframeTimeAt.js +1 -1
  34. package/dist/packages/core/src/application/playback/probeDecoding.js +17 -3
  35. package/dist/packages/core/src/application/recording/readSampleTable.js +27 -0
  36. package/dist/packages/core/src/application/recording/timeRecording.js +3 -3
  37. package/dist/packages/core/src/domain/container/KeyframeRule.js +7 -0
  38. package/dist/packages/core/src/domain/container/SampleTable.js +29 -0
  39. package/dist/packages/core/src/domain/container/TrackSampleTable.js +96 -0
  40. package/dist/packages/core/src/domain/container/presentationOrder.js +10 -0
  41. package/dist/packages/core/src/domain/download/DownloadPolicy.js +41 -0
  42. package/dist/packages/core/src/domain/download/isReadyToResume.js +27 -0
  43. package/dist/packages/core/src/domain/download/planDownloads.js +175 -0
  44. package/dist/packages/core/src/domain/download/trackNeeds.js +32 -0
  45. package/dist/packages/core/src/domain/format/boxes/boxHeader.js +44 -0
  46. package/dist/packages/core/src/domain/format/boxes/scanBoxes.js +7 -34
  47. package/dist/packages/core/src/domain/format/mp4/keyframeRules.js +81 -0
  48. package/dist/packages/core/src/domain/format/mp4/movieBoxes.js +52 -0
  49. package/dist/packages/core/src/domain/format/mp4/mp4BoxTypes.js +46 -0
  50. package/dist/packages/core/src/domain/format/mp4/mp4Layouts.js +183 -0
  51. package/dist/packages/core/src/domain/format/mp4/parseMovie.js +67 -0
  52. package/dist/packages/core/src/domain/format/mp4/readTable.js +31 -0
  53. package/dist/packages/core/src/domain/format/mp4/sampleLocations.js +69 -0
  54. package/dist/packages/core/src/domain/format/mp4/sampleTiming.js +109 -0
  55. package/dist/packages/core/src/domain/format/mp4/trackHeaders.js +75 -0
  56. package/dist/packages/core/src/index.js +10 -2
  57. package/dist/packages/core/src/shared/async/iteration.js +24 -0
  58. package/dist/packages/core/src/shared/binary/ByteRange.js +12 -0
  59. package/dist/packages/core/src/shared/binary/ByteRangeSet.js +85 -0
  60. package/dist/packages/core/src/shared/binary/ByteReader.js +21 -3
  61. package/dist/packages/core/src/shared/binary/concatenated.js +15 -0
  62. package/dist/packages/core/src/shared/errors/GyroViewError.js +56 -3
  63. package/dist/packages/core/src/shared/math/countAtOrBelow.js +17 -0
  64. package/dist/packages/player/src/composition/browserPorts.js +23 -4
  65. package/dist/packages/player/src/composition/buildPipeline.js +15 -3
  66. package/dist/packages/player/src/composition/openInputs.js +51 -107
  67. package/dist/packages/player/src/composition/readInputs.js +111 -0
  68. package/dist/packages/player/src/controls/messages.js +3 -1
  69. package/dist/packages/player/src/inspectRecording.js +1 -1
  70. package/dist/standalone.js +58 -58
  71. package/package.json +1 -1
  72. package/dist/packages/adapters/mediabunny/src/MediabunnyAudioSegments.js +0 -133
  73. package/dist/packages/adapters/mediabunny/src/MediabunnyDemuxer.js +0 -63
  74. package/dist/packages/adapters/mediabunny/src/MediabunnyVideoTrackReader.js +0 -84
package/CHANGELOG.md CHANGED
@@ -3,6 +3,48 @@
3
3
  What changed for a page using the package, newest first. Until 1.0, a minor version may change
4
4
  the API.
5
5
 
6
+ ## 0.3.1 (2026-09-30)
7
+
8
+ New:
9
+
10
+ - Every error has a `category`, derived from its code, that says whose side the failure is on:
11
+ `browser`, `recording`, `source`, `usage` or `internal`; `GYRO_VIEW_ERROR_CATEGORIES` and the
12
+ type `GyroViewErrorCategory` list them. The README's "When a recording cannot play" gives the
13
+ codes of each, and what a `<video>` fallback can and cannot do (ADR 0030).
14
+ - The `webcodecs-unavailable` error: the browser has no WebCodecs, on a page not served over
15
+ HTTPS or in an old browser.
16
+
17
+ What a page may notice:
18
+
19
+ - A page on plain HTTP, or a browser without WebCodecs, hears `webcodecs-unavailable` where it
20
+ heard `codec-unsupported`, whose message blamed the codec.
21
+
22
+ ## 0.3.0 (2026-09-30)
23
+
24
+ What a page may notice:
25
+
26
+ - A recording named by URL is downloaded in file order as it plays, the picture and the sound
27
+ from the same bytes, each fetched about once (ADR 0029). Over a simulated link a little
28
+ slower than an X5 recording at its highest setting (200 Mbit/s against 210), it starts in
29
+ half the time, fetches under half the bytes, and after a seek shows the target in half the
30
+ time, asking for nothing of the old position.
31
+
32
+ | State | What it downloads |
33
+ | ---------------------- | --------------------------------------------------------------------- |
34
+ | Loaded, never played | What opening and the first frame need; with `preload="none"` no frame |
35
+ | Playing | Up to 10 s or 128 MiB ahead, whichever is less, topped up as it plays |
36
+ | Paused after playing | On up to that budget, then nothing |
37
+ | A seek | The old position's requests end at once; the target first |
38
+ | Starved by the network | Waits until the next 4 s are downloaded, then plays on |
39
+
40
+ - A range that breaks off or brings nothing for 10 s is asked for again from its next byte.
41
+
42
+ New:
43
+
44
+ - The `source-changed` error: the recording at the URL was replaced while it played, as its
45
+ `ETag` tells, or else its `Last-Modified` and size. Exposing `ETag` across origins is
46
+ optional, and now named in the CORS advice.
47
+
6
48
  ## 0.2.0 (2026-09-29)
7
49
 
8
50
  What a page may notice:
package/README.md CHANGED
@@ -5,6 +5,9 @@ camera's dual-fisheye file directly, over HTTP byte ranges or from a local file,
5
5
  lenses in hardware with WebCodecs, and stitches and gyro-stabilizes them on the GPU. No Insta360
6
6
  Studio export step.
7
7
 
8
+ **Try it first:** [insv-player.com](https://insv-player.com/) plays `.insv` files with this
9
+ package. Drop your own recording to check that your camera and browser work before you install.
10
+
8
11
  ## Install
9
12
 
10
13
  ```sh
@@ -42,10 +45,10 @@ A page without a bundler loads the standalone file, which has Three.js and media
42
45
  ```html
43
46
  <script
44
47
  type="module"
45
- src="https://cdn.jsdelivr.net/npm/@bubo-squared/gyroview@0.2/dist/standalone.js"
48
+ src="https://cdn.jsdelivr.net/npm/@bubo-squared/gyroview@0.3/dist/standalone.js"
46
49
  ></script>
47
50
  <script type="module">
48
- import { inspectRecording } from 'https://cdn.jsdelivr.net/npm/@bubo-squared/gyroview@0.2/dist/standalone.js';
51
+ import { inspectRecording } from 'https://cdn.jsdelivr.net/npm/@bubo-squared/gyroview@0.3/dist/standalone.js';
49
52
  </script>
50
53
  ```
51
54
 
@@ -116,8 +119,19 @@ player.lookAt(90, 0);
116
119
 
117
120
  The canvas keeps no picture once the browser has shown it: a snapshot (`drawImage`, `toBlob`)
118
121
  is taken in a `frame` listener, which runs right after each picture is drawn. There are no
119
- buffered ranges to show, since the player reads the recording as it plays, and no playback
120
- rates other than 1.
122
+ buffered ranges to show yet, and no playback rates other than 1.
123
+
124
+ ## How a recording is downloaded
125
+
126
+ A recording named by URL is read in byte ranges, in file order, as it plays: the lenses and
127
+ the sound come from the same bytes, each fetched about once. Until the first play the player
128
+ reads only what opening it and showing the first frame need (no frame with `preload="none"`).
129
+ Playing, it keeps up to 10 s or 128 MiB ahead, whichever is less, shared by the two files of a
130
+ split recording; paused, it reads on to that budget and stops. A seek ends the old position's
131
+ requests at once. When the network falls behind the recording, playback waits until the next
132
+ 4 s are downloaded, and plays on in stretches rather than a frame at a time. A range that
133
+ breaks off or stalls for 10 s is asked for again from its next byte, and a recording replaced at
134
+ its URL while it plays fails with `source-changed`.
121
135
 
122
136
  `@bubo-squared/gyroview` also exports `GyroViewError`, the list of its codes
123
137
  (`GYRO_VIEW_ERROR_CODES`, with `isGyroViewErrorCode` to check a string against it), and the
@@ -177,7 +191,8 @@ loads a recording only for the player in view, removing `src` from the others.
177
191
 
178
192
  ## Requirements
179
193
 
180
- - **A secure page.** WebCodecs exists only on `https://` pages, or `http://localhost`.
194
+ - **A secure page.** WebCodecs exists only on `https://` pages, or `http://localhost`; elsewhere
195
+ the player fails with `webcodecs-unavailable`.
181
196
  - **Recordings served in byte ranges.** The server answers `Range` requests with `206`, and
182
197
  sends CORS headers when the recordings live on another origin than the page:
183
198
 
@@ -185,7 +200,7 @@ loads a recording only for the player in view, removing `src` from the others.
185
200
  Access-Control-Allow-Origin: https://your-site.example
186
201
  Access-Control-Allow-Methods: GET, HEAD
187
202
  Access-Control-Allow-Headers: Range
188
- Access-Control-Expose-Headers: Content-Range, Content-Length, Accept-Ranges
203
+ Access-Control-Expose-Headers: Content-Range, Content-Length, Accept-Ranges, ETag
189
204
  ```
190
205
 
191
206
  Recordings kept behind the visitor's cookies take `crossorigin="use-credentials"` on the
@@ -194,17 +209,20 @@ loads a recording only for the player in view, removing `src` from the others.
194
209
  `Access-Control-Allow-Credentials: true`.
195
210
 
196
211
  - **A hardware HEVC decoder.** 5.7K plays on recent laptops and phones; 8K needs a Level 6
197
- decoder (Apple Silicon, recent NVIDIA and Intel). A recording the browser cannot decode fails
198
- with the `codec-unsupported` error.
212
+ decoder (Apple Silicon, recent NVIDIA and Intel). On Linux, Chrome reaches the decoder only
213
+ through VA-API: an Intel or AMD GPU whose driver offers HEVC, not NVIDIA's own driver or a
214
+ virtual machine (`chrome://gpu` lists `Decode hevc main` under Video Acceleration
215
+ Information when it can). H.264 recordings do not need it. A recording the browser cannot
216
+ decode fails with the `codec-unsupported` error.
199
217
 
200
218
  The supported browsers, with the oldest versions that have what the player uses (WebCodecs,
201
219
  WebGL 2, container queries, and on iPhone `ManagedMediaSource` for the sound):
202
220
 
203
- | Browser | From | Notes |
204
- | --------------------- | ---- | -------------------------------------------------------- |
205
- | Chrome, Edge desktop | 107 | HEVC is decoded in hardware from this version on. |
206
- | Safari on macOS | 16.4 | |
207
- | Safari on iPhone/iPad | 17.1 | 16.4 to 17.0 play without sound, with a `warning` event. |
221
+ | Browser | From | Notes |
222
+ | --------------------- | ---- | ----------------------------------------------------------------- |
223
+ | Chrome, Edge desktop | 107 | HEVC is decoded in hardware from this version on (Linux: VA-API). |
224
+ | Safari on macOS | 16.4 | |
225
+ | Safari on iPhone/iPad | 17.1 | 16.4 to 17.0 play without sound, with a `warning` event. |
208
226
 
209
227
  Firefox and Chrome on Android are untested: they play what their decoders accept.
210
228
 
@@ -213,8 +231,10 @@ Until 1.0, a minor version may change the API; [CHANGELOG.md](./CHANGELOG.md) sa
213
231
  ## Reference
214
232
 
215
233
  Every attribute, method, event and keyboard shortcut is in the
216
- [project README](https://github.com/bubo-squared/GyroView#using-the-player); hosting and the
217
- error codes are in [docs/DEPLOYMENT.md](https://github.com/bubo-squared/GyroView/blob/main/docs/DEPLOYMENT.md).
234
+ [project README](https://github.com/bubo-squared/GyroView#using-the-player), and what a failure
235
+ tells a page, with what a `<video>` fallback can do, under
236
+ [When a recording cannot play](https://github.com/bubo-squared/GyroView#when-a-recording-cannot-play);
237
+ hosting and the error codes are in [docs/DEPLOYMENT.md](https://github.com/bubo-squared/GyroView/blob/main/docs/DEPLOYMENT.md).
218
238
 
219
239
  ## License
220
240
 
package/dist/index.d.ts CHANGED
@@ -261,7 +261,7 @@ interface DragDelta {
261
261
  export type ViewMode = "raw-lenses" | "equirectangular" | "normal";
262
262
  export declare const VIEW_MODES: readonly ViewMode[];
263
263
  /**
264
- * Stable machine-readable failure categories. Embedders switch on these; messages are for humans.
264
+ * Stable machine-readable failure codes. Embedders switch on these; messages are for humans.
265
265
  */
266
266
  export declare const GYRO_VIEW_ERROR_CODES: readonly [
267
267
  "binary-out-of-bounds",
@@ -284,18 +284,37 @@ export declare const GYRO_VIEW_ERROR_CODES: readonly [
284
284
  "playback-blocked",
285
285
  "range-unsupported",
286
286
  "render-unavailable",
287
+ "source-changed",
287
288
  "source-truncated",
288
289
  "source-unreadable",
289
290
  "unsupported-calibration",
290
291
  "unsupported-container",
291
292
  "unsupported-gyro-record",
292
293
  "unsupported-info-format",
293
- "unsupported-layout"
294
+ "unsupported-layout",
295
+ "webcodecs-unavailable"
294
296
  ];
295
297
  export type GyroViewErrorCode = (typeof GYRO_VIEW_ERROR_CODES)[number];
298
+ /**
299
+ * Whose side a failure is on, for embedders that handle failures by kind: the browser cannot
300
+ * decode or draw the recording, the file is not one the player can play, its bytes could not be
301
+ * read, the page misused the API, or the player failed in a way it did not expect.
302
+ */
303
+ export declare const GYRO_VIEW_ERROR_CATEGORIES: readonly [
304
+ "browser",
305
+ "recording",
306
+ "source",
307
+ "usage",
308
+ "internal"
309
+ ];
310
+ export type GyroViewErrorCategory = (typeof GYRO_VIEW_ERROR_CATEGORIES)[number];
296
311
  export declare function isGyroViewErrorCode(value: unknown): value is GyroViewErrorCode;
297
312
  export declare class GyroViewError extends Error {
298
313
  readonly code: GyroViewErrorCode;
314
+ /**
315
+ * Its own property rather than a getter, so a serialized or logged copy of the error keeps it.
316
+ */
317
+ readonly category: GyroViewErrorCategory;
299
318
  constructor(code: GyroViewErrorCode, message: string, options?: ErrorOptions);
300
319
  }
301
320
  export declare function hasErrorCode(error: unknown, code: GyroViewErrorCode): boolean;
package/dist/index.js CHANGED
@@ -1,4 +1,4 @@
1
- import { GYRO_VIEW_ERROR_CODES, GyroViewError, hasErrorCode, isGyroViewErrorCode } from "./packages/core/src/shared/errors/GyroViewError.js";
1
+ import { GYRO_VIEW_ERROR_CATEGORIES, GYRO_VIEW_ERROR_CODES, GyroViewError, hasErrorCode, isGyroViewErrorCode } from "./packages/core/src/shared/errors/GyroViewError.js";
2
2
  import { STABILIZATION_MODES } from "./packages/core/src/domain/motion/stabilization/Stabilizer.js";
3
3
  import { PICTURE_QUALITIES } from "./packages/core/src/domain/view/PictureQuality.js";
4
4
  import { VIEW_MODES } from "./packages/core/src/domain/view/ViewMode.js";
@@ -10,4 +10,4 @@ import { inspectRecording } from "./packages/player/src/inspectRecording.js";
10
10
  import { GyroViewElement } from "./packages/player/src/element/GyroViewElement.js";
11
11
  import { GYRO_VIEW_TAG, defineGyroView } from "./packages/player/src/element/defineGyroView.js";
12
12
  import "./packages/player/src/index.js";
13
- export { GYRO_VIEW_ERROR_CODES, GYRO_VIEW_TAG, GyroViewElement, GyroViewError, PICTURE_QUALITIES, Player, STABILIZATION_MODES, VIEW_MODES, attachKeyboard, attachViewGestures, createBrowserPlayer, defineGyroView, hasErrorCode, inspectRecording, isGyroViewErrorCode };
13
+ export { GYRO_VIEW_ERROR_CATEGORIES, GYRO_VIEW_ERROR_CODES, GYRO_VIEW_TAG, GyroViewElement, GyroViewError, PICTURE_QUALITIES, Player, STABILIZATION_MODES, VIEW_MODES, attachKeyboard, attachViewGestures, createBrowserPlayer, defineGyroView, hasErrorCode, inspectRecording, isGyroViewErrorCode };
@@ -0,0 +1,174 @@
1
+ import { GyroViewError, isAbortError } from "../../../core/src/shared/errors/GyroViewError.js";
2
+ import { ByteRange } from "../../../core/src/shared/binary/ByteRange.js";
3
+ import { Ending, ITERATION_END } from "../../../core/src/shared/async/iteration.js";
4
+ import "../../../core/src/index.js";
5
+ import { contentRangeOf } from "./contentRange.js";
6
+ import { passing } from "./passingFailures.js";
7
+ import { brokenOff, truncated } from "./rangeFailures.js";
8
+ //#region ../../packages/adapters/fetch/src/HttpByteStream.ts
9
+ /**
10
+ * Long enough for a slow mobile network to deliver its next packet, short enough that playback
11
+ * waiting on a dead connection recovers well before the viewer gives up.
12
+ */
13
+ var DEFAULT_STALL_TIMEOUT_MS = 1e4;
14
+ /**
15
+ * ByteStream over HTTP: a range is one request, its body handed on a chunk at a time as it
16
+ * comes, and given up by aborting the request. A body that breaks off or stalls is asked for
17
+ * again from its next byte, as the resource's retry rules allow.
18
+ */
19
+ var HttpByteStream = class {
20
+ resource;
21
+ stallTimeoutMs;
22
+ constructor(resource, options = {}) {
23
+ this.resource = resource;
24
+ this.stallTimeoutMs = options.stallTimeoutMs ?? DEFAULT_STALL_TIMEOUT_MS;
25
+ }
26
+ stream(range) {
27
+ return { [Symbol.asyncIterator]: () => new RangeReading({
28
+ resource: this.resource,
29
+ range,
30
+ stallTimeoutMs: this.stallTimeoutMs
31
+ }) };
32
+ }
33
+ };
34
+ /**
35
+ * One range's requests and bodies. The answer must be the bytes asked for, every one and no
36
+ * more; returning the iteration aborts the request at once, a chunk still awaited coming as the
37
+ * end.
38
+ */
39
+ var RangeReading = class {
40
+ parts;
41
+ attempt;
42
+ received = 0;
43
+ ending = new Ending();
44
+ constructor(parts) {
45
+ this.parts = parts;
46
+ }
47
+ async next() {
48
+ try {
49
+ const chunk = await this.parts.resource.askingAgain(() => this.readChunk());
50
+ if (chunk) return {
51
+ done: false,
52
+ value: chunk
53
+ };
54
+ } catch (error) {
55
+ if (!this.ending.hasEnded()) {
56
+ this.end();
57
+ throw error;
58
+ }
59
+ }
60
+ this.end();
61
+ return ITERATION_END;
62
+ }
63
+ return() {
64
+ this.end();
65
+ return Promise.resolve(ITERATION_END);
66
+ }
67
+ /**
68
+ * The next chunk; none at the range's end. An attempt that fails is dropped, so the next
69
+ * asks anew for the rest of the range.
70
+ */
71
+ async readChunk() {
72
+ const { resource, range } = this.parts;
73
+ await resource.ensureFits(range);
74
+ if (this.ending.hasEnded() || range.length === 0) return void 0;
75
+ this.attempt ??= this.attemptAtTheRest();
76
+ const { attempt } = this;
77
+ try {
78
+ const read = await this.withinStallTimeout(this.readFrom(attempt));
79
+ return this.taken(read);
80
+ } catch (error) {
81
+ this.drop(attempt);
82
+ throw error;
83
+ }
84
+ }
85
+ attemptAtTheRest() {
86
+ const { range } = this.parts;
87
+ const rest = ByteRange.of(range.offset + this.received, range.length - this.received);
88
+ const request = new AbortController();
89
+ return {
90
+ request,
91
+ body: this.bodyOf(rest, request.signal)
92
+ };
93
+ }
94
+ async bodyOf(rest, signal) {
95
+ const response = await this.parts.resource.rangeAnswer(rest, signal);
96
+ this.ensureAnswers(response, rest);
97
+ return response.body?.getReader() ?? new ReadableStream().getReader();
98
+ }
99
+ /**
100
+ * A server may answer other bytes than asked (a cache serving a range it holds); the answer
101
+ * says which in its Content-Range, where the page may read it.
102
+ */
103
+ ensureAnswers(response, rest) {
104
+ const answered = contentRangeOf(response);
105
+ if (!answered) return;
106
+ const asked = `${rest.offset}-${rest.end - 1}`;
107
+ const given = `${answered.first}-${answered.last}`;
108
+ if (given === asked) return;
109
+ throw new GyroViewError("source-unreadable", `${this.parts.resource.url} answered bytes ${given} to a request for bytes ${asked}`);
110
+ }
111
+ async readFrom(attempt) {
112
+ const body = await attempt.body;
113
+ try {
114
+ return await body.read();
115
+ } catch (error) {
116
+ if (isAbortError(error)) throw error;
117
+ throw brokenOff(this.parts.resource.url, this.parts.range, error);
118
+ }
119
+ }
120
+ /**
121
+ * A request that brings no byte for the stall timeout, answer or body, is taken for stalled:
122
+ * a connection a network change left dead never fails by itself.
123
+ */
124
+ async withinStallTimeout(pending) {
125
+ let timer;
126
+ const stalled = new Promise((_, reject) => {
127
+ timer = setTimeout(() => {
128
+ reject(this.stalled());
129
+ }, this.parts.stallTimeoutMs);
130
+ });
131
+ try {
132
+ return await Promise.race([pending, stalled]);
133
+ } finally {
134
+ clearTimeout(timer);
135
+ }
136
+ }
137
+ stalled() {
138
+ const { resource, range, stallTimeoutMs } = this.parts;
139
+ const message = `${resource.url} sent nothing of the ${range.length}-byte range at ${range.offset} for ${stallTimeoutMs} ms`;
140
+ return passing(new GyroViewError("source-unreadable", message));
141
+ }
142
+ taken(read) {
143
+ if (read.done) {
144
+ this.ensureWhole();
145
+ return;
146
+ }
147
+ this.count(read.value);
148
+ return read.value;
149
+ }
150
+ count(chunk) {
151
+ const { resource, range } = this.parts;
152
+ this.received += chunk.byteLength;
153
+ if (this.received <= range.length) return;
154
+ throw new GyroViewError("source-unreadable", `${resource.url} sent more than the ${range.length}-byte range at ${range.offset}`);
155
+ }
156
+ ensureWhole() {
157
+ const { resource, range } = this.parts;
158
+ if (this.received !== range.length) throw truncated(resource.url, this.received, range);
159
+ }
160
+ drop(attempt) {
161
+ attempt.request.abort();
162
+ if (this.attempt === attempt) this.attempt = void 0;
163
+ }
164
+ /**
165
+ * Aborting the request once it is over, however it ended, also lets go of the listeners it
166
+ * put on the resource's own signal.
167
+ */
168
+ end() {
169
+ this.ending.end();
170
+ if (this.attempt) this.drop(this.attempt);
171
+ }
172
+ };
173
+ //#endregion
174
+ export { HttpByteStream };
@@ -1,194 +1,38 @@
1
- import { GyroViewError, hasErrorCode, isAbortError } from "../../../core/src/shared/errors/GyroViewError.js";
1
+ import { isAbortError } from "../../../core/src/shared/errors/GyroViewError.js";
2
2
  import "../../../core/src/index.js";
3
- import { EXPOSED_HEADERS_ADVICE, FIRST_BYTE_RANGE, discardBody, httpRequest, plainHttpRequest, withAbortSignal } from "./httpRequest.js";
3
+ import { brokenOff, truncated } from "./rangeFailures.js";
4
4
  //#region ../../packages/adapters/fetch/src/HttpRangeSource.ts
5
- var HTTP_OK = 200;
6
- var HTTP_PARTIAL_CONTENT = 206;
7
- var HTTP_SERVER_ERROR = 500;
8
- var DEFAULT_RETRY_DELAYS_MS = [250, 1e3];
9
5
  /**
10
- * The failures on the way, which asking again may cure; any other fails the read at once. They
11
- * are `source-unreadable` errors like any other, so a caller that gets one after the retries
12
- * cannot tell, and need not: only the retry loop asks.
13
- */
14
- var passingFailures = /* @__PURE__ */ new WeakSet();
15
- function passing(failure) {
16
- passingFailures.add(failure);
17
- return failure;
18
- }
19
- var CONTENT_RANGE_TOTAL = /\/(\d+)$/u;
20
- /**
21
- * RandomAccessSource over HTTP. The server must answer `Range` requests with 206 and, for
22
- * cross-origin use, send CORS headers that expose `Content-Range`; both are hard requirements
23
- * of playing a remote recording and are reported with distinct error codes.
6
+ * RandomAccessSource over HTTP: each range read whole, in one request, asked for again when it
7
+ * fails on the way. It shares its resource with the recording's byte stream, so both know one
8
+ * size, one proof of CORS and one version.
24
9
  */
25
10
  var HttpRangeSource = class {
26
- url;
27
- options;
28
- retryDelaysMs;
29
- sizePromise;
30
- /**
31
- * A range has come through: CORS is proven for this source, so a request that fails from here
32
- * on failed on the way, whatever the diagnosis says of an error page without CORS headers.
33
- */
34
- hasReadRange = false;
35
- /**
36
- * `signal` ends every request the source makes, with the host's own `requestInit` signal.
37
- */
38
- constructor(url, options = {}, signal) {
39
- this.url = url;
40
- this.options = signal ? withAbortSignal(options, signal) : options;
41
- this.retryDelaysMs = options.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS;
11
+ resource;
12
+ constructor(resource) {
13
+ this.resource = resource;
42
14
  }
43
- /**
44
- * One HEAD request (or a one-byte range where HEAD is refused or gives no length), cached for
45
- * the lifetime of the source once it succeeded; a failed lookup is retried on the next call.
46
- */
47
15
  size() {
48
- this.sizePromise ??= this.rememberSize();
49
- return this.sizePromise;
16
+ return this.resource.size();
50
17
  }
51
18
  async read(range) {
52
- await this.ensureFits(range);
53
- return range.length === 0 ? /* @__PURE__ */ new Uint8Array() : this.readAskingAgain(range);
54
- }
55
- /**
56
- * A range that failed on the way is asked for again after a short wait, a few times: one
57
- * dropped connection must not end a long playback. What asking again cannot change (an abort,
58
- * a refusal, a server ignoring ranges, CORS) fails at once.
59
- */
60
- async readAskingAgain(range) {
61
- for (const delayMs of this.retryDelaysMs) {
62
- try {
63
- return await this.readOnce(range);
64
- } catch (error) {
65
- if (!(error instanceof GyroViewError && passingFailures.has(error))) throw error;
66
- }
67
- await wait(delayMs, this.options.requestInit?.signal);
68
- }
69
- return this.readOnce(range);
19
+ await this.resource.ensureFits(range);
20
+ return range.length === 0 ? /* @__PURE__ */ new Uint8Array() : this.resource.askingAgain(() => this.readOnce(range));
70
21
  }
71
22
  async readOnce(range) {
72
- const response = await this.requestRange(range);
73
- if (response.status !== HTTP_PARTIAL_CONTENT) {
74
- discardBody(response);
75
- throw this.refusalOf(response.status);
76
- }
77
- this.hasReadRange = true;
23
+ const response = await this.resource.rangeAnswer(range);
78
24
  const bytes = await this.bodyOf(response, range);
79
- if (bytes.byteLength !== range.length) throw new GyroViewError("source-truncated", `${this.url} returned ${bytes.byteLength} bytes for a ${range.length}-byte range at ${range.offset}`);
25
+ if (bytes.byteLength !== range.length) throw truncated(this.resource.url, bytes.byteLength, range);
80
26
  return bytes;
81
27
  }
82
- /**
83
- * A request that did not get through for want of a network is worth another try; one CORS
84
- * refused before any range came through is not.
85
- */
86
- async requestRange(range) {
87
- const headers = { Range: `bytes=${range.offset}-${range.end - 1}` };
88
- try {
89
- return this.hasReadRange ? await plainHttpRequest(this.url, {
90
- method: "GET",
91
- headers
92
- }, this.options) : await this.request("GET", headers);
93
- } catch (error) {
94
- throw this.hasReadRange ? this.passingOnceAnswered(error) : passingIfUnreachable(error);
95
- }
96
- }
97
- passingOnceAnswered(error) {
98
- const message = `${this.url} could not be reached for a byte range`;
99
- return isAbortError(error) ? error : passing(new GyroViewError("source-unreadable", message, { cause: error }));
100
- }
101
- refusalOf(status) {
102
- if (status === HTTP_OK) return new GyroViewError("range-unsupported", `${this.url} ignores Range requests (answered 200 to a byte range)`);
103
- const message = `${this.url} answered ${status} to a byte range`;
104
- return status >= HTTP_SERVER_ERROR ? passing(new GyroViewError("source-unreadable", message)) : new GyroViewError("source-unreadable", message);
105
- }
106
- /**
107
- * The bytes of a range, which a dropped connection can break off after the headers: the most
108
- * common way a network fails during long playback, so it is `source-unreadable` as a refused
109
- * request is, not a bug or a bad file, and worth another try.
110
- */
111
28
  async bodyOf(response, range) {
112
29
  try {
113
30
  return new Uint8Array(await response.arrayBuffer());
114
31
  } catch (error) {
115
32
  if (isAbortError(error)) throw error;
116
- throw passing(new GyroViewError("source-unreadable", `${this.url} broke off the ${range.length}-byte range at ${range.offset}`, { cause: error }));
33
+ throw brokenOff(this.resource.url, range, error);
117
34
  }
118
35
  }
119
- /**
120
- * Servers clamp out-of-range requests instead of failing them; the port contract wants a
121
- * typed error, so the range is checked against the (cached) size first.
122
- */
123
- async ensureFits(range) {
124
- range.ensureWithin(await this.size(), `resource ${this.url}`);
125
- }
126
- async rememberSize() {
127
- try {
128
- return await this.fetchSize();
129
- } catch (error) {
130
- this.sizePromise = void 0;
131
- throw error;
132
- }
133
- }
134
- async fetchSize() {
135
- const response = await this.request("HEAD");
136
- discardBody(response);
137
- if (!response.ok) return this.sizeFromContentRange();
138
- const contentLength = Number(response.headers.get("content-length"));
139
- return Number.isSafeInteger(contentLength) && contentLength > 0 ? contentLength : this.sizeFromContentRange();
140
- }
141
- /**
142
- * Some servers omit Content-Length on HEAD or refuse HEAD; a one-byte range reveals the total.
143
- */
144
- async sizeFromContentRange() {
145
- const response = await this.request("GET", { Range: FIRST_BYTE_RANGE });
146
- discardBody(response);
147
- if (response.status !== HTTP_PARTIAL_CONTENT) throw new GyroViewError("source-unreadable", `${this.url} answered ${response.status} to a byte range; cannot determine the file size`);
148
- this.hasReadRange = true;
149
- const contentRange = response.headers.get("content-range");
150
- if (contentRange === null && response.type === "cors") throw this.hiddenContentRange();
151
- const total = CONTENT_RANGE_TOTAL.exec(contentRange ?? "")?.[1];
152
- if (total === void 0) throw new GyroViewError("source-unreadable", `${this.url} reports neither Content-Length nor Content-Range; cannot determine the file size`);
153
- return Number(total);
154
- }
155
- hiddenContentRange() {
156
- return new GyroViewError("cors", `${this.url} answered a byte range but hides its Content-Range from this origin; add ${EXPOSED_HEADERS_ADVICE}`);
157
- }
158
- request(method, headers) {
159
- return httpRequest(this.url, {
160
- method,
161
- ...headers && { headers }
162
- }, this.options);
163
- }
164
36
  };
165
- /**
166
- * A pause before asking again, which an abort of the source's requests ends at once.
167
- */
168
- function wait(ms, signal) {
169
- return new Promise((resolve, reject) => {
170
- signal?.throwIfAborted();
171
- const settled = new AbortController();
172
- const timer = setTimeout(() => {
173
- settled.abort();
174
- resolve();
175
- }, ms);
176
- const onAbort = () => {
177
- clearTimeout(timer);
178
- reject(abortErrorOf(signal));
179
- };
180
- signal?.addEventListener("abort", onAbort, {
181
- once: true,
182
- signal: settled.signal
183
- });
184
- });
185
- }
186
- function passingIfUnreachable(error) {
187
- return hasErrorCode(error, "source-unreadable") && error instanceof Error ? passing(new GyroViewError("source-unreadable", error.message, { cause: error.cause })) : error;
188
- }
189
- function abortErrorOf(signal) {
190
- const reason = signal?.reason;
191
- return reason instanceof Error ? reason : new DOMException("the read was aborted", "AbortError");
192
- }
193
37
  //#endregion
194
38
  export { HttpRangeSource };