@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.
Files changed (95) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/README.md +21 -5
  3. package/dist/index.d.ts +24 -2
  4. package/dist/packages/adapters/fetch/src/HttpByteStream.js +174 -0
  5. package/dist/packages/adapters/fetch/src/HttpRangeSource.js +14 -170
  6. package/dist/packages/adapters/fetch/src/HttpResource.js +192 -0
  7. package/dist/packages/adapters/fetch/src/contentRange.js +15 -0
  8. package/dist/packages/adapters/fetch/src/httpRequest.js +15 -4
  9. package/dist/packages/adapters/fetch/src/index.js +3 -1
  10. package/dist/packages/adapters/fetch/src/passingFailures.js +24 -0
  11. package/dist/packages/adapters/fetch/src/rangeFailures.js +17 -0
  12. package/dist/packages/adapters/fetch/src/recordingVersion.js +37 -0
  13. package/dist/packages/adapters/mediabunny/src/MediabunnyAudioPackager.js +129 -0
  14. package/dist/packages/adapters/mediabunny/src/MediabunnyCodecReader.js +80 -0
  15. package/dist/packages/adapters/mediabunny/src/SegmentChannel.js +58 -6
  16. package/dist/packages/adapters/mediabunny/src/decoderConfigurations.js +28 -0
  17. package/dist/packages/adapters/mediabunny/src/index.js +3 -2
  18. package/dist/packages/adapters/three/src/shaders/rawLenses.frag.js +1 -1
  19. package/dist/packages/core/src/application/download/BlockStore.js +97 -0
  20. package/dist/packages/core/src/application/download/DownloadedAudioSamples.js +50 -0
  21. package/dist/packages/core/src/application/download/DownloadedVideoTrack.js +122 -0
  22. package/dist/packages/core/src/application/download/FileDownload.js +183 -0
  23. package/dist/packages/core/src/application/download/RecordingBuffer.js +22 -0
  24. package/dist/packages/core/src/application/download/SampleCursor.js +88 -0
  25. package/dist/packages/core/src/application/download/SourceByteStream.js +46 -0
  26. package/dist/packages/core/src/application/download/Transfers.js +75 -0
  27. package/dist/packages/core/src/application/download/startFileDownload.js +37 -0
  28. package/dist/packages/core/src/application/playback/Buffering.js +53 -0
  29. package/dist/packages/core/src/application/playback/DecodePipeline.js +11 -7
  30. package/dist/packages/core/src/application/playback/DecodeRun.js +15 -0
  31. package/dist/packages/core/src/application/playback/PlaybackSession.js +18 -27
  32. package/dist/packages/core/src/application/playback/keyframeTimeAt.js +1 -1
  33. package/dist/packages/core/src/application/playback/probeDecoding.js +15 -1
  34. package/dist/packages/core/src/application/recording/readSampleTable.js +27 -0
  35. package/dist/packages/core/src/application/recording/timeRecording.js +3 -3
  36. package/dist/packages/core/src/domain/container/KeyframeRule.js +7 -0
  37. package/dist/packages/core/src/domain/container/SampleTable.js +29 -0
  38. package/dist/packages/core/src/domain/container/TrackSampleTable.js +96 -0
  39. package/dist/packages/core/src/domain/container/presentationOrder.js +10 -0
  40. package/dist/packages/core/src/domain/download/DownloadPolicy.js +41 -0
  41. package/dist/packages/core/src/domain/download/isReadyToResume.js +27 -0
  42. package/dist/packages/core/src/domain/download/planDownloads.js +175 -0
  43. package/dist/packages/core/src/domain/download/trackNeeds.js +32 -0
  44. package/dist/packages/core/src/domain/format/boxes/boxHeader.js +44 -0
  45. package/dist/packages/core/src/domain/format/boxes/scanBoxes.js +7 -34
  46. package/dist/packages/core/src/domain/format/mp4/keyframeRules.js +81 -0
  47. package/dist/packages/core/src/domain/format/mp4/movieBoxes.js +52 -0
  48. package/dist/packages/core/src/domain/format/mp4/mp4BoxTypes.js +46 -0
  49. package/dist/packages/core/src/domain/format/mp4/mp4Layouts.js +183 -0
  50. package/dist/packages/core/src/domain/format/mp4/parseMovie.js +67 -0
  51. package/dist/packages/core/src/domain/format/mp4/readTable.js +31 -0
  52. package/dist/packages/core/src/domain/format/mp4/sampleLocations.js +69 -0
  53. package/dist/packages/core/src/domain/format/mp4/sampleTiming.js +109 -0
  54. package/dist/packages/core/src/domain/format/mp4/trackHeaders.js +75 -0
  55. package/dist/packages/core/src/domain/view/lensTilesView.js +2 -2
  56. package/dist/packages/core/src/domain/view/magnification.js +1 -1
  57. package/dist/packages/core/src/domain/view/panoramaView.js +1 -1
  58. package/dist/packages/core/src/index.js +9 -1
  59. package/dist/packages/core/src/shared/async/iteration.js +24 -0
  60. package/dist/packages/core/src/shared/binary/ByteRange.js +12 -0
  61. package/dist/packages/core/src/shared/binary/ByteRangeSet.js +85 -0
  62. package/dist/packages/core/src/shared/binary/ByteReader.js +21 -3
  63. package/dist/packages/core/src/shared/binary/concatenated.js +15 -0
  64. package/dist/packages/core/src/shared/errors/GyroViewError.js +1 -0
  65. package/dist/packages/core/src/shared/math/countAtOrBelow.js +17 -0
  66. package/dist/packages/player/src/composition/browserPorts.js +38 -5
  67. package/dist/packages/player/src/composition/buildPipeline.js +15 -3
  68. package/dist/packages/player/src/composition/openInputs.js +51 -109
  69. package/dist/packages/player/src/composition/openRecording.js +13 -2
  70. package/dist/packages/player/src/composition/readInputs.js +111 -0
  71. package/dist/packages/player/src/controls/ChoiceMenu.js +16 -4
  72. package/dist/packages/player/src/controls/PictureMenus.js +22 -8
  73. package/dist/packages/player/src/controls/SeekBar.js +6 -3
  74. package/dist/packages/player/src/controls/SoundControls.js +2 -0
  75. package/dist/packages/player/src/controls/TransportButtons.js +2 -5
  76. package/dist/packages/player/src/controls/Wording.js +11 -9
  77. package/dist/packages/player/src/controls/controlParts.js +5 -3
  78. package/dist/packages/player/src/controls/controls.js +1 -1
  79. package/dist/packages/player/src/controls/controlsBar.js +4 -1
  80. package/dist/packages/player/src/controls/controlsMarkup.js +36 -21
  81. package/dist/packages/player/src/controls/icons.js +32 -8
  82. package/dist/packages/player/src/controls/messages.js +27 -4
  83. package/dist/packages/player/src/controls/sliderFill.js +16 -0
  84. package/dist/packages/player/src/element/ElementLoads.js +7 -0
  85. package/dist/packages/player/src/element/GyroViewElement.js +8 -3
  86. package/dist/packages/player/src/element/attributeNames.js +12 -1
  87. package/dist/packages/player/src/element/attributes.js +29 -7
  88. package/dist/packages/player/src/element/reflectedProperties.js +19 -1
  89. package/dist/packages/player/src/element/styles.js +1 -1
  90. package/dist/packages/player/src/inspectRecording.js +1 -1
  91. package/dist/standalone.js +385 -126
  92. package/package.json +1 -1
  93. package/dist/packages/adapters/mediabunny/src/MediabunnyAudioSegments.js +0 -133
  94. package/dist/packages/adapters/mediabunny/src/MediabunnyDemuxer.js +0 -63
  95. package/dist/packages/adapters/mediabunny/src/MediabunnyVideoTrackReader.js +0 -84
