@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
|
@@ -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,9 +7,11 @@ 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";
|
|
13
15
|
/**
|
|
14
16
|
* What a server must add for a page to read it across origins. A request that carries the
|
|
15
17
|
* visitor's cookies is held to more: the Fetch standard refuses a wildcard origin for it.
|
|
@@ -20,7 +22,7 @@ var CREDENTIALED_CORS_ADVICE = `Access-Control-Allow-Origin naming this page's o
|
|
|
20
22
|
* Browsers keep byte ranges of one URL as a sparse cache entry and revalidate them with
|
|
21
23
|
* conditional requests. A 304 to such a request makes the browser answer from that entry, and
|
|
22
24
|
* Chrome has been seen to hand back an empty body for a range it believed it held (ADR 0013).
|
|
23
|
-
* The
|
|
25
|
+
* The download holds what it needs itself, so nothing is lost by leaving the cache out.
|
|
24
26
|
*/
|
|
25
27
|
var CACHE_MODE = "no-store";
|
|
26
28
|
/**
|
|
@@ -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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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 {
|
|
2
|
-
|
|
1
|
+
import { MediabunnyCodecReader } from "./MediabunnyCodecReader.js";
|
|
2
|
+
import { MediabunnyAudioPackager } from "./MediabunnyAudioPackager.js";
|
|
3
|
+
export { MediabunnyAudioPackager, MediabunnyCodecReader };
|