Initial public release.
This commit is contained in:
commit
c0a174e26a
|
@ -0,0 +1,2 @@
|
|||
*.mp4
|
||||
logs/
|
|
@ -0,0 +1,201 @@
|
|||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
|
@ -0,0 +1,13 @@
|
|||
Copyright Amazon.com Inc. or its affiliates.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
|
@ -0,0 +1,89 @@
|
|||
# warp
|
||||
Live media delivery protocol utilizing QUIC streams.
|
||||
|
||||
## How
|
||||
Warp works by delivering each audio and video segment as a separate QUIC stream. These streams are assigned a priority such that old video will arrive last and can be dropped. This avoids buffering in many cases, offering the viewer a potentially better experience.
|
||||
|
||||
## Browser Support
|
||||
This demo currently only works on Chrome for two reasons:
|
||||
|
||||
1. WebTransport support.
|
||||
2. [https://github.com/whatwg/html/issues/6359](Media underflow behavior).
|
||||
|
||||
### Specification
|
||||
See the [https://datatracker.ietf.org/doc/draft-lcurley-warp/](Warp draft). This demo includes a few custom messages.
|
||||
|
||||
## Setup
|
||||
### Software
|
||||
* Go
|
||||
* ffmpeg
|
||||
* openssl
|
||||
* Chrome Canary
|
||||
|
||||
### Media
|
||||
This demo simulates a live stream by reading a file from disk and sleeping based on media timestamps. Obviously you should hook this up to a real live stream to do anything useful
|
||||
|
||||
Download your favorite media file:
|
||||
```
|
||||
wget http://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4 -O media/combined.mp4
|
||||
```
|
||||
|
||||
Use ffmpeg to create a LL-DASH playlist. This creates a segment every 2s and MP4 fragment every 50ms.
|
||||
```
|
||||
ffmpeg -i media/combined.mp4 -f dash -use_timeline 0 -r:v 24 -g:v 48 -keyint_min:v 48 -sc_threshold:v 0 -tune zerolatency -streaming 1 -ldash 1 -seg_duration 2 -frag_duration 0.01 -frag_type duration media/fragmented.mpd
|
||||
```
|
||||
|
||||
You can increase the `frag_duration` (microseconds) to slightly reduce the file size in exchange for higher latency.
|
||||
|
||||
### TLS
|
||||
Unfortunately, QUIC mandates TLS and makes local development difficult.
|
||||
|
||||
#### Existing
|
||||
If you have a valid certificate you can use it instead of self-signing. The go binaries take a `-cert` and `-key` argument.
|
||||
|
||||
Skip the remaining steps in this section and use your hostname instead of `localhost.warp.demo`.
|
||||
|
||||
#### Self-Signed
|
||||
Generate a self-signed certificate for local testing:
|
||||
```
|
||||
./cert/generate
|
||||
```
|
||||
|
||||
This creates `cert/localhost.warp.demo.crt` and `cert/localhost.warp.demo.key`.
|
||||
|
||||
#### CORS
|
||||
To have the browser accept our self-signed certificate, you'll need to add an entry to `/etc/hosts`.
|
||||
|
||||
```
|
||||
echo '127.0.0.1 localhost.warp.demo' | sudo tee -a /etc/hosts
|
||||
```
|
||||
|
||||
#### Chrome
|
||||
Now we need to make Chrome accept these certificates, which normally would involve trusting a root CA but this was not working with WebTransport when I last tried.
|
||||
|
||||
Instead, we need to run a *fresh instance* of Chrome, instructing it to allow our self-signed certificate. This command will not work if Chrome is already running, so it's easier to use Chrome Canary instead. This command also needs to be executed in the project root because it invokes `./cert/fingerprint`.
|
||||
|
||||
Launch a new instance of Chrome Canary:
|
||||
```
|
||||
/Applications/Google\ Chrome\ Canary.app/Contents/MacOS/Google\ Chrome\ Canary --origin-to-force-quic-on="localhost.warp.demo:4443" --ignore-certificate-errors-spki-list="`./cert/fingerprint`" https://localhost.warp.demo:4444
|
||||
```
|
||||
|
||||
Note that this will open our web server on `localhost.warp.demo:4444`, which is started in the next section.
|
||||
|
||||
### Warp Server
|
||||
The Warp server defaults to listening on UDP 4443. It supports HTTP/3 and WebTransport, pushing media over WebTransport streams once a connection has been established. A more refined implementation would load content based on the WebTransport URL or some other messaging scheme.
|
||||
|
||||
```
|
||||
cd server
|
||||
go run ./warp-server
|
||||
```
|
||||
|
||||
### Web Server
|
||||
The web assets need to be hosted with a HTTPS server. If you're using a self-signed certificate, you will need to ignore the security warning in Chrome (Advanced -> proceed to localhost.warp.demo).
|
||||
|
||||
```
|
||||
cd client
|
||||
yarn serve
|
||||
```
|
||||
|
||||
These can be accessed on `https://localhost.warp.demo:4444` by default.
|
|
@ -0,0 +1,2 @@
|
|||
*.crt
|
||||
*.key
|
|
@ -0,0 +1,12 @@
|
|||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
HOST="localhost.warp.demo"
|
||||
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||
|
||||
# Outputs the certificate fingerprint in the format Chrome expects
|
||||
openssl x509 -pubkey -noout -in "${HOST}.crt" |
|
||||
openssl rsa -pubin -outform der 2>/dev/null |
|
||||
openssl dgst -sha256 -binary |
|
||||
base64
|
|
@ -0,0 +1,19 @@
|
|||
#!/bin/bash
|
||||
set -euxo pipefail
|
||||
|
||||
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||
|
||||
# Generate a new RSA key/cert for local development
|
||||
HOST="localhost.warp.demo"
|
||||
|
||||
openssl req \
|
||||
-x509 \
|
||||
-out "${HOST}.crt" \
|
||||
-keyout "${HOST}.key" \
|
||||
-newkey rsa:2048 \
|
||||
-nodes \
|
||||
-sha256 \
|
||||
-subj "/CN=${HOST}" \
|
||||
-extensions EXT \
|
||||
-config <( \
|
||||
printf "[dn]\nCN=${HOST}\n[req]\ndistinguished_name = dn\n[EXT]\nsubjectAltName=DNS:${HOST}\nkeyUsage=digitalSignature\nextendedKeyUsage=serverAuth")
|
|
@ -0,0 +1,3 @@
|
|||
node_modules
|
||||
.parcel-cache
|
||||
dist
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"source": "src/index.html",
|
||||
"scripts": {
|
||||
"serve": "parcel serve --https --host localhost.warp.demo --port 4444",
|
||||
"build": "parcel build",
|
||||
"check": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@parcel/validator-typescript": "^2.6.0",
|
||||
"parcel": "^2.6.0",
|
||||
"typescript": ">=3.0.0"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,82 @@
|
|||
<!doctype html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset = "UTF-8">
|
||||
<title>WARP</title>
|
||||
|
||||
<link rel="stylesheet" href="player.css">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="player">
|
||||
<div id="screen">
|
||||
<div id="play"><span>click to play</span></div>
|
||||
<video id="vid" controls></video>
|
||||
</div>
|
||||
|
||||
<div id="controls">
|
||||
<button type="button" id="live">Go Live</button>
|
||||
<button type="button" id="throttle">Throttle: None</button>
|
||||
</div>
|
||||
|
||||
<div id="stats">
|
||||
<label>Audio Buffer:</label>
|
||||
<div class="audio buffer"></div>
|
||||
|
||||
<label>Video Buffer:</label>
|
||||
<div class="video buffer"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="module">
|
||||
import { Player } from "./player.ts"
|
||||
|
||||
// This is so ghetto but I'm too lazy to improve it right now
|
||||
const vidRef = document.getElementById("vid")
|
||||
const liveRef = document.getElementById("live")
|
||||
const throttleRef = document.getElementById("throttle")
|
||||
const statsRef = document.getElementById("stats")
|
||||
const playRef = document.getElementById("play")
|
||||
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
|
||||
const player = new Player({
|
||||
url: params.get("url") || "https://localhost.warp.demo:4443",
|
||||
|
||||
vid: vidRef,
|
||||
stats: statsRef,
|
||||
throttle: throttleRef,
|
||||
})
|
||||
|
||||
liveRef.addEventListener("click", (e) => {
|
||||
e.preventDefault()
|
||||
player.goLive()
|
||||
})
|
||||
|
||||
throttleRef.addEventListener("click", (e) => {
|
||||
e.preventDefault()
|
||||
player.throttle()
|
||||
})
|
||||
|
||||
playRef.addEventListener('click', (e) => {
|
||||
vidRef.play()
|
||||
e.preventDefault()
|
||||
})
|
||||
|
||||
function playFunc(e) {
|
||||
playRef.style.display = "none"
|
||||
//player.goLive()
|
||||
|
||||
// Only fire once to restore pause/play functionality
|
||||
vidRef.removeEventListener('play', playFunc)
|
||||
}
|
||||
|
||||
vidRef.addEventListener('play', playFunc)
|
||||
vidRef.volume = 0.5
|
||||
|
||||
// Try to autoplay but ignore errors on mobile; they need to click
|
||||
//vidRef.play().catch((e) => console.warn(e))
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
|
@ -0,0 +1,59 @@
|
|||
import { MP4New, MP4File, MP4ArrayBuffer, MP4Info } from "./mp4"
|
||||
|
||||
export class InitParser {
|
||||
mp4box: MP4File;
|
||||
offset: number;
|
||||
|
||||
raw: MP4ArrayBuffer[];
|
||||
ready: Promise<Init>;
|
||||
|
||||
constructor() {
|
||||
this.mp4box = MP4New()
|
||||
|
||||
this.raw = []
|
||||
this.offset = 0
|
||||
|
||||
// Create a promise that gets resolved once the init segment has been parsed.
|
||||
this.ready = new Promise((resolve, reject) => {
|
||||
this.mp4box.onError = reject
|
||||
|
||||
// https://github.com/gpac/mp4box.js#onreadyinfo
|
||||
this.mp4box.onReady = (info: MP4Info) => {
|
||||
if (!info.isFragmented) {
|
||||
reject("expected a fragmented mp4")
|
||||
}
|
||||
|
||||
if (info.tracks.length != 1) {
|
||||
reject("expected a single track")
|
||||
}
|
||||
|
||||
resolve({
|
||||
info: info,
|
||||
raw: this.raw,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
push(data: Uint8Array) {
|
||||
// Make a copy of the atom because mp4box only accepts an ArrayBuffer unfortunately
|
||||
let box = new Uint8Array(data.byteLength);
|
||||
box.set(data)
|
||||
|
||||
// and for some reason we need to modify the underlying ArrayBuffer with fileStart
|
||||
let buffer = box.buffer as MP4ArrayBuffer
|
||||
buffer.fileStart = this.offset
|
||||
|
||||
// Parse the data
|
||||
this.offset = this.mp4box.appendBuffer(buffer)
|
||||
this.mp4box.flush()
|
||||
|
||||
// Add the box to our queue of chunks
|
||||
this.raw.push(buffer)
|
||||
}
|
||||
}
|
||||
|
||||
export interface Init {
|
||||
raw: MP4ArrayBuffer[];
|
||||
info: MP4Info;
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
export interface Message {
|
||||
init?: MessageInit
|
||||
segment?: MessageSegment
|
||||
}
|
||||
|
||||
export interface MessageInit {
|
||||
id: number // integer id
|
||||
}
|
||||
|
||||
export interface MessageSegment {
|
||||
init: number // integer id of the init segment
|
||||
timestamp: number // presentation timestamp in milliseconds of the first sample
|
||||
// TODO track would be nice
|
||||
}
|
|
@ -0,0 +1,85 @@
|
|||
// Wrapper around MP4Box to play nicely with MP4Box.
|
||||
// I tried getting a mp4box.all.d.ts file to work but just couldn't figure it out
|
||||
import { createFile, ISOFile, DataStream, BoxParser } from "./mp4box.all"
|
||||
|
||||
// Rename some stuff so it's on brand.
|
||||
export { createFile as MP4New, ISOFile as MP4File, DataStream as MP4Stream, BoxParser as MP4Parser }
|
||||
|
||||
export type MP4ArrayBuffer = ArrayBuffer & {fileStart: number};
|
||||
|
||||
export interface MP4MediaTrack {
|
||||
id: number;
|
||||
created: Date;
|
||||
modified: Date;
|
||||
movie_duration: number;
|
||||
layer: number;
|
||||
alternate_group: number;
|
||||
volume: number;
|
||||
track_width: number;
|
||||
track_height: number;
|
||||
timescale: number;
|
||||
duration: number;
|
||||
bitrate: number;
|
||||
codec: string;
|
||||
language: string;
|
||||
nb_samples: number;
|
||||
}
|
||||
|
||||
export interface MP4VideoData {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface MP4VideoTrack extends MP4MediaTrack {
|
||||
video: MP4VideoData;
|
||||
}
|
||||
|
||||
export interface MP4AudioData {
|
||||
sample_rate: number;
|
||||
channel_count: number;
|
||||
sample_size: number;
|
||||
}
|
||||
|
||||
export interface MP4AudioTrack extends MP4MediaTrack {
|
||||
audio: MP4AudioData;
|
||||
}
|
||||
|
||||
export type MP4Track = MP4VideoTrack | MP4AudioTrack;
|
||||
|
||||
export interface MP4Info {
|
||||
duration: number;
|
||||
timescale: number;
|
||||
fragment_duration: number;
|
||||
isFragmented: boolean;
|
||||
isProgressive: boolean;
|
||||
hasIOD: boolean;
|
||||
brands: string[];
|
||||
created: Date;
|
||||
modified: Date;
|
||||
tracks: MP4Track[];
|
||||
mime: string;
|
||||
videoTracks: MP4Track[];
|
||||
audioTracks: MP4Track[];
|
||||
}
|
||||
|
||||
export interface MP4Sample {
|
||||
number: number;
|
||||
track_id: number;
|
||||
timescale: number;
|
||||
description_index: number;
|
||||
description: any;
|
||||
data: ArrayBuffer;
|
||||
size: number;
|
||||
alreadyRead: number;
|
||||
duration: number;
|
||||
cts: number;
|
||||
dts: number;
|
||||
is_sync: boolean;
|
||||
is_leading: number;
|
||||
depends_on: number;
|
||||
is_depended_on: number;
|
||||
has_redundancy: number;
|
||||
degration_priority: number;
|
||||
offset: number;
|
||||
subsamples: any;
|
||||
}
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,79 @@
|
|||
html, body, #player {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #000000;
|
||||
color: #ffffff;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
|
||||
#screen {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
#play {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
#vid {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100vh;
|
||||
}
|
||||
|
||||
#controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
padding: 8px 16px;
|
||||
}
|
||||
|
||||
#controls > * {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
#controls label {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
#stats {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
}
|
||||
|
||||
#stats label {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.buffer {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.buffer .fill {
|
||||
position: absolute;
|
||||
transition-duration: 0.1s;
|
||||
transition-property: left, right, background-color;
|
||||
background-color: RebeccaPurple;
|
||||
height: 100%;
|
||||
text-align: right;
|
||||
padding-right: 0.5rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.buffer .fill.net {
|
||||
background-color: Purple;
|
||||
}
|
|
@ -0,0 +1,334 @@
|
|||
import { Source } from "./source"
|
||||
import { StreamReader, StreamWriter } from "./stream"
|
||||
import { InitParser } from "./init"
|
||||
import { Segment } from "./segment"
|
||||
import { Track } from "./track"
|
||||
import { Message, MessageInit, MessageSegment } from "./message"
|
||||
|
||||
///<reference path="./types/webtransport.d.ts"/>
|
||||
|
||||
export class Player {
|
||||
mediaSource: MediaSource;
|
||||
|
||||
init: Map<number, InitParser>;
|
||||
audio: Track;
|
||||
video: Track;
|
||||
|
||||
quic: Promise<WebTransport>;
|
||||
api: Promise<WritableStream>;
|
||||
|
||||
// References to elements in the DOM
|
||||
vidRef: HTMLVideoElement; // The video element itself
|
||||
statsRef: HTMLElement; // The stats div
|
||||
throttleRef: HTMLButtonElement; // The throttle button
|
||||
throttleCount: number; // number of times we've clicked the button in a row
|
||||
|
||||
interval: number;
|
||||
|
||||
timeRef?: DOMHighResTimeStamp;
|
||||
|
||||
constructor(props: any) {
|
||||
this.vidRef = props.vid
|
||||
this.statsRef = props.stats
|
||||
this.throttleRef = props.throttle
|
||||
this.throttleCount = 0
|
||||
|
||||
this.mediaSource = new MediaSource()
|
||||
this.vidRef.src = URL.createObjectURL(this.mediaSource)
|
||||
|
||||
this.init = new Map()
|
||||
this.audio = new Track(new Source(this.mediaSource));
|
||||
this.video = new Track(new Source(this.mediaSource));
|
||||
|
||||
this.interval = setInterval(this.tick.bind(this), 100)
|
||||
this.vidRef.addEventListener("waiting", this.tick.bind(this))
|
||||
|
||||
const quic = new WebTransport(props.url)
|
||||
this.quic = quic.ready.then(() => { return quic });
|
||||
|
||||
// Create a unidirectional stream for all of our messages
|
||||
this.api = this.quic.then((q) => {
|
||||
return q.createUnidirectionalStream()
|
||||
})
|
||||
|
||||
// async functions
|
||||
this.receiveStreams()
|
||||
|
||||
// Limit to 4Mb/s
|
||||
this.sendThrottle()
|
||||
}
|
||||
|
||||
async close() {
|
||||
clearInterval(this.interval);
|
||||
(await this.quic).close()
|
||||
}
|
||||
|
||||
async sendMessage(msg: any) {
|
||||
const payload = JSON.stringify(msg)
|
||||
const size = payload.length + 8
|
||||
|
||||
const stream = await this.api
|
||||
|
||||
const writer = new StreamWriter(stream)
|
||||
await writer.uint32(size)
|
||||
await writer.string("warp")
|
||||
await writer.string(payload)
|
||||
writer.release()
|
||||
}
|
||||
|
||||
throttle() {
|
||||
// Throttle is incremented each time we click the throttle button
|
||||
this.throttleCount += 1
|
||||
this.sendThrottle()
|
||||
|
||||
// After 5 seconds disable the throttling
|
||||
setTimeout(() => {
|
||||
this.throttleCount -= 1
|
||||
this.sendThrottle()
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
sendThrottle() {
|
||||
// TODO detect the incoming bitrate instead of hard-coding
|
||||
const bitrate = 4 * 1024 * 1024 // 4Mb/s
|
||||
|
||||
// Right shift by throttle to divide by 2,4,8,16,etc each time
|
||||
// Right shift by 3 more to divide by 8 to convert bits to bytes
|
||||
// Right shift by another 2 to divide by 4 to get the number of bytes in a quarter of a second
|
||||
let rate = bitrate >> (this.throttleCount + 3)
|
||||
let buffer = bitrate >> (this.throttleCount + 5) // 250ms before dropping
|
||||
|
||||
const str = formatBits(8*rate) + "/s"
|
||||
this.throttleRef.textContent = `Throttle: ${ str }`;
|
||||
|
||||
// NOTE: We don't use random packet loss because it's not a good simulator of how congestion works.
|
||||
// Delay-based congestion control like BBR most ignores packet loss, rightfully so.
|
||||
|
||||
// Send the server a message to fake network congestion.
|
||||
// This is done on the server side at the socket-level for maximum accuracy (impacts all packets).
|
||||
this.sendMessage({
|
||||
"x-throttle": {
|
||||
rate: rate,
|
||||
buffer: buffer,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
tick() {
|
||||
// Try skipping ahead if there's no data in the current buffer.
|
||||
this.trySeek()
|
||||
|
||||
// Try skipping video if it would fix any desync.
|
||||
this.trySkip()
|
||||
|
||||
// Update the stats at the end
|
||||
this.updateStats()
|
||||
}
|
||||
|
||||
goLive() {
|
||||
const ranges = this.vidRef.buffered
|
||||
if (!ranges.length) {
|
||||
return
|
||||
}
|
||||
|
||||
this.vidRef.currentTime = ranges.end(ranges.length-1);
|
||||
this.vidRef.play();
|
||||
}
|
||||
|
||||
// Try seeking ahead to the next buffered range if there's a gap
|
||||
trySeek() {
|
||||
if (this.vidRef.readyState > 2) { // HAVE_CURRENT_DATA
|
||||
// No need to seek
|
||||
return
|
||||
}
|
||||
|
||||
const ranges = this.vidRef.buffered
|
||||
if (!ranges.length) {
|
||||
// Video has not started yet
|
||||
return
|
||||
}
|
||||
|
||||
for (let i = 0; i < ranges.length; i += 1) {
|
||||
const pos = ranges.start(i)
|
||||
|
||||
if (this.vidRef.currentTime >= pos) {
|
||||
// This would involve seeking backwards
|
||||
continue
|
||||
}
|
||||
|
||||
console.warn("seeking forward", pos - this.vidRef.currentTime)
|
||||
|
||||
this.vidRef.currentTime = pos
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Try dropping video frames if there is future data available.
|
||||
trySkip() {
|
||||
let playhead: number | undefined
|
||||
|
||||
if (this.vidRef.readyState > 2) {
|
||||
// If we're not buffering, only skip video if it's before the current playhead
|
||||
playhead = this.vidRef.currentTime
|
||||
}
|
||||
|
||||
this.video.advance(playhead)
|
||||
}
|
||||
|
||||
async receiveStreams() {
|
||||
const q = await this.quic
|
||||
const streams = q.incomingUnidirectionalStreams.getReader()
|
||||
|
||||
while (true) {
|
||||
const result = await streams.read()
|
||||
if (result.done) break
|
||||
|
||||
const stream = result.value
|
||||
this.handleStream(stream) // don't await
|
||||
}
|
||||
}
|
||||
|
||||
async handleStream(stream: ReadableStream) {
|
||||
let r = new StreamReader(stream.getReader())
|
||||
|
||||
while (!await r.done()) {
|
||||
const size = await r.uint32();
|
||||
const typ = new TextDecoder('utf-8').decode(await r.bytes(4));
|
||||
|
||||
if (typ != "warp") throw "expected warp atom"
|
||||
if (size < 8) throw "atom too small"
|
||||
|
||||
const payload = new TextDecoder('utf-8').decode(await r.bytes(size - 8));
|
||||
const msg = JSON.parse(payload) as Message
|
||||
|
||||
if (msg.init) {
|
||||
return this.handleInit(r, msg.init)
|
||||
} else if (msg.segment) {
|
||||
return this.handleSegment(r, msg.segment)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async handleInit(stream: StreamReader, msg: MessageInit) {
|
||||
let init = this.init.get(msg.id);
|
||||
if (!init) {
|
||||
init = new InitParser()
|
||||
this.init.set(msg.id, init)
|
||||
}
|
||||
|
||||
while (1) {
|
||||
const data = await stream.read()
|
||||
if (!data) break
|
||||
|
||||
init.push(data)
|
||||
}
|
||||
}
|
||||
|
||||
async handleSegment(stream: StreamReader, msg: MessageSegment) {
|
||||
let pending = this.init.get(msg.init);
|
||||
if (!pending) {
|
||||
pending = new InitParser()
|
||||
this.init.set(msg.init, pending)
|
||||
}
|
||||
|
||||
// Wait for the init segment to be fully received and parsed
|
||||
const init = await pending.ready;
|
||||
|
||||
let track: Track;
|
||||
if (init.info.videoTracks.length) {
|
||||
track = this.video
|
||||
} else {
|
||||
track = this.audio
|
||||
}
|
||||
|
||||
const segment = new Segment(track.source, init, msg.timestamp)
|
||||
|
||||
// The track is responsible for flushing the segments in order
|
||||
track.source.initialize(init)
|
||||
track.add(segment)
|
||||
|
||||
/* TODO I'm not actually sure why this code doesn't work; something trips up the MP4 parser
|
||||
while (1) {
|
||||
const data = await stream.read()
|
||||
if (!data) break
|
||||
|
||||
segment.push(data)
|
||||
track.flush() // Flushes if the active segment has samples
|
||||
}
|
||||
*/
|
||||
|
||||
// One day I'll figure it out; until then read one top-level atom at a time
|
||||
while (!await stream.done()) {
|
||||
const raw = await stream.peek(4)
|
||||
const size = new DataView(raw.buffer, raw.byteOffset, raw.byteLength).getUint32(0)
|
||||
const atom = await stream.bytes(size)
|
||||
|
||||
segment.push(atom)
|
||||
track.flush() // Flushes if the active segment has new samples
|
||||
}
|
||||
|
||||
segment.finish()
|
||||
}
|
||||
|
||||
updateStats() {
|
||||
for (const child of this.statsRef.children) {
|
||||
if (child.className == "audio buffer") {
|
||||
const ranges: any = (this.audio) ? this.audio.buffered() : { length: 0 }
|
||||
this.visualizeBuffer(child as HTMLElement, ranges)
|
||||
} else if (child.className == "video buffer") {
|
||||
const ranges: any = (this.video) ? this.video.buffered() : { length: 0 }
|
||||
this.visualizeBuffer(child as HTMLElement, ranges)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
visualizeBuffer(element: HTMLElement, ranges: TimeRanges) {
|
||||
const children = element.children
|
||||
const max = 5
|
||||
|
||||
let index = 0
|
||||
let prev = 0
|
||||
|
||||
for (let i = 0; i < ranges.length; i += 1) {
|
||||
let start = ranges.start(i) - this.vidRef.currentTime
|
||||
let end = ranges.end(i) - this.vidRef.currentTime
|
||||
|
||||
if (end < 0 || start > max) {
|
||||
continue
|
||||
}
|
||||
|
||||
let fill: HTMLElement;
|
||||
|
||||
if (index < children.length) {
|
||||
fill = children[index] as HTMLElement;
|
||||
} else {
|
||||
fill = document.createElement("div")
|
||||
element.appendChild(fill)
|
||||
}
|
||||
|
||||
fill.className = "fill"
|
||||
fill.innerHTML = end.toFixed(2)
|
||||
fill.setAttribute('style', "left: " + (100 * Math.max(start, 0) / max) + "%; right: " + (100 - 100 * Math.min(end, max) / max) + "%")
|
||||
index += 1
|
||||
|
||||
prev = end
|
||||
}
|
||||
|
||||
for (let i = index; i < children.length; i += 1) {
|
||||
element.removeChild(children[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// https://stackoverflow.com/questions/15900485/correct-way-to-convert-size-in-bytes-to-kb-mb-gb-in-javascript
|
||||
function formatBits(bits: number, decimals: number = 1) {
|
||||
if (bits === 0) return '0 bits';
|
||||
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['b', 'Kb', 'Mb', 'Gb', 'Tb', 'Pb', 'Eb', 'Zb', 'Yb'];
|
||||
|
||||
const i = Math.floor(Math.log(bits) / Math.log(k));
|
||||
|
||||
return parseFloat((bits / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
}
|
|
@ -0,0 +1,146 @@
|
|||
import { Source } from "./source"
|
||||
import { Init } from "./init"
|
||||
import { MP4New, MP4File, MP4Sample, MP4Stream, MP4Parser, MP4ArrayBuffer } from "./mp4"
|
||||
|
||||
// Manage a segment download, keeping a buffer of a single sample to potentially rewrite the duration.
|
||||
export class Segment {
|
||||
source: Source; // The SourceBuffer used to decode media.
|
||||
offset: number; // The byte offset in the received file so far
|
||||
samples: MP4Sample[]; // The samples ready to be flushed to the source.
|
||||
timestamp: number; // The expected timestamp of the first sample in milliseconds
|
||||
init: Init;
|
||||
|
||||
dts?: number; // The parsed DTS of the first sample
|
||||
timescale?: number; // The parsed timescale of the segment
|
||||
|
||||
input: MP4File; // MP4Box file used to parse the incoming atoms.
|
||||
output: MP4File; // MP4Box file used to write the outgoing atoms after modification.
|
||||
|
||||
done: boolean; // The segment has been completed
|
||||
|
||||
constructor(source: Source, init: Init, timestamp: number) {
|
||||
this.source = source
|
||||
this.offset = 0
|
||||
this.done = false
|
||||
this.timestamp = timestamp
|
||||
this.init = init
|
||||
|
||||
this.input = MP4New();
|
||||
this.output = MP4New();
|
||||
this.samples = [];
|
||||
|
||||
this.input.onReady = (info: any) => {
|
||||
this.input.setExtractionOptions(info.tracks[0].id, {}, { nbSamples: 1 });
|
||||
|
||||
this.input.onSamples = this.onSamples.bind(this)
|
||||
this.input.start();
|
||||
}
|
||||
|
||||
// We have to reparse the init segment to work with mp4box
|
||||
for (let i = 0; i < init.raw.length; i += 1) {
|
||||
this.offset = this.input.appendBuffer(init.raw[i])
|
||||
|
||||
// Also populate the output with our init segment so it knows about tracks
|
||||
this.output.appendBuffer(init.raw[i])
|
||||
}
|
||||
|
||||
this.input.flush()
|
||||
this.output.flush()
|
||||
}
|
||||
|
||||
push(data: Uint8Array) {
|
||||
if (this.done) return; // ignore new data after marked done
|
||||
|
||||
// Make a copy of the atom because mp4box only accepts an ArrayBuffer unfortunately
|
||||
let box = new Uint8Array(data.byteLength);
|
||||
box.set(data)
|
||||
|
||||
// and for some reason we need to modify the underlying ArrayBuffer with offset
|
||||
let buffer = box.buffer as MP4ArrayBuffer
|
||||
buffer.fileStart = this.offset
|
||||
|
||||
// Parse the data
|
||||
this.offset = this.input.appendBuffer(buffer)
|
||||
this.input.flush()
|
||||
}
|
||||
|
||||
onSamples(id: number, user: any, samples: MP4Sample[]) {
|
||||
if (!samples.length) return;
|
||||
|
||||
if (this.dts === undefined) {
|
||||
this.dts = samples[0].dts;
|
||||
this.timescale = samples[0].timescale;
|
||||
}
|
||||
|
||||
// Add the samples to a queue
|
||||
this.samples.push(...samples)
|
||||
}
|
||||
|
||||
// Flushes any pending samples, returning true if the stream has finished.
|
||||
flush(): boolean {
|
||||
let stream = new MP4Stream(new ArrayBuffer(0), 0, false); // big-endian
|
||||
|
||||
while (this.samples.length) {
|
||||
// Keep a single sample if we're not done yet
|
||||
if (!this.done && this.samples.length < 2) break;
|
||||
|
||||
const sample = this.samples.shift()
|
||||
if (!sample) break;
|
||||
|
||||
let moof = this.output.createSingleSampleMoof(sample);
|
||||
moof.write(stream);
|
||||
|
||||
// adjusting the data_offset now that the moof size is known
|
||||
moof.trafs[0].truns[0].data_offset = moof.size+8; //8 is mdat header
|
||||
stream.adjustUint32(moof.trafs[0].truns[0].data_offset_position, moof.trafs[0].truns[0].data_offset);
|
||||
|
||||
// @ts-ignore
|
||||
var mdat = new MP4Parser.mdatBox();
|
||||
mdat.data = sample.data;
|
||||
mdat.write(stream);
|
||||
}
|
||||
|
||||
this.source.appendBuffer(stream.buffer as ArrayBuffer)
|
||||
|
||||
return this.done
|
||||
}
|
||||
|
||||
// The segment has completed
|
||||
finish() {
|
||||
this.done = true
|
||||
this.flush()
|
||||
}
|
||||
|
||||
// Extend the last sample so it reaches the provided timestamp
|
||||
skipTo(pts: number) {
|
||||
if (this.samples.length == 0) return
|
||||
let last = this.samples[this.samples.length-1]
|
||||
|
||||
const skip = pts - (last.dts + last.duration);
|
||||
|
||||
if (skip == 0) return;
|
||||
if (skip < 0) throw "can't skip backwards"
|
||||
|
||||
last.duration += skip
|
||||
|
||||
if (this.timescale) {
|
||||
console.warn("skipping video", skip / this.timescale)
|
||||
}
|
||||
}
|
||||
|
||||
buffered() {
|
||||
// Ignore if we have a single sample
|
||||
if (this.samples.length <= 1) return undefined;
|
||||
if (!this.timescale) return undefined;
|
||||
|
||||
const first = this.samples[0];
|
||||
const last = this.samples[this.samples.length-1]
|
||||
|
||||
|
||||
return {
|
||||
length: 1,
|
||||
start: first.dts / this.timescale,
|
||||
end: (last.dts + last.duration) / this.timescale,
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,81 @@
|
|||
import { Init } from "./init"
|
||||
|
||||
// Create a SourceBuffer with convenience methods
|
||||
export class Source {
|
||||
sourceBuffer?: SourceBuffer;
|
||||
mediaSource: MediaSource;
|
||||
queue: Array<Uint8Array | ArrayBuffer>;
|
||||
mime: string;
|
||||
|
||||
constructor(mediaSource: MediaSource) {
|
||||
this.mediaSource = mediaSource;
|
||||
this.queue = [];
|
||||
this.mime = "";
|
||||
}
|
||||
|
||||
initialize(init: Init) {
|
||||
if (!this.sourceBuffer) {
|
||||
this.sourceBuffer = this.mediaSource.addSourceBuffer(init.info.mime)
|
||||
this.sourceBuffer.addEventListener('updateend', this.flush.bind(this))
|
||||
|
||||
// Add the init data to the front of the queue
|
||||
for (let i = init.raw.length - 1; i >= 0; i -= 1) {
|
||||
this.queue.unshift(init.raw[i])
|
||||
}
|
||||
|
||||
this.flush()
|
||||
} else if (init.info.mime != this.mime) {
|
||||
this.sourceBuffer.changeType(init.info.mime)
|
||||
|
||||
// Add the init data to the front of the queue
|
||||
for (let i = init.raw.length - 1; i >= 0; i -= 1) {
|
||||
this.queue.unshift(init.raw[i])
|
||||
}
|
||||
}
|
||||
|
||||
this.mime = init.info.mime
|
||||
}
|
||||
|
||||
appendBuffer(data: Uint8Array | ArrayBuffer) {
|
||||
if (!this.sourceBuffer || this.sourceBuffer.updating || this.queue.length) {
|
||||
this.queue.push(data)
|
||||
} else {
|
||||
this.sourceBuffer.appendBuffer(data)
|
||||
}
|
||||
}
|
||||
|
||||
buffered() {
|
||||
if (!this.sourceBuffer) {
|
||||
return { length: 0 }
|
||||
}
|
||||
|
||||
return this.sourceBuffer.buffered
|
||||
}
|
||||
|
||||
flush() {
|
||||
// Check if we have a mime yet
|
||||
if (!this.sourceBuffer) {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the buffer is currently busy.
|
||||
if (this.sourceBuffer.updating) {
|
||||
return
|
||||
}
|
||||
|
||||
const data = this.queue.shift()
|
||||
if (data) {
|
||||
// If there's data in the queue, flush it.
|
||||
this.sourceBuffer.appendBuffer(data)
|
||||
} else if (this.sourceBuffer.buffered.length) {
|
||||
// Otherwise with no data, trim anything older than 30s.
|
||||
const end = this.sourceBuffer.buffered.end(this.sourceBuffer.buffered.length - 1) - 30.0
|
||||
const start = this.sourceBuffer.buffered.start(0)
|
||||
|
||||
// Remove any range larger than 1s.
|
||||
if (end > start && end - start > 1.0) {
|
||||
this.sourceBuffer.remove(start, end)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,254 @@
|
|||
// Reader wraps a stream and provides convience methods for reading pieces from a stream
|
||||
export class StreamReader {
|
||||
reader: ReadableStreamDefaultReader; // TODO make a separate class without promises when null
|
||||
buffer: Uint8Array;
|
||||
|
||||
constructor(reader: ReadableStreamDefaultReader, buffer: Uint8Array = new Uint8Array(0)) {
|
||||
this.reader = reader
|
||||
this.buffer = buffer
|
||||
}
|
||||
|
||||
// TODO implementing pipeTo seems more reasonable than releasing the lock
|
||||
release() {
|
||||
this.reader.releaseLock()
|
||||
}
|
||||
|
||||
// Returns any number of bytes
|
||||
async read(): Promise<Uint8Array | undefined> {
|
||||
if (this.buffer.byteLength) {
|
||||
const buffer = this.buffer;
|
||||
this.buffer = new Uint8Array()
|
||||
return buffer
|
||||
}
|
||||
|
||||
const result = await this.reader.read()
|
||||
return result.value
|
||||
}
|
||||
|
||||
async bytes(size: number): Promise<Uint8Array> {
|
||||
while (this.buffer.byteLength < size) {
|
||||
const result = await this.reader.read()
|
||||
if (result.done) {
|
||||
throw "short buffer"
|
||||
}
|
||||
|
||||
const buffer = new Uint8Array(result.value)
|
||||
|
||||
if (this.buffer.byteLength == 0) {
|
||||
this.buffer = buffer
|
||||
} else {
|
||||
const temp = new Uint8Array(this.buffer.byteLength + buffer.byteLength)
|
||||
temp.set(this.buffer)
|
||||
temp.set(buffer, this.buffer.byteLength)
|
||||
this.buffer = temp
|
||||
}
|
||||
}
|
||||
|
||||
const result = new Uint8Array(this.buffer.buffer, this.buffer.byteOffset, size)
|
||||
this.buffer = new Uint8Array(this.buffer.buffer, this.buffer.byteOffset + size)
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
async peek(size: number): Promise<Uint8Array> {
|
||||
while (this.buffer.byteLength < size) {
|
||||
const result = await this.reader.read()
|
||||
if (result.done) {
|
||||
throw "short buffer"
|
||||
}
|
||||
|
||||
const buffer = new Uint8Array(result.value)
|
||||
|
||||
if (this.buffer.byteLength == 0) {
|
||||
this.buffer = buffer
|
||||
} else {
|
||||
const temp = new Uint8Array(this.buffer.byteLength + buffer.byteLength)
|
||||
temp.set(this.buffer)
|
||||
temp.set(buffer, this.buffer.byteLength)
|
||||
this.buffer = temp
|
||||
}
|
||||
}
|
||||
|
||||
return new Uint8Array(this.buffer.buffer, this.buffer.byteOffset, size)
|
||||
}
|
||||
|
||||
async view(size: number): Promise<DataView> {
|
||||
const buf = await this.bytes(size)
|
||||
return new DataView(buf.buffer, buf.byteOffset, buf.byteLength)
|
||||
}
|
||||
|
||||
async uint8(): Promise<number> {
|
||||
const view = await this.view(1)
|
||||
return view.getUint8(0)
|
||||
}
|
||||
|
||||
async uint16(): Promise<number> {
|
||||
const view = await this.view(2)
|
||||
return view.getUint16(0)
|
||||
}
|
||||
|
||||
async uint32(): Promise<number> {
|
||||
const view = await this.view(4)
|
||||
return view.getUint32(0)
|
||||
}
|
||||
|
||||
// Returns a Number using 52-bits, the max Javascript can use for integer math
|
||||
async uint52(): Promise<number> {
|
||||
const v = await this.uint64()
|
||||
if (v > Number.MAX_SAFE_INTEGER) {
|
||||
throw "overflow"
|
||||
}
|
||||
|
||||
return Number(v)
|
||||
}
|
||||
|
||||
// Returns a Number using 52-bits, the max Javascript can use for integer math
|
||||
async vint52(): Promise<number> {
|
||||
const v = await this.vint64()
|
||||
if (v > Number.MAX_SAFE_INTEGER) {
|
||||
throw "overflow"
|
||||
}
|
||||
|
||||
return Number(v)
|
||||
}
|
||||
|
||||
// NOTE: Returns a BigInt instead of a Number
|
||||
async uint64(): Promise<bigint> {
|
||||
const view = await this.view(8)
|
||||
return view.getBigUint64(0)
|
||||
}
|
||||
|
||||
// NOTE: Returns a BigInt instead of a Number
|
||||
async vint64(): Promise<bigint> {
|
||||
const peek = await this.peek(1)
|
||||
const first = new DataView(peek.buffer, peek.byteOffset, peek.byteLength).getUint8(0)
|
||||
const size = (first & 0xc0) >> 6
|
||||
|
||||
switch (size) {
|
||||
case 0:
|
||||
const v0 = await this.uint8()
|
||||
return BigInt(v0) & 0x3fn
|
||||
case 1:
|
||||
const v1 = await this.uint16()
|
||||
return BigInt(v1) & 0x3fffn
|
||||
case 2:
|
||||
const v2 = await this.uint32()
|
||||
return BigInt(v2) & 0x3fffffffn
|
||||
case 3:
|
||||
const v3 = await this.uint64()
|
||||
return v3 & 0x3fffffffffffffffn
|
||||
default:
|
||||
throw "impossible"
|
||||
}
|
||||
}
|
||||
|
||||
async done(): Promise<boolean> {
|
||||
try {
|
||||
const peek = await this.peek(1)
|
||||
return false
|
||||
} catch (err) {
|
||||
return true // Assume EOF
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// StreamWriter wraps a stream and writes chunks of data
|
||||
export class StreamWriter {
|
||||
buffer: ArrayBuffer;
|
||||
writer: WritableStreamDefaultWriter;
|
||||
|
||||
constructor(stream: WritableStream) {
|
||||
this.buffer = new ArrayBuffer(8)
|
||||
this.writer = stream.getWriter()
|
||||
}
|
||||
|
||||
release() {
|
||||
this.writer.releaseLock()
|
||||
}
|
||||
|
||||
async close() {
|
||||
return this.writer.close()
|
||||
}
|
||||
|
||||
async uint8(v: number) {
|
||||
const view = new DataView(this.buffer, 0, 1)
|
||||
view.setUint8(0, v)
|
||||
return this.writer.write(view)
|
||||
}
|
||||
|
||||
async uint16(v: number) {
|
||||
const view = new DataView(this.buffer, 0, 2)
|
||||
view.setUint16(0, v)
|
||||
return this.writer.write(view)
|
||||
}
|
||||
|
||||
async uint24(v: number) {
|
||||
const v1 = (v >> 16) & 0xff
|
||||
const v2 = (v >> 8) & 0xff
|
||||
const v3 = (v) & 0xff
|
||||
|
||||
const view = new DataView(this.buffer, 0, 3)
|
||||
view.setUint8(0, v1)
|
||||
view.setUint8(1, v2)
|
||||
view.setUint8(2, v3)
|
||||
|
||||
return this.writer.write(view)
|
||||
}
|
||||
|
||||
async uint32(v: number) {
|
||||
const view = new DataView(this.buffer, 0, 4)
|
||||
view.setUint32(0, v)
|
||||
return this.writer.write(view)
|
||||
}
|
||||
|
||||
async uint52(v: number) {
|
||||
if (v > Number.MAX_SAFE_INTEGER) {
|
||||
throw "value too large"
|
||||
}
|
||||
|
||||
this.uint64(BigInt(v))
|
||||
}
|
||||
|
||||
async vint52(v: number) {
|
||||
if (v > Number.MAX_SAFE_INTEGER) {
|
||||
throw "value too large"
|
||||
}
|
||||
|
||||
if (v < (1 << 6)) {
|
||||
return this.uint8(v)
|
||||
} else if (v < (1 << 14)) {
|
||||
return this.uint16(v|0x4000)
|
||||
} else if (v < (1 << 30)) {
|
||||
return this.uint32(v|0x80000000)
|
||||
} else {
|
||||
return this.uint64(BigInt(v) | 0xc000000000000000n)
|
||||
}
|
||||
}
|
||||
|
||||
async uint64(v: bigint) {
|
||||
const view = new DataView(this.buffer, 0, 8)
|
||||
view.setBigUint64(0, v)
|
||||
return this.writer.write(view)
|
||||
}
|
||||
|
||||
async vint64(v: bigint) {
|
||||
if (v < (1 << 6)) {
|
||||
return this.uint8(Number(v))
|
||||
} else if (v < (1 << 14)) {
|
||||
return this.uint16(Number(v)|0x4000)
|
||||
} else if (v < (1 << 30)) {
|
||||
return this.uint32(Number(v)|0x80000000)
|
||||
} else {
|
||||
return this.uint64(v | 0xc000000000000000n)
|
||||
}
|
||||
}
|
||||
|
||||
async bytes(buffer: ArrayBuffer) {
|
||||
return this.writer.write(buffer)
|
||||
}
|
||||
|
||||
async string(str: string) {
|
||||
const data = new TextEncoder().encode(str)
|
||||
return this.writer.write(data)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,124 @@
|
|||
import { Source } from "./source"
|
||||
import { Segment } from "./segment"
|
||||
import { TimeRange } from "./util"
|
||||
|
||||
// An audio or video track that consists of multiple sequential segments.
|
||||
//
|
||||
// Instead of buffering, we want to drop video while audio plays uninterupted.
|
||||
// Chrome actually plays up to 3s of audio without video before buffering when in low latency mode.
|
||||
// Unforuntately, this does not recover correctly when there are gaps (pls fix).
|
||||
// Our solution is to flush segments in decode order, buffering a single additional frame.
|
||||
// We extend the duration of the buffered frame and flush it to cover any gaps.
|
||||
export class Track {
|
||||
source: Source;
|
||||
segments: Segment[];
|
||||
|
||||
constructor(source: Source) {
|
||||
this.source = source;
|
||||
this.segments = [];
|
||||
}
|
||||
|
||||
add(segment: Segment) {
|
||||
// TODO don't add if the segment is out of date already
|
||||
this.segments.push(segment)
|
||||
|
||||
// Sort by timestamp ascending
|
||||
// NOTE: The timestamp is in milliseconds, and we need to parse the media to get the accurate PTS/DTS.
|
||||
this.segments.sort((a: Segment, b: Segment): number => {
|
||||
return a.timestamp - b.timestamp
|
||||
})
|
||||
}
|
||||
|
||||
buffered(): TimeRanges {
|
||||
let ranges: TimeRange[] = []
|
||||
|
||||
const buffered = this.source.buffered() as TimeRanges
|
||||
for (let i = 0; i < buffered.length; i += 1) {
|
||||
// Convert the TimeRanges into an oject we can modify
|
||||
ranges.push({
|
||||
start: buffered.start(i),
|
||||
end: buffered.end(i)
|
||||
})
|
||||
}
|
||||
|
||||
// Loop over segments and add in their ranges, merging if possible.
|
||||
for (let segment of this.segments) {
|
||||
const buffered = segment.buffered()
|
||||
if (!buffered) continue;
|
||||
|
||||
if (ranges.length) {
|
||||
// Try to merge with an existing range
|
||||
const last = ranges[ranges.length-1];
|
||||
if (buffered.start < last.start) {
|
||||
// Network buffer is old; ignore it
|
||||
continue
|
||||
}
|
||||
|
||||
// Extend the end of the last range instead of pushing
|
||||
if (buffered.start <= last.end && buffered.end > last.end) {
|
||||
last.end = buffered.end
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
ranges.push(buffered)
|
||||
}
|
||||
|
||||
// TODO typescript
|
||||
return {
|
||||
length: ranges.length,
|
||||
start: (x) => { return ranges[x].start },
|
||||
end: (x) => { return ranges[x].end },
|
||||
}
|
||||
}
|
||||
|
||||
flush() {
|
||||
while (1) {
|
||||
if (!this.segments.length) break
|
||||
|
||||
const first = this.segments[0]
|
||||
const done = first.flush()
|
||||
if (!done) break
|
||||
|
||||
this.segments.shift()
|
||||
}
|
||||
}
|
||||
|
||||
// Given the current playhead, determine if we should drop any segments
|
||||
// If playhead is undefined, it means we're buffering so skip to anything now.
|
||||
advance(playhead: number | undefined) {
|
||||
if (this.segments.length < 2) return
|
||||
|
||||
while (this.segments.length > 1) {
|
||||
const current = this.segments[0];
|
||||
const next = this.segments[1];
|
||||
|
||||
if (next.dts === undefined || next.timescale == undefined) {
|
||||
// No samples have been parsed for the next segment yet.
|
||||
break
|
||||
}
|
||||
|
||||
if (current.dts === undefined) {
|
||||
// No samples have been parsed for the current segment yet.
|
||||
// We can't cover the gap by extending the sample so we have to seek.
|
||||
// TODO I don't think this can happen, but I guess we have to seek past the gap.
|
||||
break
|
||||
}
|
||||
|
||||
if (playhead !== undefined) {
|
||||
// Check if the next segment has playable media now.
|
||||
// Otherwise give the current segment more time to catch up.
|
||||
if ((next.dts / next.timescale) > playhead) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
current.skipTo(next.dts || 0) // tell typescript that it's not undefined; we already checked
|
||||
current.finish()
|
||||
|
||||
// TODO cancel the QUIC stream to save bandwidth
|
||||
|
||||
this.segments.shift()
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,84 @@
|
|||
declare module "webtransport"
|
||||
|
||||
/*
|
||||
There's no WebTransport support in TypeScript yet. Use this script to update definitions:
|
||||
|
||||
npx webidl2ts -i https://www.w3.org/TR/webtransport/ -o webtransport.d.ts
|
||||
You'll have to fix the constructors by hand.
|
||||
*/
|
||||
|
||||
interface WebTransportDatagramDuplexStream {
|
||||
readonly readable: ReadableStream;
|
||||
readonly writable: WritableStream;
|
||||
readonly maxDatagramSize: number;
|
||||
incomingMaxAge: number;
|
||||
outgoingMaxAge: number;
|
||||
incomingHighWaterMark: number;
|
||||
outgoingHighWaterMark: number;
|
||||
}
|
||||
|
||||
interface WebTransport {
|
||||
getStats(): Promise<WebTransportStats>;
|
||||
readonly ready: Promise<undefined>;
|
||||
readonly closed: Promise<WebTransportCloseInfo>;
|
||||
close(closeInfo?: WebTransportCloseInfo): undefined;
|
||||
readonly datagrams: WebTransportDatagramDuplexStream;
|
||||
createBidirectionalStream(): Promise<WebTransportBidirectionalStream>;
|
||||
readonly incomingBidirectionalStreams: ReadableStream;
|
||||
createUnidirectionalStream(): Promise<WritableStream>;
|
||||
readonly incomingUnidirectionalStreams: ReadableStream;
|
||||
}
|
||||
|
||||
declare var WebTransport: {
|
||||
prototype: WebTransport;
|
||||
new(url: string, options?: WebTransportOptions): WebTransport;
|
||||
};
|
||||
|
||||
interface WebTransportHash {
|
||||
algorithm?: string;
|
||||
value?: BufferSource;
|
||||
}
|
||||
|
||||
interface WebTransportOptions {
|
||||
allowPooling?: boolean;
|
||||
serverCertificateHashes?: Array<WebTransportHash>;
|
||||
}
|
||||
|
||||
interface WebTransportCloseInfo {
|
||||
closeCode?: number;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
interface WebTransportStats {
|
||||
timestamp?: DOMHighResTimeStamp;
|
||||
bytesSent?: number;
|
||||
packetsSent?: number;
|
||||
numOutgoingStreamsCreated?: number;
|
||||
numIncomingStreamsCreated?: number;
|
||||
bytesReceived?: number;
|
||||
packetsReceived?: number;
|
||||
minRtt?: DOMHighResTimeStamp;
|
||||
numReceivedDatagramsDropped?: number;
|
||||
}
|
||||
|
||||
interface WebTransportBidirectionalStream {
|
||||
readonly readable: ReadableStream;
|
||||
readonly writable: WritableStream;
|
||||
}
|
||||
|
||||
interface WebTransportError extends DOMException {
|
||||
readonly source: WebTransportErrorSource;
|
||||
readonly streamErrorCode: number;
|
||||
}
|
||||
|
||||
declare var WebTransportError: {
|
||||
prototype: WebTransportError;
|
||||
new(init?: WebTransportErrorInit): WebTransportError;
|
||||
};
|
||||
|
||||
interface WebTransportErrorInit {
|
||||
streamErrorCode?: number;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
type WebTransportErrorSource = "stream" | "session";
|
|
@ -0,0 +1,4 @@
|
|||
export interface TimeRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
|
@ -0,0 +1,12 @@
|
|||
{
|
||||
"include": ["src/**/*"],
|
||||
"compilerOptions": {
|
||||
"target": "es2021",
|
||||
"strict": true,
|
||||
"typeRoots": [
|
||||
"src/types"
|
||||
],
|
||||
"allowJs": true
|
||||
}
|
||||
}
|
||||
|
File diff suppressed because it is too large
Load Diff
|
@ -0,0 +1,3 @@
|
|||
*.mp4
|
||||
*.mpd
|
||||
*.m4s
|
|
@ -0,0 +1 @@
|
|||
logs/
|
|
@ -0,0 +1,38 @@
|
|||
module github.com/kixelated/warp-sample
|
||||
|
||||
go 1.18
|
||||
|
||||
require (
|
||||
github.com/abema/go-mp4 v0.7.2
|
||||
github.com/adriancable/webtransport-go v0.1.0
|
||||
github.com/kixelated/invoker v0.9.2
|
||||
github.com/lucas-clemente/quic-go v0.27.1
|
||||
github.com/zencoder/go-dash/v3 v3.0.2
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cheekybits/genny v1.0.0 // indirect
|
||||
github.com/francoispqt/gojay v1.2.13 // indirect
|
||||
github.com/fsnotify/fsnotify v1.4.9 // indirect
|
||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 // indirect
|
||||
github.com/google/uuid v1.1.2 // indirect
|
||||
github.com/marten-seemann/qpack v0.2.1 // indirect
|
||||
github.com/marten-seemann/qtls-go1-16 v0.1.5 // indirect
|
||||
github.com/marten-seemann/qtls-go1-17 v0.1.1 // indirect
|
||||
github.com/marten-seemann/qtls-go1-18 v0.1.1 // indirect
|
||||
github.com/nxadm/tail v1.4.8 // indirect
|
||||
github.com/onsi/ginkgo v1.16.4 // indirect
|
||||
github.com/stretchr/testify v1.7.0 // indirect
|
||||
golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871 // indirect
|
||||
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3 // indirect
|
||||
golang.org/x/net v0.0.0-20211116231205-47ca1ff31462 // indirect
|
||||
golang.org/x/sys v0.0.0-20211117180635-dee7805ff2e1 // indirect
|
||||
golang.org/x/text v0.3.7 // indirect
|
||||
golang.org/x/tools v0.1.11-0.20220316014157-77aa08bb151a // indirect
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect
|
||||
)
|
||||
|
||||
replace github.com/adriancable/webtransport-go => github.com/kixelated/webtransport-go v0.1.1
|
||||
|
||||
replace github.com/lucas-clemente/quic-go => github.com/kixelated/quic-go v0.28.0
|
|
@ -0,0 +1,325 @@
|
|||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.31.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
cloud.google.com/go v0.37.0/go.mod h1:TS1dMSSfndXH133OKGwekG838Om/cQT0BUHV3HcBgoo=
|
||||
dmitri.shuralyov.com/app/changes v0.0.0-20180602232624-0a106ad413e3/go.mod h1:Yl+fi1br7+Rr3LqpNJf1/uxUdtRUV+Tnj0o93V2B9MU=
|
||||
dmitri.shuralyov.com/html/belt v0.0.0-20180602232347-f7d459c86be0/go.mod h1:JLBrvjyP0v+ecvNYvCpyZgu5/xkfAUhi6wJj28eUfSU=
|
||||
dmitri.shuralyov.com/service/change v0.0.0-20181023043359-a85b471d5412/go.mod h1:a1inKt/atXimZ4Mv927x+r7UpyzRUf4emIoiiSC2TN4=
|
||||
dmitri.shuralyov.com/state v0.0.0-20180228185332-28bcc343414c/go.mod h1:0PRwlb0D6DFvNNtx+9ybjezNCa8XF0xaYcETyp6rHWU=
|
||||
git.apache.org/thrift.git v0.0.0-20180902110319-2566ecd5d999/go.mod h1:fPE2ZNJGynbRyZ4dJvy6G277gSllfV2HJqblrnkyeyg=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/abema/go-mp4 v0.7.2 h1:ugTC8gfEmjyaDKpXs3vi2QzgJbDu9B8m6UMMIpbYbGg=
|
||||
github.com/abema/go-mp4 v0.7.2/go.mod h1:vPl9t5ZK7K0x68jh12/+ECWBCXoWuIDtNgPtU2f04ws=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c=
|
||||
github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q=
|
||||
github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g=
|
||||
github.com/buger/jsonparser v0.0.0-20181115193947-bf1c66bbce23/go.mod h1:bbYlZJ7hK1yFx9hf58LP0zeX7UjIGs20ufpu3evjr+s=
|
||||
github.com/cheekybits/genny v1.0.0 h1:uGGa4nei+j20rOSeDeP5Of12XVm7TGUd4dJA9RDitfE=
|
||||
github.com/cheekybits/genny v1.0.0/go.mod h1:+tQajlRqAUrPI7DOSpB0XAqZYtQakVtB7wXkRAgjxjQ=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
github.com/coreos/go-systemd v0.0.0-20181012123002-c6f51f82210d/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
|
||||
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
|
||||
github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc=
|
||||
github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk=
|
||||
github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY=
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
|
||||
github.com/gliderlabs/ssh v0.1.1/go.mod h1:U7qILu1NlMHj9FlMhZLlkCdDnU1DBEAqr0aevW3Awn0=
|
||||
github.com/go-errors/errors v1.0.1/go.mod h1:f4zRHt4oKfwPJE5k8C9vpYG+aDHdBFUsgrm6/TyX73Q=
|
||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I=
|
||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
|
||||
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||
github.com/golang/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:tluoj9z5200jBnyusfRPU2LqT6J+DAorxEvtC7LHB+E=
|
||||
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||
github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4=
|
||||
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
|
||||
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
|
||||
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-github v17.0.0+incompatible/go.mod h1:zLgOLi98H3fifZn+44m+umXrS52loVEgC2AApnigrVQ=
|
||||
github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck=
|
||||
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
|
||||
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
|
||||
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
|
||||
github.com/google/uuid v1.1.2 h1:EVhdT+1Kseyi1/pUmXKaFxYsDNy9RQYkMWRH68J/W7Y=
|
||||
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/googleapis/gax-go v2.0.0+incompatible/go.mod h1:SFVmujtThgffbyetf+mdk2eWhX2bMyUtNHzFKcPA9HY=
|
||||
github.com/googleapis/gax-go/v2 v2.0.3/go.mod h1:LLvjysVCY1JZeum8Z6l8qUty8fiNwE08qbEPm1M08qg=
|
||||
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
|
||||
github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA=
|
||||
github.com/grpc-ecosystem/grpc-gateway v1.5.0/go.mod h1:RSKVYQBd5MCa4OVpNdGskqpgL2+G+NZTnrVHpWWfpdw=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
github.com/jellevandenhooff/dkim v0.0.0-20150330215556-f50fe3d243e1/go.mod h1:E0B/fFc00Y+Rasa88328GlI/XbtyysCtTHZS8h7IrBU=
|
||||
github.com/json-iterator/go v1.1.6/go.mod h1:+SdeFBvtyEkXs7REEP0seUULqWtbJapLOCVDaaPEHmU=
|
||||
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
|
||||
github.com/kisielk/errcheck v1.4.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/kixelated/invoker v0.9.2 h1:Pz8JDiRs8EzGc4EGVMZ4RYvFh+iQLXGZ4PG2KZyAh/0=
|
||||
github.com/kixelated/invoker v0.9.2/go.mod h1:RjG3iqm/sKwZjOpcW4SGq+l+4DJCDR/yUtc70VjCRB8=
|
||||
github.com/kixelated/quic-go v0.28.0 h1:KVA+baVIHNoRc3V6f/zGvfyZGRilAaTmKkkgvi7PEdw=
|
||||
github.com/kixelated/quic-go v0.28.0/go.mod h1:AzgQoPda7N+3IqMMMkywBKggIFo2KT6pfnlrQ2QieeI=
|
||||
github.com/kixelated/webtransport-go v0.1.1 h1:giXtM4UNHrVuccTL1WWTTsyQUzGUW9KmEwRkae26T6g=
|
||||
github.com/kixelated/webtransport-go v0.1.1/go.mod h1:eEJGJfh2zVTLyVEIiqIlBcb1y8ltAl9CprvaZNnE7Fs=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/pty v1.1.3/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI=
|
||||
github.com/mailru/easyjson v0.0.0-20190312143242-1de009706dbe/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||
github.com/marten-seemann/qpack v0.2.1 h1:jvTsT/HpCn2UZJdP+UUB53FfUUgeOyG5K1ns0OJOGVs=
|
||||
github.com/marten-seemann/qpack v0.2.1/go.mod h1:F7Gl5L1jIgN1D11ucXefiuJS9UMVP2opoCp2jDKb7wc=
|
||||
github.com/marten-seemann/qtls-go1-16 v0.1.5 h1:o9JrYPPco/Nukd/HpOHMHZoBDXQqoNtUCmny98/1uqQ=
|
||||
github.com/marten-seemann/qtls-go1-16 v0.1.5/go.mod h1:gNpI2Ol+lRS3WwSOtIUUtRwZEQMXjYK+dQSBFbethAk=
|
||||
github.com/marten-seemann/qtls-go1-17 v0.1.1 h1:DQjHPq+aOzUeh9/lixAGunn6rIOQyWChPSI4+hgW7jc=
|
||||
github.com/marten-seemann/qtls-go1-17 v0.1.1/go.mod h1:C2ekUKcDdz9SDWxec1N/MvcXBpaX9l3Nx67XaR84L5s=
|
||||
github.com/marten-seemann/qtls-go1-18 v0.1.1 h1:qp7p7XXUFL7fpBvSS1sWD+uSqPvzNQK43DH+/qEkj0Y=
|
||||
github.com/marten-seemann/qtls-go1-18 v0.1.1/go.mod h1:mJttiymBAByA49mhlNZZGrH5u1uXYZJ+RW28Py7f4m4=
|
||||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||
github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4=
|
||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
|
||||
github.com/neelance/astrewrite v0.0.0-20160511093645-99348263ae86/go.mod h1:kHJEU3ofeGjhHklVoIGuVj85JJwZ6kWPaJwCIxgnFmo=
|
||||
github.com/neelance/sourcemap v0.0.0-20151028013722-8c68805598ab/go.mod h1:Qr6/a/Q4r9LP1IltGz7tA7iOK1WonHEYhu1HRBA7ZiM=
|
||||
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
|
||||
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
|
||||
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
|
||||
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
|
||||
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
|
||||
github.com/onsi/ginkgo v1.14.0/go.mod h1:iSB4RoI2tjJc9BBv4NKIKWKya62Rps+oPG/Lv9klQyY=
|
||||
github.com/onsi/ginkgo v1.16.2/go.mod h1:CObGmKUOKaSC0RjmoAK7tKyn4Azo5P2IWuoMnvwxz1E=
|
||||
github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc=
|
||||
github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0=
|
||||
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
|
||||
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
|
||||
github.com/onsi/gomega v1.13.0 h1:7lLHu94wT9Ij0o6EWWclhu0aOh32VxhkwEJvzuWPeak=
|
||||
github.com/onsi/gomega v1.13.0/go.mod h1:lRk9szgn8TxENtWd0Tp4c3wjlRfMTMH27I+3Je41yGY=
|
||||
github.com/openzipkin/zipkin-go v0.1.1/go.mod h1:NtoC/o8u3JlF1lSlyPNswIbeQH9bJTmOf0Erfk+hxe8=
|
||||
github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e h1:s2RNOM/IGdY0Y6qfTeUKhDawdHDpK9RGBdx80qN4Ttw=
|
||||
github.com/orcaman/writerseeker v0.0.0-20200621085525-1d3f536ff85e/go.mod h1:nBdnFKj15wFbf94Rwfq4m30eAcyY9V/IyKAGQFtqkW0=
|
||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/prometheus/client_golang v0.8.0/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
|
||||
github.com/prometheus/common v0.0.0-20180801064454-c7de2306084e/go.mod h1:daVV7qP5qjZbuso7PdcryaAu0sAZbrN9i7WWcTMWvro=
|
||||
github.com/prometheus/procfs v0.0.0-20180725123919-05ee40e3a273/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
|
||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
|
||||
github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
|
||||
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
|
||||
github.com/shurcooL/component v0.0.0-20170202220835-f88ec8f54cc4/go.mod h1:XhFIlyj5a1fBNx5aJTbKoIq0mNaPvOagO+HjB3EtxrY=
|
||||
github.com/shurcooL/events v0.0.0-20181021180414-410e4ca65f48/go.mod h1:5u70Mqkb5O5cxEA8nxTsgrgLehJeAw6Oc4Ab1c/P1HM=
|
||||
github.com/shurcooL/github_flavored_markdown v0.0.0-20181002035957-2122de532470/go.mod h1:2dOwnU2uBioM+SGy2aZoq1f/Sd1l9OkAeAUvjSyvgU0=
|
||||
github.com/shurcooL/go v0.0.0-20180423040247-9e1955d9fb6e/go.mod h1:TDJrrUr11Vxrven61rcy3hJMUqaf/CLWYhHNPmT14Lk=
|
||||
github.com/shurcooL/go-goon v0.0.0-20170922171312-37c2f522c041/go.mod h1:N5mDOmsrJOB+vfqUK+7DmDyjhSLIIBnXo9lvZJj3MWQ=
|
||||
github.com/shurcooL/gofontwoff v0.0.0-20180329035133-29b52fc0a18d/go.mod h1:05UtEgK5zq39gLST6uB0cf3NEHjETfB4Fgr3Gx5R9Vw=
|
||||
github.com/shurcooL/gopherjslib v0.0.0-20160914041154-feb6d3990c2c/go.mod h1:8d3azKNyqcHP1GaQE/c6dDgjkgSx2BZ4IoEi4F1reUI=
|
||||
github.com/shurcooL/highlight_diff v0.0.0-20170515013008-09bb4053de1b/go.mod h1:ZpfEhSmds4ytuByIcDnOLkTHGUI6KNqRNPDLHDk+mUU=
|
||||
github.com/shurcooL/highlight_go v0.0.0-20181028180052-98c3abbbae20/go.mod h1:UDKB5a1T23gOMUJrI+uSuH0VRDStOiUVSjBTRDVBVag=
|
||||
github.com/shurcooL/home v0.0.0-20181020052607-80b7ffcb30f9/go.mod h1:+rgNQw2P9ARFAs37qieuu7ohDNQ3gds9msbT2yn85sg=
|
||||
github.com/shurcooL/htmlg v0.0.0-20170918183704-d01228ac9e50/go.mod h1:zPn1wHpTIePGnXSHpsVPWEktKXHr6+SS6x/IKRb7cpw=
|
||||
github.com/shurcooL/httperror v0.0.0-20170206035902-86b7830d14cc/go.mod h1:aYMfkZ6DWSJPJ6c4Wwz3QtW22G7mf/PEgaB9k/ik5+Y=
|
||||
github.com/shurcooL/httpfs v0.0.0-20171119174359-809beceb2371/go.mod h1:ZY1cvUeJuFPAdZ/B6v7RHavJWZn2YPVFQ1OSXhCGOkg=
|
||||
github.com/shurcooL/httpgzip v0.0.0-20180522190206-b1c53ac65af9/go.mod h1:919LwcH0M7/W4fcZ0/jy0qGght1GIhqyS/EgWGH2j5Q=
|
||||
github.com/shurcooL/issues v0.0.0-20181008053335-6292fdc1e191/go.mod h1:e2qWDig5bLteJ4fwvDAc2NHzqFEthkqn7aOZAOpj+PQ=
|
||||
github.com/shurcooL/issuesapp v0.0.0-20180602232740-048589ce2241/go.mod h1:NPpHK2TI7iSaM0buivtFUc9offApnI0Alt/K8hcHy0I=
|
||||
github.com/shurcooL/notifications v0.0.0-20181007000457-627ab5aea122/go.mod h1:b5uSkrEVM1jQUspwbixRBhaIjIzL2xazXp6kntxYle0=
|
||||
github.com/shurcooL/octicon v0.0.0-20181028054416-fa4f57f9efb2/go.mod h1:eWdoE5JD4R5UVWDucdOPg1g2fqQRq78IQa9zlOV1vpQ=
|
||||
github.com/shurcooL/reactions v0.0.0-20181006231557-f2e0b4ca5b82/go.mod h1:TCR1lToEk4d2s07G3XGfz2QrgHXg4RJBvjrOozvoWfk=
|
||||
github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
github.com/shurcooL/users v0.0.0-20180125191416-49c67e49c537/go.mod h1:QJTqeLYEDaXHZDBsXlPCDqdhQuJkuw4NOtaxYe3xii4=
|
||||
github.com/shurcooL/webdavfs v0.0.0-20170829043945-18c3829fa133/go.mod h1:hKmq5kWdCj2z2KEozexVbfEZIWiTjhE0+UjmZgPqehw=
|
||||
github.com/sourcegraph/annotate v0.0.0-20160123013949-f4cad6c6324d/go.mod h1:UdhH50NIW0fCiwBSr0co2m7BnFLdv4fQTgdqdJTHFeE=
|
||||
github.com/sourcegraph/syntaxhighlight v0.0.0-20170531221838-bd320f5d308e/go.mod h1:HuIsMU8RRBOtsCgI77wP899iHVBQpCmg4ErYMZB+2IA=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/sunfish-shogi/bufseekio v0.0.0-20210207115823-a4185644b365/go.mod h1:dEzdXgvImkQ3WLI+0KQpmEx8T/C/ma9KeS3AfmU899I=
|
||||
github.com/tarm/serial v0.0.0-20180830185346-98f6abe2eb07/go.mod h1:kDXzergiv9cbyO7IOYJZWg1U88JhDg3PB6klq9Hg2pA=
|
||||
github.com/viant/assertly v0.4.8/go.mod h1:aGifi++jvCrUaklKEKT0BU95igDNaqkvz+49uaYMPRU=
|
||||
github.com/viant/toolbox v0.24.0/go.mod h1:OxMCG57V0PXuIP2HNQrtJf2CjqdmbrOx5EkMILuUhzM=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
github.com/zencoder/go-dash/v3 v3.0.2 h1:oP1+dOh+Gp57PkvdCyMfbHtrHaxfl3w4kR3KBBbuqQE=
|
||||
github.com/zencoder/go-dash/v3 v3.0.2/go.mod h1:30R5bKy1aUYY45yesjtZ9l8trNc2TwNqbS17WVQmCzk=
|
||||
go.opencensus.io v0.18.0/go.mod h1:vKdFvxhtzZ9onBp9VKHK8z/sRpBMnKAsufL7wlDrCOA=
|
||||
go4.org v0.0.0-20180809161055-417644f6feb5/go.mod h1:MkTOUMDaeVYJUOUsaDXIhWPZYa1yOyC1qaOBpL57BhE=
|
||||
golang.org/x/build v0.0.0-20190111050920-041ab4dc3f9d/go.mod h1:OWs+y06UdEOHN4y+MfF/py+xQ/tYqIWW03b70/CG9Rw=
|
||||
golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190313024323-a1f597ede03a/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200221231518-2aa609cf4a9d/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871 h1:/pEO3GD/ABYAjuakUS6xSEmmlyVS4kxBNkeA9tLJiTI=
|
||||
golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/lint v0.0.0-20180702182130-06c8688daad7/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3 h1:kQgndtyPBW/JIYERgdxfwMYh3AVStj88WQTlNDi2a+o=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220106191415-9b9b3d81d5e3/go.mod h1:3p9vT2HGsQu2K1YbXdKPJLVgG5VJdoTa1poYQBtP1AY=
|
||||
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181029044818-c44066c5c816/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20181106065722-10aee1819953/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190313220215-9f648a60d977/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||
golang.org/x/net v0.0.0-20211116231205-47ca1ff31462 h1:2vmJlzGKvQ7e/X9XT0XydeWDxmqx8DnegiIMRT+5ssI=
|
||||
golang.org/x/net v0.0.0-20211116231205-47ca1ff31462/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20181017192945-9dcd33a902f4/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20181203162652-d668ce993890/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
|
||||
golang.org/x/perf v0.0.0-20180704124530-6e6d33e29852/go.mod h1:JLpeXjPJfIyPr5TlbXLkXWLhP8nz10XfvxElABhCtcw=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20181029174526-d69651ed3497/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190316082340-a2f829d7f35f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200519105757-fe76b779f299/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211117180635-dee7805ff2e1 h1:kwrAHlwJ0DUBZwQ238v+Uod/3eZ8B2K5rYsUHBQvzmI=
|
||||
golang.org/x/sys v0.0.0-20211117180635-dee7805ff2e1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180828015842-6cd1fcedba52/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20181030000716-a0a13e073c7b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200410194907-79a7a3126eef/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/tools v0.1.11-0.20220316014157-77aa08bb151a h1:ofrrl6c6NG5/IOSx/R1cyiQxxjqlur0h/TvbUhkH0II=
|
||||
golang.org/x/tools v0.1.11-0.20220316014157-77aa08bb151a/go.mod h1:Uh6Zz+xoGYZom868N8YTex3t7RhtHDBrE8Gzo9bV56E=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/api v0.0.0-20180910000450-7ca32eb868bf/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0=
|
||||
google.golang.org/api v0.0.0-20181030000543-1d582fd0359e/go.mod h1:4mhQ8q/RsB7i+udVvVy5NUi08OU8ZlA0gRVgrF7VFY0=
|
||||
google.golang.org/api v0.1.0/go.mod h1:UGEZY7KEX120AnNLIHFMKIo4obdJhkp2tPbaPlQx13Y=
|
||||
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||
google.golang.org/appengine v1.2.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.3.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20180831171423-11092d34479b/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20181029155118-b69ba1387ce2/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||
google.golang.org/genproto v0.0.0-20181202183823-bd91e49a0898/go.mod h1:7Ep/1NZk928CDR8SjdVbjWNpdIf6nzjE3BTgJDr2Atg=
|
||||
google.golang.org/genproto v0.0.0-20190306203927-b5d61aea6440/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
|
||||
google.golang.org/grpc v1.14.0/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
|
||||
google.golang.org/grpc v1.16.0/go.mod h1:0JHn/cJsOMiMfNA9+DeHDlAU7KAAB5GDlYFpa9MZMio=
|
||||
google.golang.org/grpc v1.17.0/go.mod h1:6QZJwpn2B+Zp71q/5VxRsJ6NXXVCE5NRUHRo+f3cWCs=
|
||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0 h1:bxAC2xTBsZGibn2RTntX0oH50xLsqy1OxA9tTL3p/lk=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
|
||||
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
|
||||
gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw=
|
||||
gopkg.in/src-d/go-billy.v4 v4.3.2 h1:0SQA1pRztfTFx2miS8sA97XvooFeNOmvUenF4o0EcVg=
|
||||
gopkg.in/src-d/go-billy.v4 v4.3.2/go.mod h1:nDjArDMp+XMs1aFAESLRjfGSgfvoYN0hDfzEk0GjC98=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
|
||||
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
|
||||
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
|
||||
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
grpc.go4.org v0.0.0-20170609214715-11d0a25b4919/go.mod h1:77eQGdRu53HpSqPFJFmuJdjuHRquDANNeA4x7B8WQ9o=
|
||||
honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
honnef.co/go/tools v0.0.1-2020.1.6/go.mod h1:pyyisuGw24ruLjrr1ddx39WE0y9OooInRzEYLhQB2YY=
|
||||
sourcegraph.com/sourcegraph/go-diff v0.5.0/go.mod h1:kuch7UrkMzY0X+p9CRK03kfuPQ2zzQcaEFbx8wA8rck=
|
||||
sourcegraph.com/sqs/pbtypes v0.0.0-20180604144634-d3ebe8f20ae4/go.mod h1:ketZ/q3QxT9HOBeFhu6RdvsftgpsbFHBF5Cas6cDKZ0=
|
|
@ -0,0 +1,358 @@
|
|||
package warp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/abema/go-mp4"
|
||||
"github.com/kixelated/invoker"
|
||||
"github.com/zencoder/go-dash/v3/mpd"
|
||||
)
|
||||
|
||||
// This is a demo; you should actually fetch media from a live backend.
|
||||
// It's just much easier to read from disk and "fake" being live.
|
||||
type Media struct {
|
||||
base fs.FS
|
||||
audio *mpd.Representation
|
||||
video *mpd.Representation
|
||||
}
|
||||
|
||||
func NewMedia(playlistPath string) (m *Media, err error) {
|
||||
m = new(Media)
|
||||
|
||||
// Create a fs.FS out of the folder holding the playlist
|
||||
m.base = os.DirFS(filepath.Dir(playlistPath))
|
||||
|
||||
// Read the playlist file
|
||||
playlist, err := mpd.ReadFromFile(playlistPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open playlist: %w", err)
|
||||
}
|
||||
|
||||
if len(playlist.Periods) > 1 {
|
||||
return nil, fmt.Errorf("multiple periods not supported")
|
||||
}
|
||||
|
||||
period := playlist.Periods[0]
|
||||
|
||||
for _, adaption := range period.AdaptationSets {
|
||||
representation := adaption.Representations[0]
|
||||
|
||||
if representation.MimeType == nil {
|
||||
return nil, fmt.Errorf("missing representation mime type")
|
||||
}
|
||||
|
||||
switch *representation.MimeType {
|
||||
case "video/mp4":
|
||||
m.video = representation
|
||||
case "audio/mp4":
|
||||
m.audio = representation
|
||||
}
|
||||
}
|
||||
|
||||
if m.video == nil {
|
||||
return nil, fmt.Errorf("no video representation found")
|
||||
}
|
||||
|
||||
if m.audio == nil {
|
||||
return nil, fmt.Errorf("no audio representation found")
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *Media) Start() (audio *MediaStream, video *MediaStream, err error) {
|
||||
start := time.Now()
|
||||
|
||||
audio, err = newMediaStream(m, m.audio, start)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
video, err = newMediaStream(m, m.video, start)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
return audio, video, nil
|
||||
}
|
||||
|
||||
type MediaStream struct {
|
||||
media *Media
|
||||
init *MediaInit
|
||||
|
||||
start time.Time
|
||||
rep *mpd.Representation
|
||||
sequence int
|
||||
}
|
||||
|
||||
func newMediaStream(m *Media, rep *mpd.Representation, start time.Time) (ms *MediaStream, err error) {
|
||||
ms = new(MediaStream)
|
||||
ms.media = m
|
||||
ms.rep = rep
|
||||
ms.start = start
|
||||
|
||||
if rep.SegmentTemplate == nil {
|
||||
return nil, fmt.Errorf("missing segment template")
|
||||
}
|
||||
|
||||
if rep.SegmentTemplate.StartNumber == nil {
|
||||
return nil, fmt.Errorf("missing start number")
|
||||
}
|
||||
|
||||
ms.sequence = int(*rep.SegmentTemplate.StartNumber)
|
||||
|
||||
return ms, nil
|
||||
}
|
||||
|
||||
// Returns the init segment for the stream
|
||||
func (ms *MediaStream) Init(ctx context.Context) (init *MediaInit, err error) {
|
||||
// Cache the init segment
|
||||
if ms.init != nil {
|
||||
return ms.init, nil
|
||||
}
|
||||
|
||||
if ms.rep.SegmentTemplate.Initialization == nil {
|
||||
return nil, fmt.Errorf("no init template")
|
||||
}
|
||||
|
||||
path := *ms.rep.SegmentTemplate.Initialization
|
||||
|
||||
// TODO Support the full template engine
|
||||
path = strings.ReplaceAll(path, "$RepresentationID$", *ms.rep.ID)
|
||||
|
||||
f, err := fs.ReadFile(ms.media.base, path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read init file: %w", err)
|
||||
}
|
||||
|
||||
ms.init, err = newMediaInit(f)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create init segment: %w", err)
|
||||
}
|
||||
|
||||
return ms.init, nil
|
||||
}
|
||||
|
||||
// Returns the next segment in the stream
|
||||
func (ms *MediaStream) Segment(ctx context.Context) (segment *MediaSegment, err error) {
|
||||
if ms.rep.SegmentTemplate.Media == nil {
|
||||
return nil, fmt.Errorf("no media template")
|
||||
}
|
||||
|
||||
path := *ms.rep.SegmentTemplate.Media
|
||||
|
||||
// TODO Support the full template engine
|
||||
path = strings.ReplaceAll(path, "$RepresentationID$", *ms.rep.ID)
|
||||
path = strings.ReplaceAll(path, "$Number%05d$", fmt.Sprintf("%05d", ms.sequence)) // TODO TODO
|
||||
|
||||
// Check if this is the first segment in the playlist
|
||||
first := ms.sequence == int(*ms.rep.SegmentTemplate.StartNumber)
|
||||
|
||||
// Try openning the file
|
||||
f, err := ms.media.base.Open(path)
|
||||
if !first && errors.Is(err, os.ErrNotExist) {
|
||||
// Return EOF if the next file is missing
|
||||
return nil, nil
|
||||
} else if err != nil {
|
||||
return nil, fmt.Errorf("failed to open segment file: %w", err)
|
||||
}
|
||||
|
||||
offset := ms.sequence - int(*ms.rep.SegmentTemplate.StartNumber)
|
||||
duration := time.Duration(*ms.rep.SegmentTemplate.Duration) / time.Nanosecond
|
||||
|
||||
timestamp := time.Duration(offset) * duration
|
||||
|
||||
// We need the init segment to properly parse the media segment
|
||||
init, err := ms.Init(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open init file: %w", err)
|
||||
}
|
||||
|
||||
segment, err = newMediaSegment(ms, init, f, timestamp)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create segment: %w", err)
|
||||
}
|
||||
|
||||
ms.sequence += 1
|
||||
|
||||
return segment, nil
|
||||
}
|
||||
|
||||
type MediaInit struct {
|
||||
Raw []byte
|
||||
Timescale int
|
||||
}
|
||||
|
||||
func newMediaInit(raw []byte) (mi *MediaInit, err error) {
|
||||
mi = new(MediaInit)
|
||||
mi.Raw = raw
|
||||
|
||||
err = mi.parse()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse init segment: %w", err)
|
||||
}
|
||||
|
||||
return mi, nil
|
||||
}
|
||||
|
||||
// Parse through the init segment, literally just to populate the timescale
|
||||
func (mi *MediaInit) parse() (err error) {
|
||||
r := bytes.NewReader(mi.Raw)
|
||||
|
||||
_, err = mp4.ReadBoxStructure(r, func(h *mp4.ReadHandle) (interface{}, error) {
|
||||
if !h.BoxInfo.IsSupportedType() {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
payload, _, err := h.ReadPayload()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch box := payload.(type) {
|
||||
case *mp4.Mdhd: // Media Header; moov -> trak -> mdia > mdhd
|
||||
if mi.Timescale != 0 {
|
||||
// verify only one track
|
||||
return nil, fmt.Errorf("multiple mdhd atoms")
|
||||
}
|
||||
|
||||
mi.Timescale = int(box.Timescale)
|
||||
}
|
||||
|
||||
// Expands children
|
||||
return h.Expand()
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to parse MP4 file: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
type MediaSegment struct {
|
||||
stream *MediaStream
|
||||
init *MediaInit
|
||||
file fs.File
|
||||
timestamp time.Duration
|
||||
}
|
||||
|
||||
func newMediaSegment(s *MediaStream, init *MediaInit, file fs.File, timestamp time.Duration) (ms *MediaSegment, err error) {
|
||||
ms = new(MediaSegment)
|
||||
ms.stream = s
|
||||
ms.init = init
|
||||
ms.file = file
|
||||
ms.timestamp = timestamp
|
||||
return ms, nil
|
||||
}
|
||||
|
||||
// Return the next atom, sleeping based on the PTS to simulate a live stream
|
||||
func (ms *MediaSegment) Read(ctx context.Context) (chunk []byte, err error) {
|
||||
// Read the next top-level box
|
||||
var header [8]byte
|
||||
|
||||
_, err = io.ReadFull(ms.file, header[:])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read header: %w", err)
|
||||
}
|
||||
|
||||
size := int(binary.BigEndian.Uint32(header[0:4]))
|
||||
if size < 8 {
|
||||
return nil, fmt.Errorf("box is too small")
|
||||
}
|
||||
|
||||
buf := make([]byte, size)
|
||||
n := copy(buf, header[:])
|
||||
|
||||
_, err = io.ReadFull(ms.file, buf[n:])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read atom: %w", err)
|
||||
}
|
||||
|
||||
sample, err := ms.parseAtom(ctx, buf)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse atom: %w", err)
|
||||
}
|
||||
|
||||
if sample != nil {
|
||||
// Simulate a live stream by sleeping before we write this sample.
|
||||
// Figure out how much time has elapsed since the start
|
||||
elapsed := time.Since(ms.stream.start)
|
||||
delay := sample.Timestamp - elapsed
|
||||
|
||||
if delay > 0 {
|
||||
// Sleep until we're supposed to see these samples
|
||||
err = invoker.Sleep(delay)(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return buf, nil
|
||||
}
|
||||
|
||||
// Parse through the MP4 atom, returning infomation about the next fragmented sample
|
||||
func (ms *MediaSegment) parseAtom(ctx context.Context, buf []byte) (sample *mediaSample, err error) {
|
||||
r := bytes.NewReader(buf)
|
||||
|
||||
_, err = mp4.ReadBoxStructure(r, func(h *mp4.ReadHandle) (interface{}, error) {
|
||||
if !h.BoxInfo.IsSupportedType() {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
payload, _, err := h.ReadPayload()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch box := payload.(type) {
|
||||
case *mp4.Moof:
|
||||
sample = new(mediaSample)
|
||||
case *mp4.Tfdt: // Track Fragment Decode Timestamp; moof -> traf -> tfdt
|
||||
// TODO This box isn't required
|
||||
// TODO we want the last PTS if there are multiple samples
|
||||
var dts time.Duration
|
||||
if box.FullBox.Version == 0 {
|
||||
dts = time.Duration(box.BaseMediaDecodeTimeV0)
|
||||
} else {
|
||||
dts = time.Duration(box.BaseMediaDecodeTimeV1)
|
||||
}
|
||||
|
||||
if ms.init.Timescale == 0 {
|
||||
return nil, fmt.Errorf("missing timescale")
|
||||
}
|
||||
|
||||
// Convert to seconds
|
||||
// TODO What about PTS?
|
||||
sample.Timestamp = dts * time.Second / time.Duration(ms.init.Timescale)
|
||||
}
|
||||
|
||||
// Expands children
|
||||
return h.Expand()
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse MP4 file: %w", err)
|
||||
}
|
||||
|
||||
return sample, nil
|
||||
}
|
||||
|
||||
func (ms *MediaSegment) Close() (err error) {
|
||||
return ms.file.Close()
|
||||
}
|
||||
|
||||
type mediaSample struct {
|
||||
Timestamp time.Duration // The timestamp of the first sample
|
||||
}
|
|
@ -0,0 +1,22 @@
|
|||
package warp
|
||||
|
||||
type Message struct {
|
||||
Init *MessageInit `json:"init,omitempty"`
|
||||
Segment *MessageSegment `json:"segment,omitempty"`
|
||||
Throttle *MessageThrottle `json:"x-throttle,omitempty"`
|
||||
}
|
||||
|
||||
type MessageInit struct {
|
||||
Id int `json:"id"` // ID of the init segment
|
||||
}
|
||||
|
||||
type MessageSegment struct {
|
||||
Init int `json:"init"` // ID of the init segment to use for this segment
|
||||
Timestamp int `json:"timestamp"` // PTS of the first frame in milliseconds
|
||||
}
|
||||
|
||||
type MessageThrottle struct {
|
||||
Rate int `json:"rate"` // Artificially limit the socket byte rate per second
|
||||
Buffer int `json:"buffer"` // Artificially limit the socket buffer to the number of bytes
|
||||
Loss float64 `json:"loss"` // Artificially increase packet loss percentage from 0.0 - 1.0
|
||||
}
|
|
@ -0,0 +1,99 @@
|
|||
package warp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/adriancable/webtransport-go"
|
||||
"github.com/kixelated/invoker"
|
||||
"github.com/lucas-clemente/quic-go"
|
||||
"github.com/lucas-clemente/quic-go/logging"
|
||||
"github.com/lucas-clemente/quic-go/qlog"
|
||||
)
|
||||
|
||||
type Server struct {
|
||||
inner *webtransport.Server
|
||||
media *Media
|
||||
socket *Socket
|
||||
|
||||
sessions invoker.Tasks
|
||||
}
|
||||
|
||||
type ServerConfig struct {
|
||||
Addr string
|
||||
CertFile string
|
||||
KeyFile string
|
||||
LogDir string
|
||||
}
|
||||
|
||||
func NewServer(config ServerConfig, media *Media) (s *Server, err error) {
|
||||
s = new(Server)
|
||||
|
||||
// Listen using a custom socket that simulates congestion.
|
||||
s.socket, err = NewSocket(config.Addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create socket: %w", err)
|
||||
}
|
||||
|
||||
quicConfig := &quic.Config{}
|
||||
|
||||
if config.LogDir != "" {
|
||||
quicConfig.Tracer = qlog.NewTracer(func(p logging.Perspective, connectionID []byte) io.WriteCloser {
|
||||
path := fmt.Sprintf("%s-%s.qlog", p, hex.EncodeToString(connectionID))
|
||||
|
||||
f, err := os.Create(filepath.Join(config.LogDir, path))
|
||||
if err != nil {
|
||||
// lame
|
||||
panic(err)
|
||||
}
|
||||
|
||||
return f
|
||||
})
|
||||
}
|
||||
|
||||
s.inner = &webtransport.Server{
|
||||
Listen: s.socket,
|
||||
TLSCert: webtransport.CertFile{Path: config.CertFile},
|
||||
TLSKey: webtransport.CertFile{Path: config.KeyFile},
|
||||
QuicConfig: quicConfig,
|
||||
}
|
||||
|
||||
s.media = media
|
||||
|
||||
http.HandleFunc("/", func(rw http.ResponseWriter, r *http.Request) {
|
||||
session, ok := r.Body.(*webtransport.Session)
|
||||
if !ok {
|
||||
log.Print("http requests not supported")
|
||||
return
|
||||
}
|
||||
|
||||
ss, err := NewSession(session, s.media, s.socket)
|
||||
if err != nil {
|
||||
// TODO handle better?
|
||||
log.Printf("failed to create warp session: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
// Run the session in parallel, logging errors instead of crashing
|
||||
s.sessions.Add(func(ctx context.Context) (err error) {
|
||||
err = ss.Run(ctx)
|
||||
if err != nil {
|
||||
log.Printf("terminated session: %s", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
})
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Server) Run(ctx context.Context) (err error) {
|
||||
return invoker.Run(ctx, s.inner.Run, s.socket.Run, s.sessions.Repeat)
|
||||
}
|
|
@ -0,0 +1,276 @@
|
|||
package warp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math"
|
||||
"time"
|
||||
|
||||
"github.com/adriancable/webtransport-go"
|
||||
"github.com/kixelated/invoker"
|
||||
)
|
||||
|
||||
// A single WebTransport session
|
||||
type Session struct {
|
||||
inner *webtransport.Session
|
||||
media *Media
|
||||
socket *Socket
|
||||
audio *MediaStream
|
||||
video *MediaStream
|
||||
|
||||
streams invoker.Tasks
|
||||
}
|
||||
|
||||
func NewSession(session *webtransport.Session, media *Media, socket *Socket) (s *Session, err error) {
|
||||
s = new(Session)
|
||||
s.inner = session
|
||||
s.media = media
|
||||
s.socket = socket
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Session) Run(ctx context.Context) (err error) {
|
||||
// TODO validate the session before accepting it
|
||||
s.inner.AcceptSession()
|
||||
defer s.inner.CloseSession()
|
||||
|
||||
s.audio, s.video, err = s.media.Start()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to start media: %w", err)
|
||||
}
|
||||
|
||||
// Once we've validated the session, now we can start accessing the streams
|
||||
return invoker.Run(ctx, s.runAccept, s.runAcceptUni, s.runAudio, s.runVideo, s.streams.Repeat)
|
||||
}
|
||||
|
||||
func (s *Session) runAccept(ctx context.Context) (err error) {
|
||||
for {
|
||||
// TODO context support :(
|
||||
stream, err := s.inner.AcceptStream()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to accept bidirectional stream: %w", err)
|
||||
}
|
||||
|
||||
// Warp doesn't utilize bidirectional streams so just close them immediately.
|
||||
// We might use them in the future so don't close the connection with an error.
|
||||
stream.CancelRead(1)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) runAcceptUni(ctx context.Context) (err error) {
|
||||
for {
|
||||
stream, err := s.inner.AcceptUniStream(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to accept unidirectional stream: %w", err)
|
||||
}
|
||||
|
||||
s.streams.Add(func(ctx context.Context) (err error) {
|
||||
return s.handleStream(ctx, &stream)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) handleStream(ctx context.Context, stream *webtransport.ReceiveStream) (err error) {
|
||||
defer func() {
|
||||
if err != nil {
|
||||
stream.CancelRead(1)
|
||||
}
|
||||
}()
|
||||
|
||||
var header [8]byte
|
||||
for {
|
||||
_, err = io.ReadFull(stream, header[:])
|
||||
if errors.Is(io.EOF, err) {
|
||||
return nil
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("failed to read atom header: %w", err)
|
||||
}
|
||||
|
||||
size := binary.BigEndian.Uint32(header[0:4])
|
||||
name := string(header[4:8])
|
||||
|
||||
if size < 8 {
|
||||
return fmt.Errorf("atom size is too small")
|
||||
} else if size > 42069 { // arbitrary limit
|
||||
return fmt.Errorf("atom size is too large")
|
||||
} else if name != "warp" {
|
||||
return fmt.Errorf("only warp atoms are supported")
|
||||
}
|
||||
|
||||
payload := make([]byte, size-8)
|
||||
|
||||
_, err = io.ReadFull(stream, payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read atom payload: %w", err)
|
||||
}
|
||||
|
||||
msg := Message{}
|
||||
|
||||
err = json.Unmarshal(payload, &msg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to decode json payload: %w", err)
|
||||
}
|
||||
|
||||
if msg.Throttle != nil {
|
||||
s.setThrottle(msg.Throttle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) runAudio(ctx context.Context) (err error) {
|
||||
init, err := s.audio.Init(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch init segment: %w", err)
|
||||
}
|
||||
|
||||
// NOTE: Assumes a single init segment
|
||||
err = s.writeInit(ctx, init, 1)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write init stream: %w", err)
|
||||
}
|
||||
|
||||
for {
|
||||
segment, err := s.audio.Segment(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get next segment: %w", err)
|
||||
}
|
||||
|
||||
if segment == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
err = s.writeSegment(ctx, segment, 1)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write segment stream: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Session) runVideo(ctx context.Context) (err error) {
|
||||
init, err := s.video.Init(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch init segment: %w", err)
|
||||
}
|
||||
|
||||
// NOTE: Assumes a single init segment
|
||||
err = s.writeInit(ctx, init, 2)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write init stream: %w", err)
|
||||
}
|
||||
|
||||
for {
|
||||
segment, err := s.video.Segment(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get next segment: %w", err)
|
||||
}
|
||||
|
||||
if segment == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
err = s.writeSegment(ctx, segment, 2)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write segment stream: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a stream for an INIT segment and write the container.
|
||||
func (s *Session) writeInit(ctx context.Context, init *MediaInit, id int) (err error) {
|
||||
temp, err := s.inner.OpenUniStreamSync(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create stream: %w", err)
|
||||
}
|
||||
|
||||
// Wrap the stream in an object that buffers writes instead of blocking.
|
||||
stream := NewStream(temp)
|
||||
s.streams.Add(stream.Run)
|
||||
|
||||
defer func() {
|
||||
if err != nil {
|
||||
stream.WriteCancel(1)
|
||||
}
|
||||
}()
|
||||
|
||||
stream.SetPriority(math.MaxInt)
|
||||
|
||||
err = stream.WriteMessage(Message{
|
||||
Init: &MessageInit{Id: id},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write init header: %w", err)
|
||||
}
|
||||
|
||||
_, err = stream.Write(init.Raw)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write init data: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create a stream for a segment and write the contents, chunk by chunk.
|
||||
func (s *Session) writeSegment(ctx context.Context, segment *MediaSegment, init int) (err error) {
|
||||
temp, err := s.inner.OpenUniStreamSync(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create stream: %w", err)
|
||||
}
|
||||
|
||||
// Wrap the stream in an object that buffers writes instead of blocking.
|
||||
stream := NewStream(temp)
|
||||
s.streams.Add(stream.Run)
|
||||
|
||||
defer func() {
|
||||
if err != nil {
|
||||
stream.WriteCancel(1)
|
||||
}
|
||||
}()
|
||||
|
||||
ms := int(segment.timestamp / time.Millisecond)
|
||||
|
||||
// newer segments take priority
|
||||
stream.SetPriority(ms)
|
||||
|
||||
err = stream.WriteMessage(Message{
|
||||
Segment: &MessageSegment{
|
||||
Init: init,
|
||||
Timestamp: ms,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write segment header: %w", err)
|
||||
}
|
||||
|
||||
for {
|
||||
// Get the next fragment
|
||||
buf, err := segment.Read(ctx)
|
||||
if errors.Is(err, io.EOF) {
|
||||
break
|
||||
} else if err != nil {
|
||||
return fmt.Errorf("failed to read segment data: %w", err)
|
||||
}
|
||||
|
||||
// NOTE: This won't block because of our wrapper
|
||||
_, err = stream.Write(buf)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write segment data: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
err = stream.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to close segemnt stream: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Session) setThrottle(msg *MessageThrottle) {
|
||||
s.socket.SetWriteRate(msg.Rate)
|
||||
s.socket.SetWriteBuffer(msg.Buffer)
|
||||
s.socket.SetWriteLoss(msg.Loss)
|
||||
}
|
|
@ -0,0 +1,264 @@
|
|||
package warp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"net"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/kixelated/invoker"
|
||||
)
|
||||
|
||||
// Perform network simulation in-process to make a simpler demo.
|
||||
// You should not use this in production; there are much better ways to throttle a network.
|
||||
type Socket struct {
|
||||
inner *net.UDPConn
|
||||
|
||||
writeRate int // bytes per second
|
||||
writeErr error // return this error on all future writes
|
||||
|
||||
writeQueue []packet // packets ready to be sent
|
||||
writeQueueSize int // number of bytes in the queue
|
||||
writeQueueMax int // number of bytes allowed in the queue
|
||||
|
||||
writeLastTime time.Time
|
||||
writeLastSize int
|
||||
|
||||
writeLoss float64 // packet loss percentage
|
||||
|
||||
writeNotify chan struct{} // closed when rate or queue is changed
|
||||
writeMutex sync.Mutex
|
||||
}
|
||||
|
||||
type packet struct {
|
||||
Addr net.Addr
|
||||
Data []byte
|
||||
}
|
||||
|
||||
func NewSocket(addr string) (s *Socket, err error) {
|
||||
s = new(Socket)
|
||||
|
||||
uaddr, err := net.ResolveUDPAddr("udp", addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to resolve addr: %w", err)
|
||||
}
|
||||
|
||||
s.inner, err = net.ListenUDP("udp", uaddr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to listen: %w", err)
|
||||
}
|
||||
|
||||
s.writeNotify = make(chan struct{})
|
||||
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (s *Socket) ReadFrom(p []byte) (n int, addr net.Addr, err error) {
|
||||
// TODO throttle reads?
|
||||
return s.inner.ReadFrom(p)
|
||||
}
|
||||
|
||||
// Queue up packets to be sent
|
||||
func (s *Socket) WriteTo(p []byte, addr net.Addr) (n int, err error) {
|
||||
s.writeMutex.Lock()
|
||||
defer s.writeMutex.Unlock()
|
||||
|
||||
if s.writeErr != nil {
|
||||
return 0, s.writeErr
|
||||
}
|
||||
|
||||
if s.writeQueueMax > 0 && s.writeQueueSize+len(p) > s.writeQueueMax {
|
||||
// Gotta drop the packet
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
if len(s.writeQueue) == 0 && s.writeRate == 0 {
|
||||
// If there's no queue and no throttling, write directly
|
||||
if s.writeLoss == 0 || rand.Float64() >= s.writeLoss {
|
||||
_, err = s.inner.WriteTo(p, addr)
|
||||
if err != nil {
|
||||
s.writeErr = err
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// Make a copy of the packet
|
||||
pc := packet{
|
||||
Addr: addr,
|
||||
Data: append([]byte{}, p...),
|
||||
}
|
||||
|
||||
if len(s.writeQueue) == 0 {
|
||||
// Wakeup the writer goroutine.
|
||||
close(s.writeNotify)
|
||||
s.writeNotify = make(chan struct{})
|
||||
}
|
||||
|
||||
s.writeQueue = append(s.writeQueue, pc)
|
||||
s.writeQueueSize += len(p)
|
||||
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
// Perform the writing in another goroutine.
|
||||
func (s *Socket) runWrite(ctx context.Context) (err error) {
|
||||
timer := time.NewTimer(time.Second)
|
||||
timer.Stop()
|
||||
|
||||
s.writeMutex.Lock()
|
||||
defer s.writeMutex.Unlock()
|
||||
|
||||
for {
|
||||
// Lock is held at the start of the loop
|
||||
|
||||
lastTime := s.writeLastTime
|
||||
lastSize := s.writeLastSize
|
||||
rate := s.writeRate
|
||||
notify := s.writeNotify
|
||||
ready := len(s.writeQueue) > 0
|
||||
|
||||
if !ready {
|
||||
// Unlock while we wait for changes.
|
||||
s.writeMutex.Unlock()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
s.writeMutex.Lock() // gotta lock again just for the defer...
|
||||
return ctx.Err()
|
||||
case <-notify:
|
||||
// Something changed, try again
|
||||
s.writeMutex.Lock()
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
if lastSize > 0 && rate > 0 {
|
||||
// Compute the amount of time it should take to send lastSize bytes
|
||||
delay := time.Second * time.Duration(lastSize) / time.Duration(rate)
|
||||
next := lastTime.Add(delay)
|
||||
|
||||
delay = next.Sub(now)
|
||||
if delay > 0 {
|
||||
// Unlock while we sleep.
|
||||
s.writeMutex.Unlock()
|
||||
|
||||
// Reuse the timer instance
|
||||
// No need to drain the timer beforehand
|
||||
timer.Reset(delay)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
s.writeMutex.Lock() // gotta lock again just for the defer...
|
||||
return ctx.Err()
|
||||
case <-timer.C:
|
||||
now = next
|
||||
s.writeMutex.Lock()
|
||||
case <-notify:
|
||||
// Something changed, try again
|
||||
if !timer.Stop() {
|
||||
// Drain the timer
|
||||
<-timer.C
|
||||
}
|
||||
|
||||
s.writeMutex.Lock()
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Send the first packet in the queue
|
||||
p := s.writeQueue[0]
|
||||
s.writeQueue = s.writeQueue[1:]
|
||||
s.writeQueueSize -= len(p.Data)
|
||||
s.writeLastTime = now
|
||||
s.writeLastSize = len(p.Data)
|
||||
|
||||
loss := s.writeLoss
|
||||
|
||||
if loss > 0 || rand.Float64() >= loss {
|
||||
_, err = s.inner.WriteTo(p.Data, p.Addr)
|
||||
if err != nil {
|
||||
s.writeErr = err
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set the number of *bytes* that can be written within a second, or -1 for unlimited.
|
||||
// Defaults to unlimited.
|
||||
func (s *Socket) SetWriteRate(rate int) {
|
||||
s.writeMutex.Lock()
|
||||
defer s.writeMutex.Unlock()
|
||||
|
||||
s.writeRate = rate
|
||||
close(s.writeNotify)
|
||||
s.writeNotify = make(chan struct{})
|
||||
}
|
||||
|
||||
// Set the maximum number of bytes to queue before we drop packets.
|
||||
// Defaults to unlimited (!)
|
||||
func (s *Socket) SetWriteBuffer(size int) {
|
||||
s.writeMutex.Lock()
|
||||
defer s.writeMutex.Unlock()
|
||||
|
||||
s.writeQueueMax = size
|
||||
if s.writeQueueMax > 0 {
|
||||
// Remove from the queue until the limit has been met
|
||||
for s.writeQueueSize > s.writeQueueMax {
|
||||
last := s.writeQueue[len(s.writeQueue)-1]
|
||||
s.writeQueue = s.writeQueue[:len(s.writeQueue)-1]
|
||||
s.writeQueueSize -= len(last.Data)
|
||||
}
|
||||
}
|
||||
|
||||
close(s.writeNotify)
|
||||
s.writeNotify = make(chan struct{})
|
||||
}
|
||||
|
||||
func (s *Socket) SetWriteLoss(percent float64) {
|
||||
s.writeMutex.Lock()
|
||||
defer s.writeMutex.Unlock()
|
||||
|
||||
s.writeLoss = percent
|
||||
}
|
||||
|
||||
func (s *Socket) Close() (err error) {
|
||||
return s.inner.Close()
|
||||
}
|
||||
|
||||
func (s *Socket) LocalAddr() net.Addr {
|
||||
return s.inner.LocalAddr()
|
||||
}
|
||||
|
||||
func (s *Socket) SetDeadline(t time.Time) error {
|
||||
return s.inner.SetDeadline(t)
|
||||
}
|
||||
|
||||
func (s *Socket) SetReadDeadline(t time.Time) error {
|
||||
return s.inner.SetReadDeadline(t)
|
||||
}
|
||||
|
||||
func (s *Socket) SetReadBuffer(size int) error {
|
||||
return s.inner.SetReadBuffer(size)
|
||||
}
|
||||
|
||||
func (s *Socket) SetWriteDeadline(t time.Time) error {
|
||||
return s.inner.SetWriteDeadline(t)
|
||||
}
|
||||
|
||||
func (s *Socket) SyscallConn() (syscall.RawConn, error) {
|
||||
return s.inner.SyscallConn()
|
||||
}
|
||||
|
||||
func (s *Socket) Run(ctx context.Context) (err error) {
|
||||
return invoker.Run(ctx /*s.runRead, */, s.runWrite)
|
||||
}
|
|
@ -0,0 +1,145 @@
|
|||
package warp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/adriancable/webtransport-go"
|
||||
"github.com/lucas-clemente/quic-go"
|
||||
)
|
||||
|
||||
// Wrapper around quic.SendStream to make Write non-blocking.
|
||||
// Otherwise we can't write to multiple concurrent streams in the same goroutine.
|
||||
type Stream struct {
|
||||
inner webtransport.SendStream
|
||||
|
||||
chunks [][]byte
|
||||
closed bool
|
||||
err error
|
||||
|
||||
notify chan struct{}
|
||||
mutex sync.Mutex
|
||||
}
|
||||
|
||||
func NewStream(inner webtransport.SendStream) (s *Stream) {
|
||||
s = new(Stream)
|
||||
s.inner = inner
|
||||
s.notify = make(chan struct{})
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Stream) Run(ctx context.Context) (err error) {
|
||||
defer func() {
|
||||
s.mutex.Lock()
|
||||
s.err = err
|
||||
s.mutex.Unlock()
|
||||
}()
|
||||
|
||||
for {
|
||||
s.mutex.Lock()
|
||||
|
||||
chunks := s.chunks
|
||||
notify := s.notify
|
||||
closed := s.closed
|
||||
|
||||
s.chunks = s.chunks[len(s.chunks):]
|
||||
s.mutex.Unlock()
|
||||
|
||||
for _, chunk := range chunks {
|
||||
_, err = s.inner.Write(chunk)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if closed {
|
||||
return s.inner.Close()
|
||||
}
|
||||
|
||||
if len(chunks) == 0 {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
case <-notify:
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Stream) Write(buf []byte) (n int, err error) {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
if s.err != nil {
|
||||
return 0, s.err
|
||||
}
|
||||
|
||||
if s.closed {
|
||||
return 0, fmt.Errorf("closed")
|
||||
}
|
||||
|
||||
// Make a copy of the buffer so it's long lived
|
||||
buf = append([]byte{}, buf...)
|
||||
s.chunks = append(s.chunks, buf)
|
||||
|
||||
// Wake up the writer
|
||||
close(s.notify)
|
||||
s.notify = make(chan struct{})
|
||||
|
||||
return len(buf), nil
|
||||
}
|
||||
|
||||
func (s *Stream) WriteMessage(msg Message) (err error) {
|
||||
payload, err := json.Marshal(msg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to marshal message: %w", err)
|
||||
}
|
||||
|
||||
var size [4]byte
|
||||
binary.BigEndian.PutUint32(size[:], uint32(len(payload)+8))
|
||||
|
||||
_, err = s.Write(size[:])
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write size: %w", err)
|
||||
}
|
||||
|
||||
_, err = s.Write([]byte("warp"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write atom header: %w", err)
|
||||
}
|
||||
|
||||
_, err = s.Write(payload)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write payload: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Stream) WriteCancel(code quic.StreamErrorCode) {
|
||||
s.inner.CancelWrite(code)
|
||||
}
|
||||
|
||||
func (s *Stream) SetPriority(prio int) {
|
||||
s.inner.SetPriority(prio)
|
||||
}
|
||||
|
||||
func (s *Stream) Close() (err error) {
|
||||
s.mutex.Lock()
|
||||
defer s.mutex.Unlock()
|
||||
|
||||
if s.err != nil {
|
||||
return s.err
|
||||
}
|
||||
|
||||
s.closed = true
|
||||
|
||||
// Wake up the writer
|
||||
close(s.notify)
|
||||
s.notify = make(chan struct{})
|
||||
|
||||
return nil
|
||||
}
|
|
@ -0,0 +1,70 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/kixelated/invoker"
|
||||
"github.com/kixelated/warp-sample/internal/warp"
|
||||
)
|
||||
|
||||
func main() {
|
||||
err := run(context.Background())
|
||||
if err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
log.Println(err)
|
||||
|
||||
var errPanic invoker.ErrPanic
|
||||
|
||||
// TODO use an interface
|
||||
if errors.As(err, &errPanic) {
|
||||
stack := string(errPanic.Stack())
|
||||
|
||||
scanner := bufio.NewScanner(strings.NewReader(stack))
|
||||
for scanner.Scan() {
|
||||
log.Println(scanner.Text())
|
||||
}
|
||||
}
|
||||
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
func run(ctx context.Context) (err error) {
|
||||
addr := flag.String("addr", ":4443", "HTTPS server address")
|
||||
cert := flag.String("tls-cert", "../cert/localhost.warp.demo.crt", "TLS certificate file path")
|
||||
key := flag.String("tls-key", "../cert/localhost.warp.demo.key", "TLS certificate file path")
|
||||
logDir := flag.String("log-dir", "", "logs will be written to the provided directory")
|
||||
|
||||
dash := flag.String("dash", "../media/fragmented.mpd", "DASH playlist path")
|
||||
|
||||
flag.Parse()
|
||||
|
||||
media, err := warp.NewMedia(*dash)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to open media: %w", err)
|
||||
}
|
||||
|
||||
config := warp.ServerConfig{
|
||||
Addr: *addr,
|
||||
CertFile: *cert,
|
||||
KeyFile: *key,
|
||||
LogDir: *logDir,
|
||||
}
|
||||
|
||||
ws, err := warp.NewServer(config, media)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create warp server: %w", err)
|
||||
}
|
||||
|
||||
log.Printf("listening on %s", *addr)
|
||||
|
||||
return invoker.Run(ctx, invoker.Interrupt, ws.Run)
|
||||
}
|
Loading…
Reference in New Issue