@@ -0,0 +1,192 @@
1
+ import { GyroViewError, isAbortError } from "../../../core/src/shared/errors/GyroViewError.js";
2
+ import "../../../core/src/index.js";
3
+ import { contentRangeOf } from "./contentRange.js";
4
+ import { isPassing, passing, passingIfUnreachable } from "./passingFailures.js";
5
+ import { EXPOSED_HEADERS_ADVICE, FIRST_BYTE_RANGE, discardBody, httpRequest, plainHttpRequest, withAbortSignal } from "./httpRequest.js";
6
+ import { isSameVersion, versionOf } from "./recordingVersion.js";
7
+ //#region ../../packages/adapters/fetch/src/HttpResource.ts
8
+ var HTTP_OK = 200;
9
+ var HTTP_PARTIAL_CONTENT = 206;
10
+ var HTTP_SERVER_ERROR = 500;
11
+ var DEFAULT_RETRY_DELAYS_MS = [250, 1e3];
12
+ /**
13
+ * One recording at a URL, as every reader of it sees it: its size, whether its server has shown
14
+ * it lets this page read it (CORS), the version its first answer told of (ADR 0029), and how a
15
+ * range that failed on the way is asked for again.
16
+ * The server must answer `Range` requests with 206 and, for cross-origin use, send CORS headers
17
+ * that expose `Content-Range`; both are hard requirements of playing a remote recording and are
18
+ * reported with distinct error codes.
19
+ */
20
+ var HttpResource = class {
21
+ url;
22
+ options;
23
+ retryDelaysMs;
24
+ sizePromise;
25
+ /**
26
+ * A range has come through: CORS is proven for this resource, so a request that fails from
27
+ * here on failed on the way, whatever the diagnosis says of an error page without CORS headers.
28
+ */
29
+ hasReadRange = false;
30
+ version;
31
+ /**
32
+ * `signal` ends every request made for the resource, with the host's own `requestInit` signal.
33
+ */
34
+ constructor(url, options = {}, signal) {
35
+ this.url = url;
36
+ this.options = signal ? withAbortSignal(options, signal) : options;
37
+ this.retryDelaysMs = options.retryDelaysMs ?? DEFAULT_RETRY_DELAYS_MS;
38
+ }
39
+ /**
40
+ * One HEAD request (or a one-byte range where HEAD is refused or gives no length), cached for
41
+ * the lifetime of the resource once it succeeded; a failed lookup is retried on the next call.
42
+ */
43
+ size() {
44
+ this.sizePromise ??= this.rememberSize();
45
+ return this.sizePromise;
46
+ }
47
+ /**
48
+ * Servers clamp out-of-range requests instead of failing them; the port contracts want a
49
+ * typed error, so the range is checked against the (cached) size first.
50
+ */
51
+ async ensureFits(range) {
52
+ range.ensureWithin(await this.size(), `resource ${this.url}`);
53
+ }
54
+ /**
55
+ * Runs `attempt`, and after a short wait again, a few times, while it fails on the way: one
56
+ * dropped connection must not end a long playback. What asking again cannot change (an abort,
57
+ * a refusal, a server ignoring ranges, CORS) fails at once.
58
+ */
59
+ async askingAgain(attempt) {
60
+ for (const delayMs of this.retryDelaysMs) {
61
+ try {
62
+ return await attempt();
63
+ } catch (error) {
64
+ if (!isPassing(error)) throw error;
65
+ }
66
+ await wait(delayMs, this.options.requestInit?.signal);
67
+ }
68
+ return attempt();
69
+ }
70
+ /**
71
+ * The server's answer to a byte range, its body still to be read: a 206, or the refusal it
72
+ * amounts to. `signal` ends this request alone.
73
+ */
74
+ async rangeAnswer(range, signal) {
75
+ const options = signal ? withAbortSignal(this.options, signal) : this.options;
76
+ const response = await this.requestRange(range, options);
77
+ if (response.status !== HTTP_PARTIAL_CONTENT) {
78
+ discardBody(response);
79
+ throw this.refusalOf(response.status);
80
+ }
81
+ this.hasReadRange = true;
82
+ this.ensureUnchanged(response);
83
+ return response;
84
+ }
85
+ /**
86
+ * Every answer is of the version the first one told of: bytes of a recording replaced while it
87
+ * plays must not be put together with the old ones'.
88
+ */
89
+ ensureUnchanged(response) {
90
+ const seen = versionOf(response);
91
+ this.version ??= seen;
92
+ if (isSameVersion(this.version, seen)) return;
93
+ discardBody(response);
94
+ throw new GyroViewError("source-changed", `${this.url} was replaced while it played; load it again`);
95
+ }
96
+ /**
97
+ * A request that did not get through for want of a network is worth another try; one CORS
98
+ * refused before any range came through is not.
99
+ */
100
+ async requestRange(range, options) {
101
+ const headers = { Range: `bytes=${range.offset}-${range.end - 1}` };
102
+ try {
103
+ return this.hasReadRange ? await plainHttpRequest(this.url, {
104
+ method: "GET",
105
+ headers
106
+ }, options) : await httpRequest(this.url, {
107
+ method: "GET",
108
+ headers
109
+ }, options);
110
+ } catch (error) {
111
+ throw this.hasReadRange ? this.passingOnceAnswered(error) : passingIfUnreachable(error);
112
+ }
113
+ }
114
+ passingOnceAnswered(error) {
115
+ const message = `${this.url} could not be reached for a byte range`;
116
+ return isAbortError(error) ? error : passing(new GyroViewError("source-unreadable", message, { cause: error }));
117
+ }
118
+ refusalOf(status) {
119
+ if (status === HTTP_OK) return new GyroViewError("range-unsupported", `${this.url} ignores Range requests (answered 200 to a byte range)`);
120
+ const message = `${this.url} answered ${status} to a byte range`;
121
+ return status >= HTTP_SERVER_ERROR ? passing(new GyroViewError("source-unreadable", message)) : new GyroViewError("source-unreadable", message);
122
+ }
123
+ async rememberSize() {
124
+ try {
125
+ return await this.fetchSize();
126
+ } catch (error) {
127
+ this.sizePromise = void 0;
128
+ throw error;
129
+ }
130
+ }
131
+ async fetchSize() {
132
+ const response = await this.request("HEAD");
133
+ discardBody(response);
134
+ if (!response.ok) return this.sizeFromContentRange();
135
+ this.ensureUnchanged(response);
136
+ const contentLength = Number(response.headers.get("content-length"));
137
+ return Number.isSafeInteger(contentLength) && contentLength > 0 ? contentLength : this.sizeFromContentRange();
138
+ }
139
+ /**
140
+ * Some servers omit Content-Length on HEAD or refuse HEAD; a one-byte range reveals the total.
141
+ */
142
+ async sizeFromContentRange() {
143
+ const response = await this.request("GET", { Range: FIRST_BYTE_RANGE });
144
+ discardBody(response);
145
+ 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`);
146
+ this.hasReadRange = true;
147
+ this.ensureUnchanged(response);
148
+ return this.totalOf(response);
149
+ }
150
+ totalOf(response) {
151
+ if (!response.headers.has("content-range") && response.type === "cors") throw this.hiddenContentRange();
152
+ const total = contentRangeOf(response)?.total;
153
+ if (total === void 0) throw new GyroViewError("source-unreadable", `${this.url} reports neither Content-Length nor Content-Range; cannot determine the file size`);
154
+ return total;
155
+ }
156
+ hiddenContentRange() {
157
+ return new GyroViewError("cors", `${this.url} answered a byte range but hides its Content-Range from this origin; add ${EXPOSED_HEADERS_ADVICE}`);
158
+ }
159
+ request(method, headers) {
160
+ return httpRequest(this.url, {
161
+ method,
162
+ ...headers && { headers }
163
+ }, this.options);
164
+ }
165
+ };
166
+ /**
167
+ * A pause before asking again, which an abort of the resource's requests ends at once.
168
+ */
169
+ function wait(ms, signal) {
170
+ return new Promise((resolve, reject) => {
171
+ signal?.throwIfAborted();
172
+ const settled = new AbortController();
173
+ const timer = setTimeout(() => {
174
+ settled.abort();
175
+ resolve();
176
+ }, ms);
177
+ const onAbort = () => {
178
+ clearTimeout(timer);
179
+ reject(abortErrorOf(signal));
180
+ };
181
+ signal?.addEventListener("abort", onAbort, {
182
+ once: true,
183
+ signal: settled.signal
184
+ });
185
+ });
186
+ }
187
+ function abortErrorOf(signal) {
188
+ const reason = signal?.reason;
189
+ return reason instanceof Error ? reason : new DOMException("the read was aborted", "AbortError");
190
+ }
191
+ //#endregion
192
+ export { HttpResource };
@@ -0,0 +1,15 @@
1
+ //#region ../../packages/adapters/fetch/src/contentRange.ts
2
+ var CONTENT_RANGE = /^bytes (\d+)-(\d+)\/(\d+|\*)$/u;
3
+ var UNKNOWN_TOTAL = "*";
4
+ function contentRangeOf(response) {
5
+ const match = CONTENT_RANGE.exec(response.headers.get("content-range") ?? "");
6
+ if (!match) return void 0;
7
+ const [, first, last, total] = match;
8
+ return {
9
+ first: Number(first),
10
+ last: Number(last),
11
+ total: total === UNKNOWN_TOTAL ? void 0 : Number(total)
12
+ };
13
+ }
14
+ //#endregion
15
+ export { contentRangeOf };
@@ -7,14 +7,22 @@ import "../../../core/src/index.js";
7
7
  */
8
8
  var FIRST_BYTE_RANGE = "bytes=0-0";
9
9
  /**
10
- * The response headers a cross-origin page must be let read, as the fix a CORS failure names.
10
+ * The response headers a cross-origin page must be let read, as the fix a CORS failure names;
11
+ * `ETag` is optional, and tells a recording replaced while it plays more surely than
12
+ * `Last-Modified`, which a page may always read.
11
13
  */
12
- var EXPOSED_HEADERS_ADVICE = "Access-Control-Expose-Headers: Content-Range, Content-Length, Accept-Ranges";
14
+ var EXPOSED_HEADERS_ADVICE = "Access-Control-Expose-Headers: Content-Range, Content-Length, Accept-Ranges, ETag";
15
+ /**
16
+ * What a server must add for a page to read it across origins. A request that carries the
17
+ * visitor's cookies is held to more: the Fetch standard refuses a wildcard origin for it.
18
+ */
19
+ var ANONYMOUS_CORS_ADVICE = `Access-Control-Allow-Origin, ${EXPOSED_HEADERS_ADVICE}`;
20
+ var CREDENTIALED_CORS_ADVICE = `Access-Control-Allow-Origin naming this page's origin, not *; Access-Control-Allow-Credentials: true; ${EXPOSED_HEADERS_ADVICE}`;
13
21
  /**
14
22
  * Browsers keep byte ranges of one URL as a sparse cache entry and revalidate them with
15
23
  * conditional requests. A 304 to such a request makes the browser answer from that entry, and
16
24
  * Chrome has been seen to hand back an empty body for a range it believed it held (ADR 0013).
17
- * The demuxer caches what it needs itself, so nothing is lost by leaving the cache out.
25
+ * The download holds what it needs itself, so nothing is lost by leaving the cache out.
18
26
  */
19
27
  var CACHE_MODE = "no-store";
20
28
  /**
@@ -83,7 +91,10 @@ function eitherSignal(first, second) {
83
91
  * reachable, so its outcome tells the two apart.
84
92
  */
85
93
  async function diagnoseFailure(url, options, cause) {
86
- return await isReachableWithoutCors(url, options) ? new GyroViewError("cors", `${url} answered, but its server does not allow this page's origin to read it; add CORS headers (Access-Control-Allow-Origin, ${EXPOSED_HEADERS_ADVICE})`, { cause }) : new GyroViewError("source-unreadable", `${url} could not be fetched; check the URL and the network`, { cause });
94
+ return await isReachableWithoutCors(url, options) ? new GyroViewError("cors", `${url} answered, but its server does not allow this page's origin to read it; add CORS headers (${corsAdviceFor(options)})`, { cause }) : new GyroViewError("source-unreadable", `${url} could not be fetched; check the URL and the network`, { cause });
95
+ }
96
+ function corsAdviceFor(options) {
97
+ return options.requestInit?.credentials === "include" ? CREDENTIALED_CORS_ADVICE : ANONYMOUS_CORS_ADVICE;
87
98
  }
88
99
  /**
89
100
  * The probe ends with the request it diagnoses: an abort of the load ends it too.
@@ -1,3 +1,5 @@
1
+ import { HttpByteStream } from "./HttpByteStream.js";
1
2
  import { HttpRangeSource } from "./HttpRangeSource.js";
3
+ import { HttpResource } from "./HttpResource.js";
2
4
  import { HttpResourceLocator } from "./HttpResourceLocator.js";
3
- export { HttpRangeSource, HttpResourceLocator };
5
+ export { HttpByteStream, HttpRangeSource, HttpResource, HttpResourceLocator };
@@ -0,0 +1,24 @@
1
+ import { GyroViewError, hasErrorCode } from "../../../core/src/shared/errors/GyroViewError.js";
2
+ import "../../../core/src/index.js";
3
+ //#region ../../packages/adapters/fetch/src/passingFailures.ts
4
+ /**
5
+ * The failures on the way, which asking again may cure; any other fails the read at once. They
6
+ * are `source-unreadable` errors like any other, so a caller that gets one after the retries
7
+ * cannot tell, and need not: only the retry loop asks.
8
+ */
9
+ var passingFailures = /* @__PURE__ */ new WeakSet();
10
+ function passing(failure) {
11
+ passingFailures.add(failure);
12
+ return failure;
13
+ }
14
+ function isPassing(error) {
15
+ return error instanceof GyroViewError && passingFailures.has(error);
16
+ }
17
+ /**
18
+ * A request that did not get through for want of a network is worth another try.
19
+ */
20
+ function passingIfUnreachable(error) {
21
+ return hasErrorCode(error, "source-unreadable") && error instanceof Error ? passing(new GyroViewError("source-unreadable", error.message, { cause: error.cause })) : error;
22
+ }
23
+ //#endregion
24
+ export { isPassing, passing, passingIfUnreachable };
@@ -0,0 +1,17 @@
1
+ import { GyroViewError } from "../../../core/src/shared/errors/GyroViewError.js";
2
+ import "../../../core/src/index.js";
3
+ import { passing } from "./passingFailures.js";
4
+ //#region ../../packages/adapters/fetch/src/rangeFailures.ts
5
+ /**
6
+ * A body a dropped connection broke off after the headers: the most common way a network fails
7
+ * during long playback, so it is `source-unreadable` as a refused request is, not a bug or a bad
8
+ * file, and worth another try.
9
+ */
10
+ function brokenOff(url, range, cause) {
11
+ return passing(new GyroViewError("source-unreadable", `${url} broke off the ${range.length}-byte range at ${range.offset}`, { cause }));
12
+ }
13
+ function truncated(url, received, range) {
14
+ return new GyroViewError("source-truncated", `${url} returned ${received} bytes for a ${range.length}-byte range at ${range.offset}`);
15
+ }
16
+ //#endregion
17
+ export { brokenOff, truncated };
@@ -0,0 +1,37 @@
1
+ import { contentRangeOf } from "./contentRange.js";
2
+ //#region ../../packages/adapters/fetch/src/recordingVersion.ts
3
+ var HTTP_OK = 200;
4
+ var WEAK_ETAG_PREFIX = /^W\//u;
5
+ function versionOf(response) {
6
+ const etag = response.headers.get("etag");
7
+ const lastModified = response.headers.get("last-modified");
8
+ const size = sizeOf(response);
9
+ return {
10
+ ...etag !== null && { etag },
11
+ ...lastModified !== null && { lastModified },
12
+ ...size !== void 0 && { size }
13
+ };
14
+ }
15
+ /**
16
+ * Whether two answers are of one version of the recording, by the ETag where both tell one,
17
+ * else by the Last-Modified and the size; what either does not tell is taken for the same, so
18
+ * a server that tells nothing is never taken for one that changed its file.
19
+ */
20
+ function isSameVersion(known, seen) {
21
+ return known.etag !== void 0 && seen.etag !== void 0 ? opaqueTagOf(known.etag) === opaqueTagOf(seen.etag) : isSameWhereTold(known.lastModified, seen.lastModified) && isSameWhereTold(known.size, seen.size);
22
+ }
23
+ /**
24
+ * The tag without its weakness mark: a CDN may weaken a strong ETag it passes on.
25
+ */
26
+ function opaqueTagOf(etag) {
27
+ return etag.replace(WEAK_ETAG_PREFIX, "");
28
+ }
29
+ function isSameWhereTold(known, seen) {
30
+ return known === void 0 || seen === void 0 || known === seen;
31
+ }
32
+ function sizeOf(response) {
33
+ const size = response.status === HTTP_OK ? Number(response.headers.get("content-length") ?? NaN) : contentRangeOf(response)?.total;
34
+ return size !== void 0 && Number.isSafeInteger(size) && size > 0 ? size : void 0;
35
+ }
36
+ //#endregion
37
+ export { isSameVersion, versionOf };
@@ -0,0 +1,129 @@
1
+ import { GyroViewError } from "../../../core/src/shared/errors/GyroViewError.js";
2
+ import "../../../core/src/index.js";
3
+ import { SegmentChannel } from "./SegmentChannel.js";
4
+ import { EncodedAudioPacketSource, EncodedPacket, Mp4OutputFormat, NullTarget, Output } from "mediabunny";
5
+ //#region ../../packages/adapters/mediabunny/src/MediabunnyAudioPackager.ts
6
+ /**
7
+ * Seconds of audio per media segment: short enough that a seek has sound quickly, long enough
8
+ * that appends stay cheap.
9
+ */
10
+ var FRAGMENT_DURATION_SECONDS = 1;
11
+ /**
12
+ * Segments (each fragment is two: `moof` and `mdat`) that may wait for the consumer before
13
+ * re-packaging pauses.
14
+ */
15
+ var SEGMENTS_AHEAD = 4;
16
+ /**
17
+ * The codec strings of the audio the cameras record, AAC (`mp4a.40.2` for AAC-LC), and mediabunny's
18
+ * name for it. Other codecs are not re-packaged: the picture then plays on a silent clock.
19
+ */
20
+ var AAC_CODEC_STRING_PREFIX = "mp4a.40.";
21
+ var AAC = "aac";
22
+ /**
23
+ * AudioPackager over mediabunny: the samples re-packaged, unchanged, into fragmented MP4.
24
+ * Segments are taken from the muxer's box callbacks rather than from its byte stream: that
25
+ * yields whole `ftyp`/`moov`/`moof`/`mdat` boxes and leaves out the `mfra` index the muxer
26
+ * appends at the end, which Media Source Extensions do not accept. Fragmented output keeps the
27
+ * track's own timestamps, so segments started mid-track land at their true time.
28
+ */
29
+ var MediabunnyAudioPackager = class {
30
+ segmentsOf(samples, configuration) {
31
+ const codec = muxedCodecOf(configuration);
32
+ return {
33
+ mimeType: `audio/mp4; codecs="${configuration.codec}"`,
34
+ duration: samples.duration,
35
+ segmentsFrom: (from) => ({ [Symbol.asyncIterator]: () => remux({
36
+ samples,
37
+ configuration,
38
+ codec
39
+ }, from) })
40
+ };
41
+ }
42
+ };
43
+ function muxedCodecOf(configuration) {
44
+ if (configuration.codec.startsWith(AAC_CODEC_STRING_PREFIX)) return AAC;
45
+ throw new GyroViewError("codec-unsupported", `audio in ${configuration.codec} cannot be re-packaged for playback`);
46
+ }
47
+ /**
48
+ * Starts re-packaging from `from` into a channel and hands out its consumer's side. When the
49
+ * consumer leaves, the samples are let go of at once, even while one is awaited, and the muxer
50
+ * once the producer has noticed.
51
+ */
52
+ function remux(parts, from) {
53
+ const channel = new SegmentChannel(SEGMENTS_AHEAD);
54
+ const output = new Output({
55
+ format: fragmentedMp4Into(channel),
56
+ target: new NullTarget()
57
+ });
58
+ const source = new EncodedAudioPacketSource(parts.codec);
59
+ output.addAudioTrack(source);
60
+ const samples = parts.samples.samplesFrom(from)[Symbol.asyncIterator]();
61
+ const production = produce({
62
+ samples,
63
+ configuration: parts.configuration,
64
+ output,
65
+ source,
66
+ channel
67
+ });
68
+ return channel.segments(() => {
69
+ samples.return?.();
70
+ releaseAfter(production, output);
71
+ });
72
+ }
73
+ /**
74
+ * Frees the muxer once the producer has noticed the closed channel, without making a consumer
75
+ * that left early (a seek) wait for a sample still in flight.
76
+ */
77
+ async function releaseAfter(production, output) {
78
+ await production;
79
+ if (output.state === "finalized") return;
80
+ try {
81
+ await output.cancel();
82
+ } catch {}
83
+ }
84
+ /**
85
+ * Feeds samples to the muxer at the consumer's pace; every failure ends up in the channel.
86
+ */
87
+ async function produce(run) {
88
+ try {
89
+ await run.output.start();
90
+ let meta = { decoderConfig: decoderConfigOf(run.configuration) };
91
+ for (let next = await run.samples.next(); next.done !== true; next = await run.samples.next()) {
92
+ await run.channel.waitForRoom();
93
+ if (run.channel.isClosed) return;
94
+ await run.source.add(packetOf(next.value), meta);
95
+ meta = void 0;
96
+ }
97
+ if (run.channel.isClosed) return;
98
+ await run.output.finalize();
99
+ run.channel.close();
100
+ } catch (error) {
101
+ run.channel.fail(error);
102
+ }
103
+ }
104
+ function packetOf(sample) {
105
+ return new EncodedPacket(sample.data, "key", sample.timestamp, sample.duration);
106
+ }
107
+ function decoderConfigOf(configuration) {
108
+ return {
109
+ codec: configuration.codec,
110
+ sampleRate: configuration.sampleRate,
111
+ numberOfChannels: configuration.numberOfChannels,
112
+ ...configuration.description && { description: configuration.description }
113
+ };
114
+ }
115
+ function fragmentedMp4Into(channel) {
116
+ const forward = (data) => {
117
+ channel.push(new Uint8Array(data));
118
+ };
119
+ return new Mp4OutputFormat({
120
+ fastStart: "fragmented",
121
+ minimumFragmentDuration: FRAGMENT_DURATION_SECONDS,
122
+ onFtyp: forward,
123
+ onMoov: forward,
124
+ onMoof: forward,
125
+ onMdat: forward
126
+ });
127
+ }
128
+ //#endregion
129
+ export { MediabunnyAudioPackager };
@@ -0,0 +1,80 @@
1
+ import { GyroViewError, isAbortError } from "../../../core/src/shared/errors/GyroViewError.js";
2
+ import "../../../core/src/index.js";
3
+ import { audioConfigurationOf, videoConfigurationOf } from "./decoderConfigurations.js";
4
+ import { ALL_FORMATS, BufferSource, Input } from "mediabunny";
5
+ //#region ../../packages/adapters/mediabunny/src/MediabunnyCodecReader.ts
6
+ /**
7
+ * CodecReader over mediabunny: opens the file type and movie boxes as a file of their own, in
8
+ * memory, and tells each track's decoder configuration. mediabunny reads a sample only for a
9
+ * video codec without its configuration box; the samples are not there, so that track is
10
+ * refused.
11
+ */
12
+ var MediabunnyCodecReader = class {
13
+ async read(movieBytes) {
14
+ const input = new Input({
15
+ formats: ALL_FORMATS,
16
+ source: new BufferSource(movieBytes)
17
+ });
18
+ try {
19
+ const [videoTracks, audioTracks] = await Promise.all([input.getVideoTracks(), input.getAudioTracks()]);
20
+ return {
21
+ video: await Promise.all(videoTracks.map((track, index) => videoCodecOf(track, index))),
22
+ audio: (await Promise.all(audioTracks.map((track) => audioCodecOf(track)))).flat()
23
+ };
24
+ } catch (error) {
25
+ throw failureOf(error);
26
+ } finally {
27
+ input.dispose();
28
+ }
29
+ }
30
+ };
31
+ async function videoCodecOf(track, trackIndex) {
32
+ const [config, codedWidth, codedHeight] = await Promise.all([
33
+ configurationOrNull(track),
34
+ track.getCodedWidth(),
35
+ track.getCodedHeight()
36
+ ]);
37
+ if (config === null) throw new GyroViewError("codec-unsupported", `video track ${trackIndex} has no decoder configuration in its sample entry`);
38
+ const description = {
39
+ trackIndex,
40
+ codedWidth,
41
+ codedHeight,
42
+ codec: config.codec
43
+ };
44
+ return {
45
+ trackId: track.id,
46
+ description,
47
+ configuration: videoConfigurationOf(config, description)
48
+ };
49
+ }
50
+ /**
51
+ * The configuration of a codec that carries it in the movie box; null for one that would need
52
+ * a sample, which the movie bytes do not hold.
53
+ */
54
+ async function configurationOrNull(track) {
55
+ try {
56
+ return await track.getDecoderConfig();
57
+ } catch {
58
+ return null;
59
+ }
60
+ }
61
+ /**
62
+ * An audio track mediabunny cannot configure is left out: the picture then plays without sound
63
+ * rather than not at all.
64
+ */
65
+ async function audioCodecOf(track) {
66
+ const config = await track.getDecoderConfig();
67
+ return config === null ? [] : [{
68
+ trackId: track.id,
69
+ configuration: audioConfigurationOf(config)
70
+ }];
71
+ }
72
+ /**
73
+ * A caller's abort and the core's own failures pass as they are; anything else mediabunny
74
+ * throws means the bytes are no movie it can read.
75
+ */
76
+ function failureOf(error) {
77
+ return isAbortError(error) || error instanceof GyroViewError ? error : new GyroViewError("unsupported-container", "the movie box is not a readable movie", { cause: error });
78
+ }
79
+ //#endregion
80
+ export { MediabunnyCodecReader };
@@ -1,4 +1,5 @@
1
1
  import { GyroViewError, ensureInvariant } from "../../../core/src/shared/errors/GyroViewError.js";
2
+ import { ITERATION_END } from "../../../core/src/shared/async/iteration.js";
2
3
  import "../../../core/src/index.js";
3
4
  //#region ../../packages/adapters/mediabunny/src/SegmentChannel.ts
4
5
  /**
@@ -43,12 +44,20 @@ var SegmentChannel = class {
43
44
  this.failure ??= error instanceof Error ? error : new GyroViewError("decode", "audio re-packaging failed", { cause: error });
44
45
  this.close();
45
46
  }
46
- async *segments() {
47
- let next = await this.take();
48
- while (next) {
49
- yield next;
50
- next = await this.take();
51
- }
47
+ /**
48
+ * The consumer's side: the segments in order until the channel is closed and drained, then
49
+ * the producer's failure if it had one. Returning closes the channel at once, even while a
50
+ * segment is awaited, which then comes as the end. `onEnd` hears once of the end, however it
51
+ * came: the producer's resources may go.
52
+ */
53
+ segments(onEnd = doNothing) {
54
+ return new SegmentReading({
55
+ take: () => this.take(),
56
+ close: () => {
57
+ this.close();
58
+ },
59
+ onEnd
60
+ });
52
61
  }
53
62
  /**
54
63
  * The next segment; undefined once closed and drained; the producer's failure, if it had one.
@@ -80,5 +89,48 @@ var SegmentChannel = class {
80
89
  for (const resolve of waiters) resolve();
81
90
  }
82
91
  };
92
+ /**
93
+ * Iterates a channel's segments. A class, not a generator: a generator's return waits behind the
94
+ * segment it awaits, and the producer would re-package another fragment for nobody.
95
+ */
96
+ var SegmentReading = class {
97
+ parts;
98
+ hasEnded = false;
99
+ constructor(parts) {
100
+ this.parts = parts;
101
+ }
102
+ async next() {
103
+ if (this.hasEnded) return ITERATION_END;
104
+ try {
105
+ const segment = await this.parts.take();
106
+ return segment === void 0 || this.wasEnded() ? this.end() : {
107
+ done: false,
108
+ value: segment
109
+ };
110
+ } catch (error) {
111
+ this.end();
112
+ throw error;
113
+ }
114
+ }
115
+ return() {
116
+ this.parts.close();
117
+ return Promise.resolve(this.end());
118
+ }
119
+ [Symbol.asyncIterator]() {
120
+ return this;
121
+ }
122
+ /**
123
+ * Asked afresh after the wait: the consumer may have returned meanwhile.
124
+ */
125
+ wasEnded() {
126
+ return this.hasEnded;
127
+ }
128
+ end() {
129
+ if (!this.hasEnded) this.parts.onEnd();
130
+ this.hasEnded = true;
131
+ return ITERATION_END;
132
+ }
133
+ };
134
+ function doNothing() {}
83
135
  //#endregion
84
136
  export { SegmentChannel };
@@ -0,0 +1,28 @@
1
+ import { copyOfBytes } from "./bufferSources.js";
2
+ //#region ../../packages/adapters/mediabunny/src/decoderConfigurations.ts
3
+ /**
4
+ * A WebCodecs video decoder configuration in the core's terms; the coded size comes from the
5
+ * track where the configuration leaves it out.
6
+ */
7
+ function videoConfigurationOf(config, description) {
8
+ return {
9
+ codec: config.codec,
10
+ codedWidth: config.codedWidth ?? description.codedWidth,
11
+ codedHeight: config.codedHeight ?? description.codedHeight,
12
+ description: config.description === void 0 ? void 0 : copyOfBytes(config.description),
13
+ isFullRange: config.colorSpace?.fullRange ?? void 0
14
+ };
15
+ }
16
+ /**
17
+ * A WebCodecs audio decoder configuration in the core's terms.
18
+ */
19
+ function audioConfigurationOf(config) {
20
+ return {
21
+ codec: config.codec,
22
+ sampleRate: config.sampleRate,
23
+ numberOfChannels: config.numberOfChannels,
24
+ description: config.description === void 0 ? void 0 : copyOfBytes(config.description)
25
+ };
26
+ }
27
+ //#endregion
28
+ export { audioConfigurationOf, videoConfigurationOf };
@@ -1,2 +1,3 @@
1
- import { MediabunnyDemuxer } from "./MediabunnyDemuxer.js";
2
- export { MediabunnyDemuxer };
1
+ import { MediabunnyCodecReader } from "./MediabunnyCodecReader.js";
2
+ import { MediabunnyAudioPackager } from "./MediabunnyAudioPackager.js";
3
+ export { MediabunnyAudioPackager, MediabunnyCodecReader };
@@ -1,4 +1,4 @@
1
1
  //#region ../../packages/adapters/three/src/shaders/rawLenses.frag.glsl?raw
2
- var rawLenses_frag_default = "// The decoded lens images on their own, unstitched: each lens's region of its frame exactly as\n// the decoder delivered it, in the lens's screen area, with no pose, stabilization or gain. Black\n// around the areas. Every pixel takes each tile's footprint before the area decides, so the\n// derivatives are defined.\nin vec2 vNdc;\nout vec4 outColor;\n\nvoid main() {\n vec3 color = vec3(0.0);\n for (int i = 0; i < MAX_LENSES; i++) {\n if (i >= uLensCount) break;\n vec2 point = pointInArea(vNdc, uScreenArea[i]);\n Footprint footprint = footprintOf(i, point);\n if (isInArea(point)) color = sampleLens(uLensTexture[i], footprint, uSampling).rgb;\n }\n outColor = vec4(color, 1.0);\n}\n";
2
+ var rawLenses_frag_default = "// The decoded lens images on their own, unstitched: each lens's region of its frame exactly as\n// the decoder delivered it, in the lens's screen area, with no pose, stabilization or gain. Black\n// around the areas. Every pixel reads every tile, and the area picks one without a branch: a\n// read inside a branch that some pixels of a 2x2 quad skip took a far too coarse footprint on\n// Apple GPUs (Chrome through Metal), a grey line along a tile's edge wherever the edge fell\n// inside a quad.\nin vec2 vNdc;\nout vec4 outColor;\n\nvoid main() {\n vec3 color = vec3(0.0);\n for (int i = 0; i < MAX_LENSES; i++) {\n if (i >= uLensCount) break;\n vec2 point = pointInArea(vNdc, uScreenArea[i]);\n Footprint footprint = footprintOf(i, point);\n vec3 sampled = sampleLens(uLensTexture[i], footprint, uSampling).rgb;\n color = mix(color, sampled, float(isInArea(point)));\n }\n outColor = vec4(color, 1.0);\n}\n";
3
3
  //#endregion
4
4
  export { rawLenses_frag_default as default };