Initial public release.

This commit is contained in:
Luke Curley 2022-06-29 09:17:02 -07:00
commit c0a174e26a
36 changed files with 17149 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*.mp4
logs/

201
LICENSE Normal file
View File

@ -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.

13
NOTICE Normal file
View File

@ -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.

89
README.md Normal file
View File

@ -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.

2
cert/.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
*.crt
*.key

12
cert/fingerprint Executable file
View File

@ -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

19
cert/generate Executable file
View File

@ -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")

3
client/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
node_modules
.parcel-cache
dist

4235
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

13
client/package.json Normal file
View File

@ -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"
}
}

82
client/src/index.html Normal file
View File

@ -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>

59
client/src/init.ts Normal file
View File

@ -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;
}

14
client/src/message.ts Normal file
View File

@ -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
}

85
client/src/mp4.ts Normal file
View File

@ -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;
}

8247
client/src/mp4box.all.js Normal file

File diff suppressed because it is too large Load Diff

79
client/src/player.css Normal file
View File

@ -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;
}

334
client/src/player.ts Normal file
View File

@ -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];
}

146
client/src/segment.ts Normal file
View File

@ -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,
}
}
}

81
client/src/source.ts Normal file
View File

@ -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)
}
}
}
}

254
client/src/stream.ts Normal file
View File

@ -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)
}
}

124
client/src/track.ts Normal file
View File

@ -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()
}
}
}

84
client/src/types/webtransport.d.ts vendored Normal file
View File

@ -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";

4
client/src/util.ts Normal file
View File

@ -0,0 +1,4 @@
export interface TimeRange {
start: number;
end: number;
}

12
client/tsconfig.json Normal file
View File

@ -0,0 +1,12 @@
{
"include": ["src/**/*"],
"compilerOptions": {
"target": "es2021",
"strict": true,
"typeRoots": [
"src/types"
],
"allowJs": true
}
}

1354
client/yarn.lock Normal file

File diff suppressed because it is too large Load Diff

3
media/.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
*.mp4
*.mpd
*.m4s

1
server/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
logs/

38
server/go.mod Normal file
View File

@ -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

325
server/go.sum Normal file
View File

@ -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=

View File

@ -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
}

View File

@ -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
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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)
}

View File

@ -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
}

View File

@ -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)
}