@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.
- package/CHANGELOG.md +42 -0
- package/README.md +35 -15
- package/dist/index.d.ts +21 -2
- package/dist/index.js +2 -2
- package/dist/packages/adapters/fetch/src/HttpByteStream.js +174 -0
- package/dist/packages/adapters/fetch/src/HttpRangeSource.js +14 -170
- package/dist/packages/adapters/fetch/src/HttpResource.js +192 -0
- package/dist/packages/adapters/fetch/src/contentRange.js +15 -0
- package/dist/packages/adapters/fetch/src/httpRequest.js +5 -3
- package/dist/packages/adapters/fetch/src/index.js +3 -1
- package/dist/packages/adapters/fetch/src/passingFailures.js +24 -0
- package/dist/packages/adapters/fetch/src/rangeFailures.js +17 -0
- package/dist/packages/adapters/fetch/src/recordingVersion.js +37 -0
- package/dist/packages/adapters/mediabunny/src/MediabunnyAudioPackager.js +129 -0
- package/dist/packages/adapters/mediabunny/src/MediabunnyCodecReader.js +80 -0
- package/dist/packages/adapters/mediabunny/src/SegmentChannel.js +58 -6
- package/dist/packages/adapters/mediabunny/src/decoderConfigurations.js +28 -0
- package/dist/packages/adapters/mediabunny/src/index.js +3 -2
- package/dist/packages/adapters/webcodecs/src/WebCodecsVideoDecoderPort.js +12 -3
- package/dist/packages/core/src/application/download/BlockStore.js +97 -0
- package/dist/packages/core/src/application/download/DownloadedAudioSamples.js +50 -0
- package/dist/packages/core/src/application/download/DownloadedVideoTrack.js +122 -0
- package/dist/packages/core/src/application/download/FileDownload.js +183 -0
- package/dist/packages/core/src/application/download/RecordingBuffer.js +22 -0
- package/dist/packages/core/src/application/download/SampleCursor.js +88 -0
- package/dist/packages/core/src/application/download/SourceByteStream.js +46 -0
- package/dist/packages/core/src/application/download/Transfers.js +75 -0
- package/dist/packages/core/src/application/download/startFileDownload.js +37 -0
- package/dist/packages/core/src/application/playback/Buffering.js +53 -0
- package/dist/packages/core/src/application/playback/DecodePipeline.js +11 -7
- package/dist/packages/core/src/application/playback/DecodeRun.js +15 -0
- package/dist/packages/core/src/application/playback/PlaybackSession.js +18 -27
- package/dist/packages/core/src/application/playback/keyframeTimeAt.js +1 -1
- package/dist/packages/core/src/application/playback/probeDecoding.js +17 -3
- package/dist/packages/core/src/application/recording/readSampleTable.js +27 -0
- package/dist/packages/core/src/application/recording/timeRecording.js +3 -3
- package/dist/packages/core/src/domain/container/KeyframeRule.js +7 -0
- package/dist/packages/core/src/domain/container/SampleTable.js +29 -0
- package/dist/packages/core/src/domain/container/TrackSampleTable.js +96 -0
- package/dist/packages/core/src/domain/container/presentationOrder.js +10 -0
- package/dist/packages/core/src/domain/download/DownloadPolicy.js +41 -0
- package/dist/packages/core/src/domain/download/isReadyToResume.js +27 -0
- package/dist/packages/core/src/domain/download/planDownloads.js +175 -0
- package/dist/packages/core/src/domain/download/trackNeeds.js +32 -0
- package/dist/packages/core/src/domain/format/boxes/boxHeader.js +44 -0
- package/dist/packages/core/src/domain/format/boxes/scanBoxes.js +7 -34
- package/dist/packages/core/src/domain/format/mp4/keyframeRules.js +81 -0
- package/dist/packages/core/src/domain/format/mp4/movieBoxes.js +52 -0
- package/dist/packages/core/src/domain/format/mp4/mp4BoxTypes.js +46 -0
- package/dist/packages/core/src/domain/format/mp4/mp4Layouts.js +183 -0
- package/dist/packages/core/src/domain/format/mp4/parseMovie.js +67 -0
- package/dist/packages/core/src/domain/format/mp4/readTable.js +31 -0
- package/dist/packages/core/src/domain/format/mp4/sampleLocations.js +69 -0
- package/dist/packages/core/src/domain/format/mp4/sampleTiming.js +109 -0
- package/dist/packages/core/src/domain/format/mp4/trackHeaders.js +75 -0
- package/dist/packages/core/src/index.js +10 -2
- package/dist/packages/core/src/shared/async/iteration.js +24 -0
- package/dist/packages/core/src/shared/binary/ByteRange.js +12 -0
- package/dist/packages/core/src/shared/binary/ByteRangeSet.js +85 -0
- package/dist/packages/core/src/shared/binary/ByteReader.js +21 -3
- package/dist/packages/core/src/shared/binary/concatenated.js +15 -0
- package/dist/packages/core/src/shared/errors/GyroViewError.js +56 -3
- package/dist/packages/core/src/shared/math/countAtOrBelow.js +17 -0
- package/dist/packages/player/src/composition/browserPorts.js +23 -4
- package/dist/packages/player/src/composition/buildPipeline.js +15 -3
- package/dist/packages/player/src/composition/openInputs.js +51 -107
- package/dist/packages/player/src/composition/readInputs.js +111 -0
- package/dist/packages/player/src/controls/messages.js +3 -1
- package/dist/packages/player/src/inspectRecording.js +1 -1
- package/dist/standalone.js +58 -58
- package/package.json +1 -1
- package/dist/packages/adapters/mediabunny/src/MediabunnyAudioSegments.js +0 -133
- package/dist/packages/adapters/mediabunny/src/MediabunnyDemuxer.js +0 -63
- 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.
|
|
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.
|
|
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,
|
|
120
|
-
|
|
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).
|
|
198
|
-
|
|
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)
|
|
217
|
-
|
|
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
|
|
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 {
|
|
1
|
+
import { isAbortError } from "../../../core/src/shared/errors/GyroViewError.js";
|
|
2
2
|
import "../../../core/src/index.js";
|
|
3
|
-
import {
|
|
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
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
|
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
|
|
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 };
|