@bubo-squared/gyroview 0.1.0 → 0.3.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.
- package/CHANGELOG.md +60 -0
- package/README.md +21 -5
- package/dist/index.d.ts +24 -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 +15 -4
- 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/three/src/shaders/rawLenses.frag.js +1 -1
- 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 +15 -1
- 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/domain/view/lensTilesView.js +2 -2
- package/dist/packages/core/src/domain/view/magnification.js +1 -1
- package/dist/packages/core/src/domain/view/panoramaView.js +1 -1
- package/dist/packages/core/src/index.js +9 -1
- 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 +1 -0
- package/dist/packages/core/src/shared/math/countAtOrBelow.js +17 -0
- package/dist/packages/player/src/composition/browserPorts.js +38 -5
- package/dist/packages/player/src/composition/buildPipeline.js +15 -3
- package/dist/packages/player/src/composition/openInputs.js +51 -109
- package/dist/packages/player/src/composition/openRecording.js +13 -2
- package/dist/packages/player/src/composition/readInputs.js +111 -0
- package/dist/packages/player/src/controls/ChoiceMenu.js +16 -4
- package/dist/packages/player/src/controls/PictureMenus.js +22 -8
- package/dist/packages/player/src/controls/SeekBar.js +6 -3
- package/dist/packages/player/src/controls/SoundControls.js +2 -0
- package/dist/packages/player/src/controls/TransportButtons.js +2 -5
- package/dist/packages/player/src/controls/Wording.js +11 -9
- package/dist/packages/player/src/controls/controlParts.js +5 -3
- package/dist/packages/player/src/controls/controls.js +1 -1
- package/dist/packages/player/src/controls/controlsBar.js +4 -1
- package/dist/packages/player/src/controls/controlsMarkup.js +36 -21
- package/dist/packages/player/src/controls/icons.js +32 -8
- package/dist/packages/player/src/controls/messages.js +27 -4
- package/dist/packages/player/src/controls/sliderFill.js +16 -0
- package/dist/packages/player/src/element/ElementLoads.js +7 -0
- package/dist/packages/player/src/element/GyroViewElement.js +8 -3
- package/dist/packages/player/src/element/attributeNames.js +12 -1
- package/dist/packages/player/src/element/attributes.js +29 -7
- package/dist/packages/player/src/element/reflectedProperties.js +19 -1
- package/dist/packages/player/src/element/styles.js +1 -1
- package/dist/packages/player/src/inspectRecording.js +1 -1
- package/dist/standalone.js +385 -126
- 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,66 @@
|
|
|
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.0 (2026-09-30)
|
|
7
|
+
|
|
8
|
+
What a page may notice:
|
|
9
|
+
|
|
10
|
+
- A recording named by URL is downloaded in file order as it plays, the picture and the sound
|
|
11
|
+
from the same bytes, each fetched about once (ADR 0029). Over a simulated link a little
|
|
12
|
+
slower than an X5 recording at its highest setting (200 Mbit/s against 210), it starts in
|
|
13
|
+
half the time, fetches under half the bytes, and after a seek shows the target in half the
|
|
14
|
+
time, asking for nothing of the old position.
|
|
15
|
+
|
|
16
|
+
| State | What it downloads |
|
|
17
|
+
| ---------------------- | --------------------------------------------------------------------- |
|
|
18
|
+
| Loaded, never played | What opening and the first frame need; with `preload="none"` no frame |
|
|
19
|
+
| Playing | Up to 10 s or 128 MiB ahead, whichever is less, topped up as it plays |
|
|
20
|
+
| Paused after playing | On up to that budget, then nothing |
|
|
21
|
+
| A seek | The old position's requests end at once; the target first |
|
|
22
|
+
| Starved by the network | Waits until the next 4 s are downloaded, then plays on |
|
|
23
|
+
|
|
24
|
+
- A range that breaks off or brings nothing for 10 s is asked for again from its next byte.
|
|
25
|
+
|
|
26
|
+
New:
|
|
27
|
+
|
|
28
|
+
- The `source-changed` error: the recording at the URL was replaced while it played, as its
|
|
29
|
+
`ETag` tells, or else its `Last-Modified` and size. Exposing `ETag` across origins is
|
|
30
|
+
optional, and now named in the CORS advice.
|
|
31
|
+
|
|
32
|
+
## 0.2.0 (2026-09-29)
|
|
33
|
+
|
|
34
|
+
What a page may notice:
|
|
35
|
+
|
|
36
|
+
- The controls are restyled, and fit the player's own width and the pointer (ADR 0028): a
|
|
37
|
+
shaded bar with a thin seek bar, Stabilization and View buttons that show the icon of the
|
|
38
|
+
choice in effect and open menus of the choices with their icons and a line describing each,
|
|
39
|
+
and on a narrow player menus over the whole player and fewer parts; on touch, 44-pixel
|
|
40
|
+
targets.
|
|
41
|
+
- The Stop button is gone; `stop()` and the S key remain. `labels.stop` goes with it.
|
|
42
|
+
- The theme's defaults follow the new look: `--gyro-view-accent` and `--gyro-view-text` are
|
|
43
|
+
white, `--gyro-view-controls-background` is `rgb(0 0 0 / 60%)` and `--gyro-view-radius` is
|
|
44
|
+
`8px`; `--gyro-view-menu-background` is new. A page's own values keep working.
|
|
45
|
+
|
|
46
|
+
New:
|
|
47
|
+
|
|
48
|
+
- `crossorigin` on `<gyro-view>`, with a media element's keywords: `use-credentials` fetches the
|
|
49
|
+
recording, its byte ranges and the other lens's file of a split pair with the visitor's
|
|
50
|
+
cookies, for recordings kept private behind cookies on another origin. `crossOrigin` reflects
|
|
51
|
+
it, `null` while absent. Changing it reloads a recording named by URL; the iframe embed never
|
|
52
|
+
carries it. Nothing changes for a page that does not set it.
|
|
53
|
+
- `credentials` on a `UrlInput` (`player.load({ main: { url, credentials: 'include' } })`), in
|
|
54
|
+
place of the player's shared `http.requestInit.credentials` for that recording.
|
|
55
|
+
- A `cors` error for a request sent with credentials names what the host must add: the page's
|
|
56
|
+
origin by name, not `*`, and `Access-Control-Allow-Credentials: true`.
|
|
57
|
+
- The equirectangular panorama and the raw lenses zoom up to eight times, from four, for fine
|
|
58
|
+
detail in 5.7K and 8K recordings. The normal view keeps its 30 to 120 degrees.
|
|
59
|
+
- `messages` takes `stabilizationModeDescriptions`, `viewModeDescriptions` and `labels.close`.
|
|
60
|
+
|
|
61
|
+
Fixed:
|
|
62
|
+
|
|
63
|
+
- The raw lenses no longer show a faint grey line along the tiles' edges on Apple GPUs, where a
|
|
64
|
+
player's size put an edge between two pixels the GPU shades together.
|
|
65
|
+
|
|
6
66
|
## 0.1.0 (2026-09-29)
|
|
7
67
|
|
|
8
68
|
The first release:
|
package/README.md
CHANGED
|
@@ -42,10 +42,10 @@ A page without a bundler loads the standalone file, which has Three.js and media
|
|
|
42
42
|
```html
|
|
43
43
|
<script
|
|
44
44
|
type="module"
|
|
45
|
-
src="https://cdn.jsdelivr.net/npm/@bubo-squared/gyroview@0.
|
|
45
|
+
src="https://cdn.jsdelivr.net/npm/@bubo-squared/gyroview@0.3/dist/standalone.js"
|
|
46
46
|
></script>
|
|
47
47
|
<script type="module">
|
|
48
|
-
import { inspectRecording } from 'https://cdn.jsdelivr.net/npm/@bubo-squared/gyroview@0.
|
|
48
|
+
import { inspectRecording } from 'https://cdn.jsdelivr.net/npm/@bubo-squared/gyroview@0.3/dist/standalone.js';
|
|
49
49
|
</script>
|
|
50
50
|
```
|
|
51
51
|
|
|
@@ -116,8 +116,19 @@ player.lookAt(90, 0);
|
|
|
116
116
|
|
|
117
117
|
The canvas keeps no picture once the browser has shown it: a snapshot (`drawImage`, `toBlob`)
|
|
118
118
|
is taken in a `frame` listener, which runs right after each picture is drawn. There are no
|
|
119
|
-
buffered ranges to show,
|
|
120
|
-
|
|
119
|
+
buffered ranges to show yet, and no playback rates other than 1.
|
|
120
|
+
|
|
121
|
+
## How a recording is downloaded
|
|
122
|
+
|
|
123
|
+
A recording named by URL is read in byte ranges, in file order, as it plays: the lenses and
|
|
124
|
+
the sound come from the same bytes, each fetched about once. Until the first play the player
|
|
125
|
+
reads only what opening it and showing the first frame need (no frame with `preload="none"`).
|
|
126
|
+
Playing, it keeps up to 10 s or 128 MiB ahead, whichever is less, shared by the two files of a
|
|
127
|
+
split recording; paused, it reads on to that budget and stops. A seek ends the old position's
|
|
128
|
+
requests at once. When the network falls behind the recording, playback waits until the next
|
|
129
|
+
4 s are downloaded, and plays on in stretches rather than a frame at a time. A range that
|
|
130
|
+
breaks off or stalls for 10 s is asked for again from its next byte, and a recording replaced at
|
|
131
|
+
its URL while it plays fails with `source-changed`.
|
|
121
132
|
|
|
122
133
|
`@bubo-squared/gyroview` also exports `GyroViewError`, the list of its codes
|
|
123
134
|
(`GYRO_VIEW_ERROR_CODES`, with `isGyroViewErrorCode` to check a string against it), and the
|
|
@@ -185,9 +196,14 @@ loads a recording only for the player in view, removing `src` from the others.
|
|
|
185
196
|
Access-Control-Allow-Origin: https://your-site.example
|
|
186
197
|
Access-Control-Allow-Methods: GET, HEAD
|
|
187
198
|
Access-Control-Allow-Headers: Range
|
|
188
|
-
Access-Control-Expose-Headers: Content-Range, Content-Length, Accept-Ranges
|
|
199
|
+
Access-Control-Expose-Headers: Content-Range, Content-Length, Accept-Ranges, ETag
|
|
189
200
|
```
|
|
190
201
|
|
|
202
|
+
Recordings kept behind the visitor's cookies take `crossorigin="use-credentials"` on the
|
|
203
|
+
element, or `credentials: 'include'` on a URL given to `createBrowserPlayer`'s `load`, and a
|
|
204
|
+
host that names the page's origin, never `*`, and adds
|
|
205
|
+
`Access-Control-Allow-Credentials: true`.
|
|
206
|
+
|
|
191
207
|
- **A hardware HEVC decoder.** 5.7K plays on recent laptops and phones; 8K needs a Level 6
|
|
192
208
|
decoder (Apple Silicon, recent NVIDIA and Intel). A recording the browser cannot decode fails
|
|
193
209
|
with the `codec-unsupported` error.
|
package/dist/index.d.ts
CHANGED
|
@@ -3,6 +3,14 @@
|
|
|
3
3
|
*/
|
|
4
4
|
export interface UrlInput {
|
|
5
5
|
readonly url: string;
|
|
6
|
+
/**
|
|
7
|
+
* Whether every request for the recording carries the visitor's cookies, in fetch's terms:
|
|
8
|
+
* `include` sends them to any origin, which must then allow the page's origin by name and
|
|
9
|
+
* with `Access-Control-Allow-Credentials: true`. Unset, the player's shared request settings
|
|
10
|
+
* decide, and fetch's `same-origin` when they do not. The element's
|
|
11
|
+
* `crossorigin="use-credentials"` sets `include`.
|
|
12
|
+
*/
|
|
13
|
+
readonly credentials?: RequestCredentials | undefined;
|
|
6
14
|
}
|
|
7
15
|
/**
|
|
8
16
|
* A recording already in the browser: a `File` from a picker or a drop.
|
|
@@ -276,6 +284,7 @@ export declare const GYRO_VIEW_ERROR_CODES: readonly [
|
|
|
276
284
|
"playback-blocked",
|
|
277
285
|
"range-unsupported",
|
|
278
286
|
"render-unavailable",
|
|
287
|
+
"source-changed",
|
|
279
288
|
"source-truncated",
|
|
280
289
|
"source-unreadable",
|
|
281
290
|
"unsupported-calibration",
|
|
@@ -592,7 +601,6 @@ export interface GyroViewLabels {
|
|
|
592
601
|
readonly loading: string;
|
|
593
602
|
readonly play: string;
|
|
594
603
|
readonly pause: string;
|
|
595
|
-
readonly stop: string;
|
|
596
604
|
readonly seek: string;
|
|
597
605
|
/**
|
|
598
606
|
* How the seek bar tells assistive technology where it is: `{time}` and `{duration}` are
|
|
@@ -605,6 +613,10 @@ export interface GyroViewLabels {
|
|
|
605
613
|
readonly stabilization: string;
|
|
606
614
|
readonly viewMode: string;
|
|
607
615
|
readonly fullscreen: string;
|
|
616
|
+
/**
|
|
617
|
+
* The button that closes a menu shown over the whole player, as it is on a narrow one.
|
|
618
|
+
*/
|
|
619
|
+
readonly close: string;
|
|
608
620
|
}
|
|
609
621
|
type LabelName = keyof GyroViewLabels;
|
|
610
622
|
/**
|
|
@@ -614,7 +626,15 @@ type LabelName = keyof GyroViewLabels;
|
|
|
614
626
|
export interface GyroViewMessages {
|
|
615
627
|
readonly labels: GyroViewLabels;
|
|
616
628
|
readonly stabilizationModes: Readonly<Record<StabilizationMode, string>>;
|
|
629
|
+
/**
|
|
630
|
+
* One line under each choice of the Stabilization menu, where there is room for it.
|
|
631
|
+
*/
|
|
632
|
+
readonly stabilizationModeDescriptions: Readonly<Record<StabilizationMode, string>>;
|
|
617
633
|
readonly viewModes: Readonly<Record<ViewMode, string>>;
|
|
634
|
+
/**
|
|
635
|
+
* One line under each choice of the View menu, where there is room for it.
|
|
636
|
+
*/
|
|
637
|
+
readonly viewModeDescriptions: Readonly<Record<ViewMode, string>>;
|
|
618
638
|
/**
|
|
619
639
|
* What a visitor reads when a recording cannot play, by the failure's code. The `error`
|
|
620
640
|
* event carries the developer's account of it.
|
|
@@ -648,7 +668,7 @@ declare class Wording {
|
|
|
648
668
|
write(root: ParentNode): void;
|
|
649
669
|
private writeLabel;
|
|
650
670
|
private writeText;
|
|
651
|
-
private
|
|
671
|
+
private writeChoiceText;
|
|
652
672
|
private writeError;
|
|
653
673
|
}
|
|
654
674
|
interface ControlsHost {
|
|
@@ -779,6 +799,7 @@ export declare class GyroViewElement extends TypedEventElement implements LiveSe
|
|
|
779
799
|
src: string | null;
|
|
780
800
|
src2: string | null;
|
|
781
801
|
poster: string | null;
|
|
802
|
+
crossOrigin: "anonymous" | "use-credentials" | null;
|
|
782
803
|
preload: "none" | "auto";
|
|
783
804
|
gainMatch: "on" | "off";
|
|
784
805
|
autoplay: boolean;
|
|
@@ -929,6 +950,7 @@ export declare class GyroViewElement extends TypedEventElement implements LiveSe
|
|
|
929
950
|
export interface GyroViewAttributes {
|
|
930
951
|
readonly src?: string | undefined;
|
|
931
952
|
readonly src2?: string | undefined;
|
|
953
|
+
readonly crossorigin?: "anonymous" | "use-credentials" | "" | undefined;
|
|
932
954
|
readonly poster?: string | undefined;
|
|
933
955
|
readonly preload?: "none" | "auto" | undefined;
|
|
934
956
|
readonly "gain-match"?: "on" | "off" | undefined;
|
|
@@ -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 };
|