commit
5ba457bf65
|
@ -17,4 +17,4 @@ go run filippo.io/mkcert -ecdsa -install
|
||||||
go run filippo.io/mkcert -ecdsa -days 10 -cert-file "$CRT" -key-file "$KEY" localhost 127.0.0.1 ::1
|
go run filippo.io/mkcert -ecdsa -days 10 -cert-file "$CRT" -key-file "$KEY" localhost 127.0.0.1 ::1
|
||||||
|
|
||||||
# Compute the sha256 fingerprint of the certificate for WebTransport
|
# Compute the sha256 fingerprint of the certificate for WebTransport
|
||||||
openssl x509 -in "$CRT" -outform der | openssl dgst -sha256
|
openssl x509 -in "$CRT" -outform der | openssl dgst -sha256 > ../player/src/transport/fingerprint.hex
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
module.exports = function (app) {
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
|
||||||
|
res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
};
|
File diff suppressed because it is too large
Load Diff
|
@ -6,8 +6,14 @@
|
||||||
"check": "tsc --noEmit"
|
"check": "tsc --noEmit"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@parcel/transformer-inline-string": "2.8.3",
|
||||||
"@parcel/validator-typescript": "^2.6.0",
|
"@parcel/validator-typescript": "^2.6.0",
|
||||||
"parcel": "^2.6.0",
|
"@types/audioworklet": "^0.0.41",
|
||||||
|
"@types/dom-webcodecs": "^0.1.6",
|
||||||
|
"parcel": "^2.8.0",
|
||||||
"typescript": ">=3.0.0"
|
"typescript": ">=3.0.0"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"mp4box": "^0.5.2"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,121 @@
|
||||||
|
import * as Message from "./message";
|
||||||
|
import * as MP4 from "../mp4"
|
||||||
|
import * as Stream from "../stream"
|
||||||
|
import * as Util from "../util"
|
||||||
|
|
||||||
|
import Renderer from "./renderer"
|
||||||
|
|
||||||
|
export default class Decoder {
|
||||||
|
// Store the init message for each track
|
||||||
|
tracks: Map<string, Util.Deferred<Message.Init>>;
|
||||||
|
decoder: AudioDecoder; // TODO one per track
|
||||||
|
sync: Message.Sync;
|
||||||
|
|
||||||
|
constructor(config: Message.Config, renderer: Renderer) {
|
||||||
|
this.tracks = new Map();
|
||||||
|
|
||||||
|
this.decoder = new AudioDecoder({
|
||||||
|
output: renderer.emit.bind(renderer),
|
||||||
|
error: console.warn,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
init(msg: Message.Init) {
|
||||||
|
let defer = this.tracks.get(msg.track);
|
||||||
|
if (!defer) {
|
||||||
|
defer = new Util.Deferred()
|
||||||
|
this.tracks.set(msg.track, defer)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.info.audioTracks.length != 1 || msg.info.videoTracks.length != 0) {
|
||||||
|
throw new Error("Expected a single audio track")
|
||||||
|
}
|
||||||
|
|
||||||
|
const track = msg.info.audioTracks[0]
|
||||||
|
const audio = track.audio
|
||||||
|
|
||||||
|
defer.resolve(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
async decode(msg: Message.Segment) {
|
||||||
|
let track = this.tracks.get(msg.track);
|
||||||
|
if (!track) {
|
||||||
|
track = new Util.Deferred()
|
||||||
|
this.tracks.set(msg.track, track)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the init segment to be fully received and parsed
|
||||||
|
const init = await track.promise;
|
||||||
|
const audio = init.info.audioTracks[0]
|
||||||
|
|
||||||
|
if (this.decoder.state == "unconfigured") {
|
||||||
|
this.decoder.configure({
|
||||||
|
codec: audio.codec,
|
||||||
|
numberOfChannels: audio.audio.channel_count,
|
||||||
|
sampleRate: audio.audio.sample_rate,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = MP4.New();
|
||||||
|
|
||||||
|
input.onSamples = (id: number, user: any, samples: MP4.Sample[]) => {
|
||||||
|
for (let sample of samples) {
|
||||||
|
// Convert to microseconds
|
||||||
|
const timestamp = 1000 * 1000 * sample.dts / sample.timescale
|
||||||
|
const duration = 1000 * 1000 * sample.duration / sample.timescale
|
||||||
|
|
||||||
|
// This assumes that timescale == sample rate
|
||||||
|
this.decoder.decode(new EncodedAudioChunk({
|
||||||
|
type: sample.is_sync ? "key" : "delta",
|
||||||
|
data: sample.data,
|
||||||
|
duration: duration,
|
||||||
|
timestamp: timestamp,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input.onReady = (info: any) => {
|
||||||
|
input.setExtractionOptions(info.tracks[0].id, {}, { nbSamples: 1 });
|
||||||
|
input.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
// MP4box requires us to reparse the init segment unfortunately
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
for (let raw of init.raw) {
|
||||||
|
raw.fileStart = offset
|
||||||
|
input.appendBuffer(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = new Stream.Reader(msg.reader, msg.buffer)
|
||||||
|
|
||||||
|
/* 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
|
||||||
|
|
||||||
|
input.appendBuffer(data)
|
||||||
|
input.flush()
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
// Make a copy of the atom because mp4box only accepts an ArrayBuffer unfortunately
|
||||||
|
let box = new Uint8Array(atom.byteLength);
|
||||||
|
box.set(atom)
|
||||||
|
|
||||||
|
// and for some reason we need to modify the underlying ArrayBuffer with offset
|
||||||
|
let buffer = box.buffer as MP4.ArrayBuffer
|
||||||
|
buffer.fileStart = offset
|
||||||
|
|
||||||
|
// Parse the data
|
||||||
|
offset = input.appendBuffer(buffer)
|
||||||
|
input.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,77 @@
|
||||||
|
import * as Message from "./message"
|
||||||
|
import Renderer from "./renderer"
|
||||||
|
import Decoder from "./decoder"
|
||||||
|
|
||||||
|
import { RingInit } from "./ring"
|
||||||
|
|
||||||
|
// Abstracts the Worker and Worklet into a simpler API
|
||||||
|
// This class must be created on the main thread due to AudioContext.
|
||||||
|
export default class Audio {
|
||||||
|
context: AudioContext;
|
||||||
|
worker: Worker;
|
||||||
|
worklet: Promise<AudioWorkletNode>;
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Assume 44.1kHz and two audio channels
|
||||||
|
const config = {
|
||||||
|
sampleRate: 44100,
|
||||||
|
ring: new RingInit(2, 4410), // 100ms at 44.1khz
|
||||||
|
}
|
||||||
|
|
||||||
|
this.context = new AudioContext({
|
||||||
|
latencyHint: "interactive",
|
||||||
|
sampleRate: config.sampleRate,
|
||||||
|
})
|
||||||
|
|
||||||
|
this.worker = this.setupWorker(config)
|
||||||
|
this.worklet = this.setupWorklet(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupWorker(config: Message.Config): Worker {
|
||||||
|
const url = new URL('worker.ts', import.meta.url)
|
||||||
|
const worker = new Worker(url, {
|
||||||
|
name: "audio",
|
||||||
|
type: "module",
|
||||||
|
})
|
||||||
|
|
||||||
|
worker.postMessage({ config })
|
||||||
|
|
||||||
|
return worker
|
||||||
|
}
|
||||||
|
|
||||||
|
private async setupWorklet(config: Message.Config): Promise<AudioWorkletNode> {
|
||||||
|
// Load the worklet source code.
|
||||||
|
const url = new URL('worklet.ts', import.meta.url)
|
||||||
|
await this.context.audioWorklet.addModule(url)
|
||||||
|
|
||||||
|
const volume = this.context.createGain()
|
||||||
|
volume.gain.value = 2.0;
|
||||||
|
|
||||||
|
// Create a worklet
|
||||||
|
const worklet = new AudioWorkletNode(this.context, 'renderer');
|
||||||
|
worklet.onprocessorerror = (e: Event) => {
|
||||||
|
console.error("Audio worklet error:", e)
|
||||||
|
};
|
||||||
|
|
||||||
|
worklet.port.postMessage({ config })
|
||||||
|
|
||||||
|
// Connect the worklet to the volume node and then to the speakers
|
||||||
|
worklet.connect(volume)
|
||||||
|
volume.connect(this.context.destination)
|
||||||
|
|
||||||
|
return worklet
|
||||||
|
}
|
||||||
|
|
||||||
|
init(init: Message.Init) {
|
||||||
|
this.worker.postMessage({ init })
|
||||||
|
}
|
||||||
|
|
||||||
|
segment(segment: Message.Segment) {
|
||||||
|
this.worker.postMessage({ segment }, [ segment.buffer.buffer, segment.reader ])
|
||||||
|
}
|
||||||
|
|
||||||
|
play(play: Message.Play) {
|
||||||
|
this.context.resume()
|
||||||
|
//this.worker.postMessage({ play })
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,30 @@
|
||||||
|
import * as MP4 from "../mp4"
|
||||||
|
import { RingInit } from "./ring"
|
||||||
|
|
||||||
|
export interface Config {
|
||||||
|
sampleRate: number;
|
||||||
|
ring: RingInit;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Init {
|
||||||
|
track: string;
|
||||||
|
info: MP4.Info;
|
||||||
|
raw: MP4.ArrayBuffer[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Segment {
|
||||||
|
track: string;
|
||||||
|
buffer: Uint8Array; // unread buffered data
|
||||||
|
reader: ReadableStream; // unread unbuffered data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Audio tells video when the given timestamp should be rendered.
|
||||||
|
export interface Sync {
|
||||||
|
origin: number;
|
||||||
|
clock: DOMHighResTimeStamp;
|
||||||
|
timestamp: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Play {
|
||||||
|
timestamp?: number;
|
||||||
|
}
|
|
@ -0,0 +1,85 @@
|
||||||
|
import * as Message from "./message"
|
||||||
|
import { Ring } from "./ring"
|
||||||
|
|
||||||
|
export default class Renderer {
|
||||||
|
ring: Ring;
|
||||||
|
queue: Array<AudioData>;
|
||||||
|
sync?: DOMHighResTimeStamp
|
||||||
|
running: number;
|
||||||
|
|
||||||
|
constructor(config: Message.Config) {
|
||||||
|
this.ring = new Ring(config.ring)
|
||||||
|
this.queue = [];
|
||||||
|
this.running = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(frame: AudioData) {
|
||||||
|
if (!this.sync) {
|
||||||
|
// Save the frame as the sync point
|
||||||
|
this.sync = 1000 * performance.now() - frame.timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert the frame into the queue sorted by timestamp.
|
||||||
|
if (this.queue.length > 0 && this.queue[this.queue.length-1].timestamp <= frame.timestamp) {
|
||||||
|
// Fast path because we normally append to the end.
|
||||||
|
this.queue.push(frame)
|
||||||
|
} else {
|
||||||
|
// Do a full binary search
|
||||||
|
let low = 0
|
||||||
|
let high = this.queue.length;
|
||||||
|
|
||||||
|
while (low < high) {
|
||||||
|
var mid = (low + high) >>> 1;
|
||||||
|
if (this.queue[mid].timestamp < frame.timestamp) low = mid + 1;
|
||||||
|
else high = mid;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.queue.splice(low, 0, frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.running) {
|
||||||
|
// Wait for the next animation frame
|
||||||
|
this.running = self.requestAnimationFrame(this.render.bind(this))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
// Determine the target timestamp.
|
||||||
|
const target = 1000 * performance.now() - this.sync!
|
||||||
|
|
||||||
|
// Check if we should skip some frames
|
||||||
|
while (this.queue.length) {
|
||||||
|
const next = this.queue[0]
|
||||||
|
if (next.timestamp >= target) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
console.warn("dropping audio")
|
||||||
|
|
||||||
|
this.queue.shift()
|
||||||
|
next.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push as many as we can to the ring buffer.
|
||||||
|
while (this.queue.length) {
|
||||||
|
let frame = this.queue[0]
|
||||||
|
let ok = this.ring.write(frame)
|
||||||
|
if (!ok) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
frame.close()
|
||||||
|
this.queue.shift()
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.queue.length) {
|
||||||
|
this.running = self.requestAnimationFrame(this.render.bind(this))
|
||||||
|
} else {
|
||||||
|
this.running = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
play(play: Message.Play) {
|
||||||
|
this.ring.reset()
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,143 @@
|
||||||
|
// Ring buffer with audio samples.
|
||||||
|
|
||||||
|
enum STATE {
|
||||||
|
READ_INDEX = 0, // Index of the current read position (mod capacity)
|
||||||
|
WRITE_INDEX, // Index of the current write position (mod capacity)
|
||||||
|
LENGTH // Clever way of saving the total number of enums values.
|
||||||
|
}
|
||||||
|
|
||||||
|
export class Ring {
|
||||||
|
state: Int32Array;
|
||||||
|
channels: Float32Array[];
|
||||||
|
capacity: number;
|
||||||
|
|
||||||
|
constructor(init: RingInit) {
|
||||||
|
this.state = new Int32Array(init.state)
|
||||||
|
|
||||||
|
this.channels = []
|
||||||
|
for (let channel of init.channels) {
|
||||||
|
this.channels.push(new Float32Array(channel))
|
||||||
|
}
|
||||||
|
|
||||||
|
this.capacity = init.capacity
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the samples for single audio frame
|
||||||
|
write(frame: AudioData): boolean {
|
||||||
|
let count = frame.numberOfFrames;
|
||||||
|
|
||||||
|
let readIndex = Atomics.load(this.state, STATE.READ_INDEX)
|
||||||
|
let writeIndex = Atomics.load(this.state, STATE.WRITE_INDEX)
|
||||||
|
let writeIndexNew = writeIndex + count;
|
||||||
|
|
||||||
|
// There's not enough space in the ring buffer
|
||||||
|
if (writeIndexNew - readIndex > this.capacity) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
let startIndex = writeIndex % this.capacity;
|
||||||
|
let endIndex = writeIndexNew % this.capacity;
|
||||||
|
|
||||||
|
// Loop over each channel
|
||||||
|
for (let i = 0; i < this.channels.length; i += 1) {
|
||||||
|
const channel = this.channels[i]
|
||||||
|
|
||||||
|
if (startIndex < endIndex) {
|
||||||
|
// One continuous range to copy.
|
||||||
|
const full = channel.subarray(startIndex, endIndex)
|
||||||
|
|
||||||
|
frame.copyTo(full, {
|
||||||
|
planeIndex: i,
|
||||||
|
frameCount: count,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
const first = channel.subarray(startIndex)
|
||||||
|
const second = channel.subarray(0, endIndex)
|
||||||
|
|
||||||
|
frame.copyTo(first, {
|
||||||
|
planeIndex: i,
|
||||||
|
frameCount: first.length,
|
||||||
|
})
|
||||||
|
|
||||||
|
frame.copyTo(second, {
|
||||||
|
planeIndex: i,
|
||||||
|
frameOffset: first.length,
|
||||||
|
frameCount: second.length,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Atomics.store(this.state, STATE.WRITE_INDEX, writeIndexNew)
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
read(dst: Float32Array[]) {
|
||||||
|
let readIndex = Atomics.load(this.state, STATE.READ_INDEX)
|
||||||
|
let writeIndex = Atomics.load(this.state, STATE.WRITE_INDEX)
|
||||||
|
if (readIndex >= writeIndex) {
|
||||||
|
// nothing to read
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let readIndexNew = readIndex + dst[0].length
|
||||||
|
if (readIndexNew > writeIndex) {
|
||||||
|
// Partial read
|
||||||
|
readIndexNew = writeIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
let startIndex = readIndex % this.capacity;
|
||||||
|
let endIndex = readIndexNew % this.capacity;
|
||||||
|
|
||||||
|
// Loop over each channel
|
||||||
|
for (let i = 0; i < dst.length; i += 1) {
|
||||||
|
if (i >= this.channels.length) {
|
||||||
|
// ignore excess channels
|
||||||
|
}
|
||||||
|
|
||||||
|
const input = this.channels[i]
|
||||||
|
const output = dst[i]
|
||||||
|
|
||||||
|
if (startIndex < endIndex) {
|
||||||
|
const full = input.subarray(startIndex, endIndex)
|
||||||
|
output.set(full)
|
||||||
|
} else {
|
||||||
|
const first = input.subarray(startIndex)
|
||||||
|
const second = input.subarray(0, endIndex)
|
||||||
|
|
||||||
|
output.set(first)
|
||||||
|
output.set(second, first.length)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Atomics.store(this.state, STATE.READ_INDEX, readIndexNew)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO not thread safe
|
||||||
|
clear() {
|
||||||
|
const writeIndex = Atomics.load(this.state, STATE.WRITE_INDEX)
|
||||||
|
Atomics.store(this.state, STATE.READ_INDEX, writeIndex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// No prototype to make this easier to send via postMessage
|
||||||
|
export class RingInit {
|
||||||
|
state: SharedArrayBuffer;
|
||||||
|
|
||||||
|
channels: SharedArrayBuffer[];
|
||||||
|
capacity: number;
|
||||||
|
|
||||||
|
constructor(channels: number, capacity: number) {
|
||||||
|
// Store the current state in a separate ring buffer.
|
||||||
|
this.state = new SharedArrayBuffer(STATE.LENGTH * Int32Array.BYTES_PER_ELEMENT)
|
||||||
|
|
||||||
|
// Create a buffer for each audio channel
|
||||||
|
this.channels = []
|
||||||
|
for (let i = 0; i < channels; i += 1) {
|
||||||
|
const buffer = new SharedArrayBuffer(capacity * Float32Array.BYTES_PER_ELEMENT)
|
||||||
|
this.channels.push(buffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
this.capacity = capacity
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,26 @@
|
||||||
|
import Decoder from "./decoder"
|
||||||
|
import Renderer from "./renderer"
|
||||||
|
|
||||||
|
import * as Message from "./message"
|
||||||
|
|
||||||
|
let decoder: Decoder
|
||||||
|
let renderer: Renderer;
|
||||||
|
|
||||||
|
self.addEventListener('message', (e: MessageEvent) => {
|
||||||
|
if (e.data.config) {
|
||||||
|
renderer = new Renderer(e.data.config)
|
||||||
|
decoder = new Decoder(e.data.config, renderer)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.data.init) {
|
||||||
|
decoder.init(e.data.init)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.data.segment) {
|
||||||
|
decoder.decode(e.data.segment)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.data.play) {
|
||||||
|
renderer.play(e.data.play)
|
||||||
|
}
|
||||||
|
})
|
|
@ -0,0 +1,49 @@
|
||||||
|
// This is an AudioWorklet that acts as a media source.
|
||||||
|
// The renderer copies audio samples to a ring buffer read by this worklet.
|
||||||
|
// The worklet then outputs those samples to emit audio.
|
||||||
|
|
||||||
|
import * as Message from "./message"
|
||||||
|
|
||||||
|
import { Ring } from "./ring"
|
||||||
|
|
||||||
|
class Renderer extends AudioWorkletProcessor {
|
||||||
|
ring?: Ring;
|
||||||
|
base: number;
|
||||||
|
|
||||||
|
constructor(params: AudioWorkletNodeOptions) {
|
||||||
|
// The super constructor call is required.
|
||||||
|
super();
|
||||||
|
|
||||||
|
this.base = 0
|
||||||
|
this.port.onmessage = this.onMessage.bind(this)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMessage(e: MessageEvent) {
|
||||||
|
if (e.data.config) {
|
||||||
|
this.config(e.data.config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config(config: Message.Config) {
|
||||||
|
this.ring = new Ring(config.ring)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inputs and outputs in groups of 128 samples.
|
||||||
|
process(inputs: Float32Array[][], outputs: Float32Array[][], parameters: Record<string, Float32Array>): boolean {
|
||||||
|
if (!this.ring) {
|
||||||
|
// Not initialized yet
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inputs.length != 1 && outputs.length != 1) {
|
||||||
|
throw new Error("only a single track is supported")
|
||||||
|
}
|
||||||
|
|
||||||
|
const output = outputs[0]
|
||||||
|
this.ring.read(output)
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerProcessor("renderer", Renderer);
|
|
@ -16,7 +16,7 @@ body {
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
#play {
|
#screen #play {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
@ -29,12 +29,6 @@ body {
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
#vid {
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
max-height: 100vh;
|
|
||||||
}
|
|
||||||
|
|
||||||
#controls {
|
#controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
|
@ -11,8 +11,8 @@
|
||||||
<body>
|
<body>
|
||||||
<div id="player">
|
<div id="player">
|
||||||
<div id="screen">
|
<div id="screen">
|
||||||
<div id="play"><span>click to play</span></div>
|
<div id="play"><span>click for audio</span></div>
|
||||||
<video id="vid" controls></video>
|
<canvas id="video" width="1280" height="720"></canvas>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="controls">
|
<div id="controls">
|
||||||
|
|
|
@ -1,48 +1,23 @@
|
||||||
import { Player } from "./player"
|
import Player from "./player"
|
||||||
|
|
||||||
// This is so ghetto but I'm too lazy to improve it right now
|
|
||||||
const videoRef = document.querySelector<HTMLVideoElement>("video#vid")!;
|
|
||||||
const liveRef = document.querySelector<HTMLElement>("#live")!;
|
|
||||||
const throttleRef = document.querySelector<HTMLElement>("#throttle")!;
|
|
||||||
const statsRef = document.querySelector<HTMLElement>("#stats")!;
|
|
||||||
const playRef = document.querySelector<HTMLElement>("#play")!;
|
|
||||||
|
|
||||||
const params = new URLSearchParams(window.location.search)
|
const params = new URLSearchParams(window.location.search)
|
||||||
|
|
||||||
const url = params.get("url") || "https://localhost:4443/watch"
|
const url = params.get("url") || "https://localhost:4443/watch"
|
||||||
|
const canvas = document.querySelector<HTMLCanvasElement>("canvas#video")!
|
||||||
|
|
||||||
const player = new Player({
|
const player = new Player({
|
||||||
url: url,
|
url: url,
|
||||||
videoRef: videoRef,
|
canvas: canvas,
|
||||||
statsRef: statsRef,
|
|
||||||
throttleRef: throttleRef,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
liveRef.addEventListener("click", (e) => {
|
const play = document.querySelector<HTMLElement>("#screen #play")!
|
||||||
|
|
||||||
|
let playFunc = (e: Event) => {
|
||||||
|
player.play()
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
player.goLive()
|
|
||||||
})
|
|
||||||
|
|
||||||
throttleRef.addEventListener("click", (e) => {
|
play.removeEventListener('click', playFunc)
|
||||||
e.preventDefault()
|
play.style.display = "none"
|
||||||
player.throttle()
|
|
||||||
})
|
|
||||||
|
|
||||||
playRef.addEventListener('click', (e) => {
|
|
||||||
videoRef.play()
|
|
||||||
e.preventDefault()
|
|
||||||
})
|
|
||||||
|
|
||||||
function playFunc(e: Event) {
|
|
||||||
playRef.style.display = "none"
|
|
||||||
//player.goLive()
|
|
||||||
|
|
||||||
// Only fire once to restore pause/play functionality
|
|
||||||
videoRef.removeEventListener('play', playFunc)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
videoRef.addEventListener('play', playFunc)
|
play.addEventListener('click', playFunc)
|
||||||
videoRef.volume = 0.5
|
|
||||||
|
|
||||||
// Try to autoplay but ignore errors on mobile; they need to click
|
|
||||||
//vidRef.play().catch((e) => console.warn(e))
|
|
|
@ -1,85 +0,0 @@
|
||||||
// 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;
|
|
||||||
}
|
|
|
@ -0,0 +1,11 @@
|
||||||
|
// Rename some stuff so it's on brand.
|
||||||
|
export {
|
||||||
|
createFile as New,
|
||||||
|
MP4File as File,
|
||||||
|
MP4ArrayBuffer as ArrayBuffer,
|
||||||
|
MP4Info as Info,
|
||||||
|
DataStream as Stream,
|
||||||
|
Sample,
|
||||||
|
} from "mp4box"
|
||||||
|
|
||||||
|
export { Init, InitParser } from "./init"
|
|
@ -1,24 +1,28 @@
|
||||||
import { MP4New, MP4File, MP4ArrayBuffer, MP4Info } from "./mp4"
|
import * as MP4 from "./index"
|
||||||
|
|
||||||
|
export interface Init {
|
||||||
|
raw: MP4.ArrayBuffer;
|
||||||
|
info: MP4.Info;
|
||||||
|
}
|
||||||
|
|
||||||
export class InitParser {
|
export class InitParser {
|
||||||
mp4box: MP4File;
|
mp4box: MP4.File;
|
||||||
offset: number;
|
offset: number;
|
||||||
|
|
||||||
raw: MP4ArrayBuffer[];
|
raw: MP4.ArrayBuffer[];
|
||||||
ready: Promise<Init>;
|
info: Promise<MP4.Info>;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.mp4box = MP4New()
|
this.mp4box = MP4.New()
|
||||||
|
|
||||||
this.raw = []
|
this.raw = []
|
||||||
this.offset = 0
|
this.offset = 0
|
||||||
|
|
||||||
// Create a promise that gets resolved once the init segment has been parsed.
|
// Create a promise that gets resolved once the init segment has been parsed.
|
||||||
this.ready = new Promise((resolve, reject) => {
|
this.info = new Promise((resolve, reject) => {
|
||||||
this.mp4box.onError = reject
|
this.mp4box.onError = reject
|
||||||
|
|
||||||
// https://github.com/gpac/mp4box.js#onreadyinfo
|
// https://github.com/gpac/mp4box.js#onreadyinfo
|
||||||
this.mp4box.onReady = (info: MP4Info) => {
|
this.mp4box.onReady = (info: MP4.Info) => {
|
||||||
if (!info.isFragmented) {
|
if (!info.isFragmented) {
|
||||||
reject("expected a fragmented mp4")
|
reject("expected a fragmented mp4")
|
||||||
}
|
}
|
||||||
|
@ -27,10 +31,7 @@ export class InitParser {
|
||||||
reject("expected a single track")
|
reject("expected a single track")
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve({
|
resolve(info)
|
||||||
info: info,
|
|
||||||
raw: this.raw,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -41,7 +42,7 @@ export class InitParser {
|
||||||
box.set(data)
|
box.set(data)
|
||||||
|
|
||||||
// and for some reason we need to modify the underlying ArrayBuffer with fileStart
|
// and for some reason we need to modify the underlying ArrayBuffer with fileStart
|
||||||
let buffer = box.buffer as MP4ArrayBuffer
|
let buffer = box.buffer as MP4.ArrayBuffer
|
||||||
buffer.fileStart = this.offset
|
buffer.fileStart = this.offset
|
||||||
|
|
||||||
// Parse the data
|
// Parse the data
|
||||||
|
@ -52,8 +53,3 @@ export class InitParser {
|
||||||
this.raw.push(buffer)
|
this.raw.push(buffer)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Init {
|
|
||||||
raw: MP4ArrayBuffer[];
|
|
||||||
info: MP4Info;
|
|
||||||
}
|
|
|
@ -0,0 +1,148 @@
|
||||||
|
// https://github.com/gpac/mp4box.js/issues/233
|
||||||
|
|
||||||
|
declare module "mp4box" {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MP4VideoData {
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MP4VideoTrack extends MP4MediaTrack {
|
||||||
|
video: MP4VideoData;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MP4AudioData {
|
||||||
|
sample_rate: number;
|
||||||
|
channel_count: number;
|
||||||
|
sample_size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MP4AudioTrack extends MP4MediaTrack {
|
||||||
|
audio: MP4AudioData;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
audioTracks: MP4AudioTrack[];
|
||||||
|
videoTracks: MP4VideoTrack[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MP4ArrayBuffer = ArrayBuffer & {fileStart: number};
|
||||||
|
|
||||||
|
export interface MP4File {
|
||||||
|
onMoovStart?: () => void;
|
||||||
|
onReady?: (info: MP4Info) => void;
|
||||||
|
onError?: (e: string) => void;
|
||||||
|
onSamples?: (id: number, user: any, samples: Sample[]) => void;
|
||||||
|
|
||||||
|
appendBuffer(data: MP4ArrayBuffer): number;
|
||||||
|
start(): void;
|
||||||
|
stop(): void;
|
||||||
|
flush(): void;
|
||||||
|
|
||||||
|
setExtractionOptions(id: number, user: any, options: ExtractionOptions): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createFile(): MP4File;
|
||||||
|
|
||||||
|
export interface Sample {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtractionOptions {
|
||||||
|
nbSamples: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const BIG_ENDIAN: boolean;
|
||||||
|
const LITTLE_ENDIAN: boolean;
|
||||||
|
|
||||||
|
export class DataStream {
|
||||||
|
constructor(buffer: ArrayBuffer, byteOffset?: number, littleEndian?: boolean);
|
||||||
|
getPosition(): number;
|
||||||
|
|
||||||
|
get byteLength(): number;
|
||||||
|
get buffer(): ArrayBuffer;
|
||||||
|
set buffer(v: ArrayBuffer);
|
||||||
|
get byteOffset(): number;
|
||||||
|
set byteOffset(v: number);
|
||||||
|
get dataView(): DataView;
|
||||||
|
set dataView(v: DataView);
|
||||||
|
|
||||||
|
seek(pos: number): void;
|
||||||
|
isEof(): boolean;
|
||||||
|
|
||||||
|
mapUint8Array(length: number): Uint8Array;
|
||||||
|
readInt32Array(length: number, littleEndian: boolean): Int32Array;
|
||||||
|
readInt16Array(length: number, littleEndian: boolean): Int16Array;
|
||||||
|
readInt8(length: number): Int8Array;
|
||||||
|
readUint32Array(length: number, littleEndian: boolean): Uint32Array;
|
||||||
|
readUint16Array(length: number, littleEndian: boolean): Uint16Array;
|
||||||
|
readUint8(length: number): Uint8Array;
|
||||||
|
readFloat64Array(length: number, littleEndian: boolean): Float64Array;
|
||||||
|
readFloat32Array(length: number, littleEndian: boolean): Float32Array;
|
||||||
|
|
||||||
|
readInt32(littleEndian: boolean): number;
|
||||||
|
readInt16(littleEndian: boolean): number;
|
||||||
|
readInt8(): number;
|
||||||
|
readUint32(littleEndian: boolean): number;
|
||||||
|
readUint16(littleEndian: boolean): number;
|
||||||
|
readUint8(): number;
|
||||||
|
readFloat32(littleEndian: boolean): number;
|
||||||
|
readFloat64(littleEndian: boolean): number;
|
||||||
|
|
||||||
|
endianness: boolean;
|
||||||
|
|
||||||
|
memcpy(dst: ArrayBufferLike, dstOffset: number, src: ArrayBufferLike, srcOffset: number, byteLength: number): void;
|
||||||
|
|
||||||
|
// TODO I got bored porting the remaining functions
|
||||||
|
}
|
||||||
|
|
||||||
|
export { };
|
||||||
|
}
|
File diff suppressed because it is too large
Load Diff
|
@ -1,374 +0,0 @@
|
||||||
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 interface PlayerInit {
|
|
||||||
url: string;
|
|
||||||
|
|
||||||
videoRef: HTMLVideoElement;
|
|
||||||
statsRef: HTMLElement;
|
|
||||||
throttleRef: HTMLElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
/*
|
|
||||||
*/
|
|
||||||
|
|
||||||
|
|
||||||
export class Player {
|
|
||||||
mediaSource: MediaSource;
|
|
||||||
|
|
||||||
init: Map<string, 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: HTMLElement; // The throttle button
|
|
||||||
throttleCount: number; // number of times we've clicked the button in a row
|
|
||||||
|
|
||||||
interval: number;
|
|
||||||
|
|
||||||
timeRef?: DOMHighResTimeStamp;
|
|
||||||
|
|
||||||
constructor(props: PlayerInit) {
|
|
||||||
this.vidRef = props.videoRef
|
|
||||||
this.statsRef = props.statsRef
|
|
||||||
this.throttleRef = props.throttleRef
|
|
||||||
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))
|
|
||||||
|
|
||||||
this.quic = this.connect(props.url)
|
|
||||||
|
|
||||||
// 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 connect(url: string): Promise<WebTransport> {
|
|
||||||
// TODO remove this when WebTransport supports the system CA pool
|
|
||||||
const fingerprintURL = new URL(url);
|
|
||||||
fingerprintURL.pathname = "/fingerprint"
|
|
||||||
|
|
||||||
const response = await fetch(fingerprintURL)
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('failed to get server fingerprint');
|
|
||||||
}
|
|
||||||
|
|
||||||
const hex = await response.text()
|
|
||||||
|
|
||||||
// Convert the hex to binary.
|
|
||||||
let fingerprint = [];
|
|
||||||
for (let c = 0; c < hex.length; c += 2) {
|
|
||||||
fingerprint.push(parseInt(hex.substring(c, c+2), 16));
|
|
||||||
}
|
|
||||||
|
|
||||||
//const fingerprint = Uint8Array.from(atob(hex), c => c.charCodeAt(0))
|
|
||||||
|
|
||||||
const quic = new WebTransport(url, {
|
|
||||||
"serverCertificateHashes": [{
|
|
||||||
"algorithm": "sha-256",
|
|
||||||
"value": new Uint8Array(fingerprint),
|
|
||||||
}]
|
|
||||||
})
|
|
||||||
|
|
||||||
await quic.ready
|
|
||||||
|
|
||||||
return quic
|
|
||||||
}
|
|
||||||
|
|
||||||
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() {
|
|
||||||
let rate = 0;
|
|
||||||
|
|
||||||
if (this.throttleCount > 0) {
|
|
||||||
// TODO detect the incoming bitrate instead of hard-coding
|
|
||||||
// Right shift by throttle to divide by 2,4,8,16,etc each time
|
|
||||||
const bitrate = 4 * 1024 * 1024 // 4Mb/s
|
|
||||||
|
|
||||||
rate = bitrate >> (this.throttleCount-1)
|
|
||||||
|
|
||||||
const str = formatBits(rate) + "/s"
|
|
||||||
this.throttleRef.textContent = `Throttle: ${ str }`;
|
|
||||||
} else {
|
|
||||||
this.throttleRef.textContent = "Throttle: none";
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send the server a message to fake network congestion.
|
|
||||||
this.sendMessage({
|
|
||||||
"debug": {
|
|
||||||
max_bitrate: rate,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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.add(segment)
|
|
||||||
|
|
||||||
/* TODO I'm not actually sure why this code doesn't work; something trips up the MP4 parser
|
|
||||||
while (1) {
|
|
||||||
const data = await stream.read()
|
|
||||||
if (!data) break
|
|
||||||
|
|
||||||
segment.push(data)
|
|
||||||
track.flush() // Flushes if the active segment has samples
|
|
||||||
}
|
|
||||||
*/
|
|
||||||
|
|
||||||
// One day I'll figure it out; until then read one top-level atom at a time
|
|
||||||
while (!await stream.done()) {
|
|
||||||
const raw = await stream.peek(4)
|
|
||||||
const size = new DataView(raw.buffer, raw.byteOffset, raw.byteLength).getUint32(0)
|
|
||||||
const atom = await stream.bytes(size)
|
|
||||||
|
|
||||||
segment.push(atom)
|
|
||||||
track.flush() // Flushes if the active segment has new samples
|
|
||||||
}
|
|
||||||
|
|
||||||
segment.finish()
|
|
||||||
}
|
|
||||||
|
|
||||||
updateStats() {
|
|
||||||
for (const child of this.statsRef.children) {
|
|
||||||
if (child.className == "audio buffer") {
|
|
||||||
const ranges: any = (this.audio) ? this.audio.buffered() : { length: 0 }
|
|
||||||
this.visualizeBuffer(child as HTMLElement, ranges)
|
|
||||||
} else if (child.className == "video buffer") {
|
|
||||||
const ranges: any = (this.video) ? this.video.buffered() : { length: 0 }
|
|
||||||
this.visualizeBuffer(child as HTMLElement, ranges)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
visualizeBuffer(element: HTMLElement, ranges: TimeRanges) {
|
|
||||||
const children = element.children
|
|
||||||
const max = 5
|
|
||||||
|
|
||||||
let index = 0
|
|
||||||
let prev = 0
|
|
||||||
|
|
||||||
for (let i = 0; i < ranges.length; i += 1) {
|
|
||||||
let start = ranges.start(i) - this.vidRef.currentTime
|
|
||||||
let end = ranges.end(i) - this.vidRef.currentTime
|
|
||||||
|
|
||||||
if (end < 0 || start > max) {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
let fill: HTMLElement;
|
|
||||||
|
|
||||||
if (index < children.length) {
|
|
||||||
fill = children[index] as HTMLElement;
|
|
||||||
} else {
|
|
||||||
fill = document.createElement("div")
|
|
||||||
element.appendChild(fill)
|
|
||||||
}
|
|
||||||
|
|
||||||
fill.className = "fill"
|
|
||||||
fill.innerHTML = end.toFixed(2)
|
|
||||||
fill.setAttribute('style', "left: " + (100 * Math.max(start, 0) / max) + "%; right: " + (100 - 100 * Math.min(end, max) / max) + "%")
|
|
||||||
index += 1
|
|
||||||
|
|
||||||
prev = end
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = index; i < children.length; i += 1) {
|
|
||||||
element.removeChild(children[i])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// https://stackoverflow.com/questions/15900485/correct-way-to-convert-size-in-bytes-to-kb-mb-gb-in-javascript
|
|
||||||
function formatBits(bits: number, decimals: number = 1) {
|
|
||||||
if (bits === 0) return '0 bits';
|
|
||||||
|
|
||||||
const k = 1024;
|
|
||||||
const dm = decimals < 0 ? 0 : decimals;
|
|
||||||
const sizes = ['b', 'Kb', 'Mb', 'Gb', 'Tb', 'Pb', 'Eb', 'Zb', 'Yb'];
|
|
||||||
|
|
||||||
const i = Math.floor(Math.log(bits) / Math.log(k));
|
|
||||||
|
|
||||||
return parseFloat((bits / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
|
||||||
}
|
|
|
@ -0,0 +1,46 @@
|
||||||
|
import Audio from "../audio"
|
||||||
|
import Transport from "../transport"
|
||||||
|
import Video from "../video"
|
||||||
|
|
||||||
|
export interface PlayerInit {
|
||||||
|
url: string;
|
||||||
|
canvas: HTMLCanvasElement;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class Player {
|
||||||
|
audio: Audio;
|
||||||
|
video: Video;
|
||||||
|
transport: Transport;
|
||||||
|
|
||||||
|
constructor(props: PlayerInit) {
|
||||||
|
this.audio = new Audio()
|
||||||
|
this.video = new Video({
|
||||||
|
canvas: props.canvas.transferControlToOffscreen(),
|
||||||
|
})
|
||||||
|
|
||||||
|
this.transport = new Transport({
|
||||||
|
url: props.url,
|
||||||
|
audio: this.audio,
|
||||||
|
video: this.video,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async close() {
|
||||||
|
this.transport.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect(url: string) {
|
||||||
|
await this.transport.connect(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
play() {
|
||||||
|
this.audio.play({})
|
||||||
|
//this.video.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
onMessage(msg: any) {
|
||||||
|
if (msg.sync) {
|
||||||
|
msg.sync
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,150 +0,0 @@
|
||||||
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.initialize(this.init)
|
|
||||||
this.source.append(stream.buffer as ArrayBuffer)
|
|
||||||
|
|
||||||
return this.done
|
|
||||||
}
|
|
||||||
|
|
||||||
// The segment has completed
|
|
||||||
finish() {
|
|
||||||
this.done = true
|
|
||||||
this.flush()
|
|
||||||
|
|
||||||
// Trim the buffer to 30s long after each segment.
|
|
||||||
this.source.trim(30)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -1,147 +0,0 @@
|
||||||
import { Init } from "./init"
|
|
||||||
|
|
||||||
// Create a SourceBuffer with convenience methods
|
|
||||||
export class Source {
|
|
||||||
sourceBuffer?: SourceBuffer;
|
|
||||||
mediaSource: MediaSource;
|
|
||||||
queue: Array<SourceInit | SourceData | SourceTrim>;
|
|
||||||
init?: Init;
|
|
||||||
|
|
||||||
constructor(mediaSource: MediaSource) {
|
|
||||||
this.mediaSource = mediaSource;
|
|
||||||
this.queue = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// (re)initialize the source using the provided init segment.
|
|
||||||
initialize(init: Init) {
|
|
||||||
// Check if the init segment is already in the queue.
|
|
||||||
for (let i = this.queue.length - 1; i >= 0; i--) {
|
|
||||||
if ((this.queue[i] as SourceInit).init == init) {
|
|
||||||
// Already queued up.
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if the init segment has already been applied.
|
|
||||||
if (this.init == init) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add the init segment to the queue so we call addSourceBuffer or changeType
|
|
||||||
this.queue.push({
|
|
||||||
kind: "init",
|
|
||||||
init: init,
|
|
||||||
})
|
|
||||||
|
|
||||||
for (let i = 0; i < init.raw.length; i += 1) {
|
|
||||||
this.queue.push({
|
|
||||||
kind: "data",
|
|
||||||
data: init.raw[i],
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
this.flush()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Append the segment data to the buffer.
|
|
||||||
append(data: Uint8Array | ArrayBuffer) {
|
|
||||||
this.queue.push({
|
|
||||||
kind: "data",
|
|
||||||
data: data,
|
|
||||||
})
|
|
||||||
|
|
||||||
this.flush()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Return the buffered range.
|
|
||||||
buffered() {
|
|
||||||
if (!this.sourceBuffer) {
|
|
||||||
return { length: 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.sourceBuffer.buffered
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete any media older than x seconds from the buffer.
|
|
||||||
trim(duration: number) {
|
|
||||||
this.queue.push({
|
|
||||||
kind: "trim",
|
|
||||||
trim: duration,
|
|
||||||
})
|
|
||||||
|
|
||||||
this.flush()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Flush any queued instructions
|
|
||||||
flush() {
|
|
||||||
while (1) {
|
|
||||||
// Check if the buffer is currently busy.
|
|
||||||
if (this.sourceBuffer && this.sourceBuffer.updating) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process the next item in the queue.
|
|
||||||
const next = this.queue.shift()
|
|
||||||
if (!next) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (next.kind) {
|
|
||||||
case "init":
|
|
||||||
this.init = next.init;
|
|
||||||
|
|
||||||
if (!this.sourceBuffer) {
|
|
||||||
// Create a new source buffer.
|
|
||||||
this.sourceBuffer = this.mediaSource.addSourceBuffer(this.init.info.mime)
|
|
||||||
|
|
||||||
// Call flush automatically after each update finishes.
|
|
||||||
this.sourceBuffer.addEventListener('updateend', this.flush.bind(this))
|
|
||||||
} else {
|
|
||||||
this.sourceBuffer.changeType(next.init.info.mime)
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
case "data":
|
|
||||||
if (!this.sourceBuffer) {
|
|
||||||
throw "failed to call initailize before append"
|
|
||||||
}
|
|
||||||
|
|
||||||
this.sourceBuffer.appendBuffer(next.data)
|
|
||||||
|
|
||||||
break;
|
|
||||||
case "trim":
|
|
||||||
if (!this.sourceBuffer) {
|
|
||||||
throw "failed to call initailize before trim"
|
|
||||||
}
|
|
||||||
|
|
||||||
const end = this.sourceBuffer.buffered.end(this.sourceBuffer.buffered.length - 1) - next.trim;
|
|
||||||
const start = this.sourceBuffer.buffered.start(0)
|
|
||||||
|
|
||||||
if (end > start) {
|
|
||||||
this.sourceBuffer.remove(start, end)
|
|
||||||
}
|
|
||||||
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
throw "impossible; unknown SourceItem"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
interface SourceItem {}
|
|
||||||
|
|
||||||
class SourceInit implements SourceItem {
|
|
||||||
kind!: "init";
|
|
||||||
init!: Init;
|
|
||||||
}
|
|
||||||
|
|
||||||
class SourceData implements SourceItem {
|
|
||||||
kind!: "data";
|
|
||||||
data!: Uint8Array | ArrayBuffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
class SourceTrim implements SourceItem {
|
|
||||||
kind!: "trim";
|
|
||||||
trim!: number;
|
|
||||||
}
|
|
|
@ -0,0 +1,2 @@
|
||||||
|
export { default as Reader } from "./reader"
|
||||||
|
export { default as Writer } from "./writer"
|
|
@ -1,33 +1,64 @@
|
||||||
// Reader wraps a stream and provides convience methods for reading pieces from a stream
|
// Reader wraps a stream and provides convience methods for reading pieces from a stream
|
||||||
export class StreamReader {
|
export default class Reader {
|
||||||
reader: ReadableStreamDefaultReader; // TODO make a separate class without promises when null
|
reader: ReadableStream;
|
||||||
buffer: Uint8Array;
|
buffer: Uint8Array;
|
||||||
|
|
||||||
constructor(reader: ReadableStreamDefaultReader, buffer: Uint8Array = new Uint8Array(0)) {
|
constructor(reader: ReadableStream, buffer: Uint8Array = new Uint8Array(0)) {
|
||||||
this.reader = reader
|
this.reader = reader
|
||||||
this.buffer = buffer
|
this.buffer = buffer
|
||||||
}
|
}
|
||||||
|
|
||||||
// TODO implementing pipeTo seems more reasonable than releasing the lock
|
|
||||||
release() {
|
|
||||||
this.reader.releaseLock()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Returns any number of bytes
|
// Returns any number of bytes
|
||||||
async read(): Promise<Uint8Array | undefined> {
|
async read(): Promise<Uint8Array | undefined> {
|
||||||
|
|
||||||
if (this.buffer.byteLength) {
|
if (this.buffer.byteLength) {
|
||||||
const buffer = this.buffer;
|
const buffer = this.buffer;
|
||||||
this.buffer = new Uint8Array()
|
this.buffer = new Uint8Array()
|
||||||
return buffer
|
return buffer
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.reader.read()
|
const r = this.reader.getReader()
|
||||||
|
const result = await r.read()
|
||||||
|
|
||||||
|
r.releaseLock()
|
||||||
|
|
||||||
return result.value
|
return result.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async readAll(): Promise<Uint8Array> {
|
||||||
|
const r = this.reader.getReader()
|
||||||
|
|
||||||
|
while (1) {
|
||||||
|
const result = await r.read()
|
||||||
|
if (result.done) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = this.buffer
|
||||||
|
this.buffer = new Uint8Array()
|
||||||
|
|
||||||
|
r.releaseLock()
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
async bytes(size: number): Promise<Uint8Array> {
|
async bytes(size: number): Promise<Uint8Array> {
|
||||||
|
const r = this.reader.getReader()
|
||||||
|
|
||||||
while (this.buffer.byteLength < size) {
|
while (this.buffer.byteLength < size) {
|
||||||
const result = await this.reader.read()
|
const result = await r.read()
|
||||||
if (result.done) {
|
if (result.done) {
|
||||||
throw "short buffer"
|
throw "short buffer"
|
||||||
}
|
}
|
||||||
|
@ -47,12 +78,16 @@ export class StreamReader {
|
||||||
const result = new Uint8Array(this.buffer.buffer, this.buffer.byteOffset, size)
|
const result = new Uint8Array(this.buffer.buffer, this.buffer.byteOffset, size)
|
||||||
this.buffer = new Uint8Array(this.buffer.buffer, this.buffer.byteOffset + size)
|
this.buffer = new Uint8Array(this.buffer.buffer, this.buffer.byteOffset + size)
|
||||||
|
|
||||||
|
r.releaseLock()
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
async peek(size: number): Promise<Uint8Array> {
|
async peek(size: number): Promise<Uint8Array> {
|
||||||
|
const r = this.reader.getReader()
|
||||||
|
|
||||||
while (this.buffer.byteLength < size) {
|
while (this.buffer.byteLength < size) {
|
||||||
const result = await this.reader.read()
|
const result = await r.read()
|
||||||
if (result.done) {
|
if (result.done) {
|
||||||
throw "short buffer"
|
throw "short buffer"
|
||||||
}
|
}
|
||||||
|
@ -69,7 +104,11 @@ export class StreamReader {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return new Uint8Array(this.buffer.buffer, this.buffer.byteOffset, size)
|
const result = new Uint8Array(this.buffer.buffer, this.buffer.byteOffset, size)
|
||||||
|
|
||||||
|
r.releaseLock()
|
||||||
|
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
async view(size: number): Promise<DataView> {
|
async view(size: number): Promise<DataView> {
|
||||||
|
@ -151,104 +190,3 @@ export class StreamReader {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// StreamWriter wraps a stream and writes chunks of data
|
|
||||||
export class StreamWriter {
|
|
||||||
buffer: ArrayBuffer;
|
|
||||||
writer: WritableStreamDefaultWriter;
|
|
||||||
|
|
||||||
constructor(stream: WritableStream) {
|
|
||||||
this.buffer = new ArrayBuffer(8)
|
|
||||||
this.writer = stream.getWriter()
|
|
||||||
}
|
|
||||||
|
|
||||||
release() {
|
|
||||||
this.writer.releaseLock()
|
|
||||||
}
|
|
||||||
|
|
||||||
async close() {
|
|
||||||
return this.writer.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
async uint8(v: number) {
|
|
||||||
const view = new DataView(this.buffer, 0, 1)
|
|
||||||
view.setUint8(0, v)
|
|
||||||
return this.writer.write(view)
|
|
||||||
}
|
|
||||||
|
|
||||||
async uint16(v: number) {
|
|
||||||
const view = new DataView(this.buffer, 0, 2)
|
|
||||||
view.setUint16(0, v)
|
|
||||||
return this.writer.write(view)
|
|
||||||
}
|
|
||||||
|
|
||||||
async uint24(v: number) {
|
|
||||||
const v1 = (v >> 16) & 0xff
|
|
||||||
const v2 = (v >> 8) & 0xff
|
|
||||||
const v3 = (v) & 0xff
|
|
||||||
|
|
||||||
const view = new DataView(this.buffer, 0, 3)
|
|
||||||
view.setUint8(0, v1)
|
|
||||||
view.setUint8(1, v2)
|
|
||||||
view.setUint8(2, v3)
|
|
||||||
|
|
||||||
return this.writer.write(view)
|
|
||||||
}
|
|
||||||
|
|
||||||
async uint32(v: number) {
|
|
||||||
const view = new DataView(this.buffer, 0, 4)
|
|
||||||
view.setUint32(0, v)
|
|
||||||
return this.writer.write(view)
|
|
||||||
}
|
|
||||||
|
|
||||||
async uint52(v: number) {
|
|
||||||
if (v > Number.MAX_SAFE_INTEGER) {
|
|
||||||
throw "value too large"
|
|
||||||
}
|
|
||||||
|
|
||||||
this.uint64(BigInt(v))
|
|
||||||
}
|
|
||||||
|
|
||||||
async vint52(v: number) {
|
|
||||||
if (v > Number.MAX_SAFE_INTEGER) {
|
|
||||||
throw "value too large"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (v < (1 << 6)) {
|
|
||||||
return this.uint8(v)
|
|
||||||
} else if (v < (1 << 14)) {
|
|
||||||
return this.uint16(v|0x4000)
|
|
||||||
} else if (v < (1 << 30)) {
|
|
||||||
return this.uint32(v|0x80000000)
|
|
||||||
} else {
|
|
||||||
return this.uint64(BigInt(v) | 0xc000000000000000n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async uint64(v: bigint) {
|
|
||||||
const view = new DataView(this.buffer, 0, 8)
|
|
||||||
view.setBigUint64(0, v)
|
|
||||||
return this.writer.write(view)
|
|
||||||
}
|
|
||||||
|
|
||||||
async vint64(v: bigint) {
|
|
||||||
if (v < (1 << 6)) {
|
|
||||||
return this.uint8(Number(v))
|
|
||||||
} else if (v < (1 << 14)) {
|
|
||||||
return this.uint16(Number(v)|0x4000)
|
|
||||||
} else if (v < (1 << 30)) {
|
|
||||||
return this.uint32(Number(v)|0x80000000)
|
|
||||||
} else {
|
|
||||||
return this.uint64(v | 0xc000000000000000n)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async bytes(buffer: ArrayBuffer) {
|
|
||||||
return this.writer.write(buffer)
|
|
||||||
}
|
|
||||||
|
|
||||||
async string(str: string) {
|
|
||||||
const data = new TextEncoder().encode(str)
|
|
||||||
return this.writer.write(data)
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1,100 @@
|
||||||
|
// Writer wraps a stream and writes chunks of data
|
||||||
|
export default class Writer {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,124 +0,0 @@
|
||||||
import { Source } from "./source"
|
|
||||||
import { Segment } from "./segment"
|
|
||||||
import { TimeRange } from "./util"
|
|
||||||
|
|
||||||
// An audio or video track that consists of multiple sequential segments.
|
|
||||||
//
|
|
||||||
// Instead of buffering, we want to drop video while audio plays uninterupted.
|
|
||||||
// Chrome actually plays up to 3s of audio without video before buffering when in low latency mode.
|
|
||||||
// Unforuntately, this does not recover correctly when there are gaps (pls fix).
|
|
||||||
// Our solution is to flush segments in decode order, buffering a single additional frame.
|
|
||||||
// We extend the duration of the buffered frame and flush it to cover any gaps.
|
|
||||||
export class Track {
|
|
||||||
source: Source;
|
|
||||||
segments: Segment[];
|
|
||||||
|
|
||||||
constructor(source: Source) {
|
|
||||||
this.source = source;
|
|
||||||
this.segments = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
add(segment: Segment) {
|
|
||||||
// TODO don't add if the segment is out of date already
|
|
||||||
this.segments.push(segment)
|
|
||||||
|
|
||||||
// Sort by timestamp ascending
|
|
||||||
// NOTE: The timestamp is in milliseconds, and we need to parse the media to get the accurate PTS/DTS.
|
|
||||||
this.segments.sort((a: Segment, b: Segment): number => {
|
|
||||||
return a.timestamp - b.timestamp
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
buffered(): TimeRanges {
|
|
||||||
let ranges: TimeRange[] = []
|
|
||||||
|
|
||||||
const buffered = this.source.buffered() as TimeRanges
|
|
||||||
for (let i = 0; i < buffered.length; i += 1) {
|
|
||||||
// Convert the TimeRanges into an oject we can modify
|
|
||||||
ranges.push({
|
|
||||||
start: buffered.start(i),
|
|
||||||
end: buffered.end(i)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// Loop over segments and add in their ranges, merging if possible.
|
|
||||||
for (let segment of this.segments) {
|
|
||||||
const buffered = segment.buffered()
|
|
||||||
if (!buffered) continue;
|
|
||||||
|
|
||||||
if (ranges.length) {
|
|
||||||
// Try to merge with an existing range
|
|
||||||
const last = ranges[ranges.length-1];
|
|
||||||
if (buffered.start < last.start) {
|
|
||||||
// Network buffer is old; ignore it
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extend the end of the last range instead of pushing
|
|
||||||
if (buffered.start <= last.end && buffered.end > last.end) {
|
|
||||||
last.end = buffered.end
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ranges.push(buffered)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO typescript
|
|
||||||
return {
|
|
||||||
length: ranges.length,
|
|
||||||
start: (x) => { return ranges[x].start },
|
|
||||||
end: (x) => { return ranges[x].end },
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
flush() {
|
|
||||||
while (1) {
|
|
||||||
if (!this.segments.length) break
|
|
||||||
|
|
||||||
const first = this.segments[0]
|
|
||||||
const done = first.flush()
|
|
||||||
if (!done) break
|
|
||||||
|
|
||||||
this.segments.shift()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Given the current playhead, determine if we should drop any segments
|
|
||||||
// If playhead is undefined, it means we're buffering so skip to anything now.
|
|
||||||
advance(playhead: number | undefined) {
|
|
||||||
if (this.segments.length < 2) return
|
|
||||||
|
|
||||||
while (this.segments.length > 1) {
|
|
||||||
const current = this.segments[0];
|
|
||||||
const next = this.segments[1];
|
|
||||||
|
|
||||||
if (next.dts === undefined || next.timescale == undefined) {
|
|
||||||
// No samples have been parsed for the next segment yet.
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if (current.dts === undefined) {
|
|
||||||
// No samples have been parsed for the current segment yet.
|
|
||||||
// We can't cover the gap by extending the sample so we have to seek.
|
|
||||||
// TODO I don't think this can happen, but I guess we have to seek past the gap.
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playhead !== undefined) {
|
|
||||||
// Check if the next segment has playable media now.
|
|
||||||
// Otherwise give the current segment more time to catch up.
|
|
||||||
if ((next.dts / next.timescale) > playhead) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
current.skipTo(next.dts || 0) // tell typescript that it's not undefined; we already checked
|
|
||||||
current.finish()
|
|
||||||
|
|
||||||
// TODO cancel the QUIC stream to save bandwidth
|
|
||||||
|
|
||||||
this.segments.shift()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -0,0 +1 @@
|
||||||
|
fingerprint.hex
|
|
@ -0,0 +1,175 @@
|
||||||
|
import * as Message from "./message"
|
||||||
|
import * as Stream from "../stream"
|
||||||
|
import * as MP4 from "../mp4"
|
||||||
|
|
||||||
|
import Audio from "../audio"
|
||||||
|
import Video from "../video"
|
||||||
|
|
||||||
|
// @ts-ignore bundler embeds data
|
||||||
|
import fingerprint from 'bundle-text:./fingerprint.hex';
|
||||||
|
|
||||||
|
export interface TransportInit {
|
||||||
|
url: string;
|
||||||
|
audio: Audio;
|
||||||
|
video: Video;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default class Transport {
|
||||||
|
quic: Promise<WebTransport>;
|
||||||
|
api: Promise<WritableStream>;
|
||||||
|
tracks: Map<string, MP4.InitParser>
|
||||||
|
|
||||||
|
audio: Audio;
|
||||||
|
video: Video;
|
||||||
|
|
||||||
|
constructor(props: TransportInit) {
|
||||||
|
this.tracks = new Map();
|
||||||
|
|
||||||
|
this.audio = props.audio;
|
||||||
|
this.video = props.video;
|
||||||
|
|
||||||
|
this.quic = this.connect(props.url)
|
||||||
|
|
||||||
|
// Create a unidirectional stream for all of our messages
|
||||||
|
this.api = this.quic.then((q) => {
|
||||||
|
return q.createUnidirectionalStream()
|
||||||
|
})
|
||||||
|
|
||||||
|
// async functions
|
||||||
|
this.receiveStreams()
|
||||||
|
}
|
||||||
|
|
||||||
|
async close() {
|
||||||
|
(await this.quic).close()
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect(url: string): Promise<WebTransport> {
|
||||||
|
// Convert the hex to binary.
|
||||||
|
let hash = [];
|
||||||
|
for (let c = 0; c < fingerprint.length-1; c += 2) {
|
||||||
|
hash.push(parseInt(fingerprint.substring(c, c+2), 16));
|
||||||
|
}
|
||||||
|
|
||||||
|
const quic = new WebTransport(url, {
|
||||||
|
"serverCertificateHashes": [{
|
||||||
|
"algorithm": "sha-256",
|
||||||
|
"value": new Uint8Array(hash),
|
||||||
|
}]
|
||||||
|
})
|
||||||
|
|
||||||
|
await quic.ready
|
||||||
|
|
||||||
|
return quic
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMessage(msg: any) {
|
||||||
|
const payload = JSON.stringify(msg)
|
||||||
|
const size = payload.length + 8
|
||||||
|
|
||||||
|
const stream = await this.api
|
||||||
|
|
||||||
|
const writer = new Stream.Writer(stream)
|
||||||
|
await writer.uint32(size)
|
||||||
|
await writer.string("warp")
|
||||||
|
await writer.string(payload)
|
||||||
|
writer.release()
|
||||||
|
}
|
||||||
|
|
||||||
|
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 Stream.Reader(stream)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
if (msg.init) {
|
||||||
|
return this.handleInit(r, msg.init as Message.Init)
|
||||||
|
} else if (msg.segment) {
|
||||||
|
return this.handleSegment(r, msg.segment as Message.Segment)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleInit(stream: Stream.Reader, msg: Message.Init) {
|
||||||
|
let track = this.tracks.get(msg.id);
|
||||||
|
if (!track) {
|
||||||
|
track = new MP4.InitParser()
|
||||||
|
this.tracks.set(msg.id, track)
|
||||||
|
}
|
||||||
|
|
||||||
|
while (1) {
|
||||||
|
const data = await stream.read()
|
||||||
|
if (!data) break
|
||||||
|
|
||||||
|
track.push(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = await track.info
|
||||||
|
|
||||||
|
if (info.audioTracks.length + info.videoTracks.length != 1) {
|
||||||
|
throw new Error("expected a single track")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.audioTracks.length) {
|
||||||
|
this.audio.init({
|
||||||
|
track: msg.id,
|
||||||
|
info: info,
|
||||||
|
raw: track.raw,
|
||||||
|
})
|
||||||
|
} else if (info.videoTracks.length) {
|
||||||
|
this.video.init({
|
||||||
|
track: msg.id,
|
||||||
|
info: info,
|
||||||
|
raw: track.raw,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
throw new Error("init is neither audio nor video")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleSegment(stream: Stream.Reader, msg: Message.Segment) {
|
||||||
|
let track = this.tracks.get(msg.init);
|
||||||
|
if (!track) {
|
||||||
|
track = new MP4.InitParser()
|
||||||
|
this.tracks.set(msg.init, track)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait until we learn if this is an audio or video track
|
||||||
|
const info = await track.info
|
||||||
|
|
||||||
|
if (info.audioTracks.length) {
|
||||||
|
this.audio.segment({
|
||||||
|
track: msg.init,
|
||||||
|
buffer: stream.buffer,
|
||||||
|
reader: stream.reader,
|
||||||
|
})
|
||||||
|
} else if (info.videoTracks.length) {
|
||||||
|
this.video.segment({
|
||||||
|
track: msg.init,
|
||||||
|
buffer: stream.buffer,
|
||||||
|
reader: stream.reader,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
throw new Error("segment is neither audio nor video")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,13 +1,8 @@
|
||||||
export interface Message {
|
export interface Init {
|
||||||
init?: MessageInit
|
|
||||||
segment?: MessageSegment
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MessageInit {
|
|
||||||
id: string
|
id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MessageSegment {
|
export interface Segment {
|
||||||
init: string // id of the init segment
|
init: string // id of the init segment
|
||||||
timestamp: number // presentation timestamp in milliseconds of the first sample
|
timestamp: number // presentation timestamp in milliseconds of the first sample
|
||||||
// TODO track would be nice
|
// TODO track would be nice
|
|
@ -1,4 +0,0 @@
|
||||||
export interface TimeRange {
|
|
||||||
start: number;
|
|
||||||
end: number;
|
|
||||||
}
|
|
|
@ -0,0 +1,16 @@
|
||||||
|
export default class Deferred<T> {
|
||||||
|
promise: Promise<T>
|
||||||
|
resolve: (value: T | PromiseLike<T>) => void
|
||||||
|
reject: (value: T | PromiseLike<T>) => void
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
// Set initial values so TS stops being annoying.
|
||||||
|
this.resolve = (value: T | PromiseLike<T>) => {};
|
||||||
|
this.reject = (value: T | PromiseLike<T>) => {};
|
||||||
|
|
||||||
|
this.promise = new Promise((resolve, reject) => {
|
||||||
|
this.resolve = resolve
|
||||||
|
this.reject = reject
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1 @@
|
||||||
|
export { default as Deferred } from "./deferred"
|
|
@ -0,0 +1,127 @@
|
||||||
|
import * as Message from "./message";
|
||||||
|
import * as MP4 from "../mp4"
|
||||||
|
import * as Stream from "../stream"
|
||||||
|
import * as Util from "../util"
|
||||||
|
|
||||||
|
import Renderer from "./renderer"
|
||||||
|
|
||||||
|
export default class Decoder {
|
||||||
|
// Store the init message for each track
|
||||||
|
tracks: Map<string, Util.Deferred<Message.Init>>
|
||||||
|
renderer: Renderer;
|
||||||
|
|
||||||
|
constructor(renderer: Renderer) {
|
||||||
|
this.tracks = new Map();
|
||||||
|
this.renderer = renderer;
|
||||||
|
}
|
||||||
|
|
||||||
|
async init(msg: Message.Init) {
|
||||||
|
let track = this.tracks.get(msg.track);
|
||||||
|
if (!track) {
|
||||||
|
track = new Util.Deferred()
|
||||||
|
this.tracks.set(msg.track, track)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (msg.info.videoTracks.length != 1 || msg.info.audioTracks.length != 0) {
|
||||||
|
throw new Error("Expected a single video track")
|
||||||
|
}
|
||||||
|
|
||||||
|
track.resolve(msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
async decode(msg: Message.Segment) {
|
||||||
|
let track = this.tracks.get(msg.track);
|
||||||
|
if (!track) {
|
||||||
|
track = new Util.Deferred()
|
||||||
|
this.tracks.set(msg.track, track)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the init segment to be fully received and parsed
|
||||||
|
const init = await track.promise;
|
||||||
|
const info = init.info;
|
||||||
|
const video = info.videoTracks[0]
|
||||||
|
|
||||||
|
const decoder = new VideoDecoder({
|
||||||
|
output: (frame: VideoFrame) => {
|
||||||
|
this.renderer.emit(frame)
|
||||||
|
},
|
||||||
|
error: (err: Error) => {
|
||||||
|
console.warn(err)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const input = MP4.New();
|
||||||
|
|
||||||
|
input.onSamples = (id: number, user: any, samples: MP4.Sample[]) => {
|
||||||
|
for (let sample of samples) {
|
||||||
|
const timestamp = 1000 * sample.dts / sample.timescale // milliseconds
|
||||||
|
|
||||||
|
if (sample.is_sync) {
|
||||||
|
// Configure the decoder using the AVC box for H.264
|
||||||
|
const avcc = sample.description.avcC;
|
||||||
|
const description = new MP4.Stream(new Uint8Array(avcc.size), 0, false)
|
||||||
|
avcc.write(description)
|
||||||
|
|
||||||
|
decoder.configure({
|
||||||
|
codec: video.codec,
|
||||||
|
codedHeight: video.track_height,
|
||||||
|
codedWidth: video.track_width,
|
||||||
|
description: description.buffer?.slice(8),
|
||||||
|
// optimizeForLatency: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
decoder.decode(new EncodedVideoChunk({
|
||||||
|
data: sample.data,
|
||||||
|
duration: sample.duration,
|
||||||
|
timestamp: timestamp,
|
||||||
|
type: sample.is_sync ? "key" : "delta",
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input.onReady = (info: any) => {
|
||||||
|
input.setExtractionOptions(info.tracks[0].id, {}, { nbSamples: 1 });
|
||||||
|
input.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
// MP4box requires us to reparse the init segment unfortunately
|
||||||
|
let offset = 0;
|
||||||
|
|
||||||
|
for (let raw of init.raw) {
|
||||||
|
raw.fileStart = offset
|
||||||
|
input.appendBuffer(raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = new Stream.Reader(msg.reader, msg.buffer)
|
||||||
|
|
||||||
|
/* 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
|
||||||
|
|
||||||
|
input.appendBuffer(data)
|
||||||
|
input.flush()
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
|
||||||
|
// Make a copy of the atom because mp4box only accepts an ArrayBuffer unfortunately
|
||||||
|
let box = new Uint8Array(atom.byteLength);
|
||||||
|
box.set(atom)
|
||||||
|
|
||||||
|
// and for some reason we need to modify the underlying ArrayBuffer with offset
|
||||||
|
let buffer = box.buffer as MP4.ArrayBuffer
|
||||||
|
buffer.fileStart = offset
|
||||||
|
|
||||||
|
// Parse the data
|
||||||
|
offset = input.appendBuffer(buffer)
|
||||||
|
input.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,27 @@
|
||||||
|
import * as Message from "./message"
|
||||||
|
|
||||||
|
// Wrapper around the WebWorker API
|
||||||
|
export default class Video {
|
||||||
|
worker: Worker;
|
||||||
|
|
||||||
|
constructor(config: Message.Config) {
|
||||||
|
const url = new URL('worker.ts', import.meta.url)
|
||||||
|
this.worker = new Worker(url, {
|
||||||
|
type: "module",
|
||||||
|
name: "video",
|
||||||
|
})
|
||||||
|
this.worker.postMessage({ config }, [ config.canvas ])
|
||||||
|
}
|
||||||
|
|
||||||
|
init(init: Message.Init) {
|
||||||
|
this.worker.postMessage({ init }) // note: we copy the raw init bytes each time
|
||||||
|
}
|
||||||
|
|
||||||
|
segment(segment: Message.Segment) {
|
||||||
|
this.worker.postMessage({ segment }, [ segment.buffer.buffer, segment.reader ])
|
||||||
|
}
|
||||||
|
|
||||||
|
play() {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,17 @@
|
||||||
|
import * as MP4 from "../mp4"
|
||||||
|
|
||||||
|
export interface Config {
|
||||||
|
canvas: OffscreenCanvas;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Init {
|
||||||
|
track: string;
|
||||||
|
info: MP4.Info;
|
||||||
|
raw: MP4.ArrayBuffer[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Segment {
|
||||||
|
track: string;
|
||||||
|
buffer: Uint8Array; // unread buffered data
|
||||||
|
reader: ReadableStream; // unread unbuffered data
|
||||||
|
}
|
|
@ -0,0 +1,91 @@
|
||||||
|
import * as Message from "./message";
|
||||||
|
|
||||||
|
export default class Renderer {
|
||||||
|
canvas: OffscreenCanvas;
|
||||||
|
queue: Array<VideoFrame>;
|
||||||
|
render: number; // non-zero if requestAnimationFrame has been called
|
||||||
|
sync?: DOMHighResTimeStamp; // the wall clock value for timestamp 0
|
||||||
|
last?: number; // the timestamp of the last rendered frame
|
||||||
|
|
||||||
|
constructor(config: Message.Config) {
|
||||||
|
this.canvas = config.canvas;
|
||||||
|
this.queue = [];
|
||||||
|
this.render = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
emit(frame: VideoFrame) {
|
||||||
|
if (!this.sync) {
|
||||||
|
// Save the frame as the sync point
|
||||||
|
this.sync = performance.now() - frame.timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop any old frames
|
||||||
|
if (this.last && frame.timestamp <= this.last) {
|
||||||
|
frame.close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert the frame into the queue sorted by timestamp.
|
||||||
|
if (this.queue.length > 0 && this.queue[this.queue.length-1].timestamp <= frame.timestamp) {
|
||||||
|
// Fast path because we normally append to the end.
|
||||||
|
this.queue.push(frame)
|
||||||
|
} else {
|
||||||
|
// Do a full binary search
|
||||||
|
let low = 0
|
||||||
|
let high = this.queue.length;
|
||||||
|
|
||||||
|
while (low < high) {
|
||||||
|
var mid = (low + high) >>> 1;
|
||||||
|
if (this.queue[mid].timestamp < frame.timestamp) low = mid + 1;
|
||||||
|
else high = mid;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.queue.splice(low, 0, frame)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue up to render the next frame.
|
||||||
|
if (!this.render) {
|
||||||
|
this.render = self.requestAnimationFrame(this.draw.bind(this))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
draw(now: DOMHighResTimeStamp) {
|
||||||
|
// Determine the target timestamp.
|
||||||
|
const target = now - this.sync!
|
||||||
|
|
||||||
|
let frame = this.queue[0]
|
||||||
|
if (frame.timestamp >= target) {
|
||||||
|
// nothing to render yet, wait for the next animation frame
|
||||||
|
this.render = self.requestAnimationFrame(this.draw.bind(this))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.queue.shift()
|
||||||
|
|
||||||
|
// Check if we should skip some frames
|
||||||
|
while (this.queue.length) {
|
||||||
|
const next = this.queue[0]
|
||||||
|
if (next.timestamp > target) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
frame.close()
|
||||||
|
|
||||||
|
this.queue.shift()
|
||||||
|
frame = next
|
||||||
|
}
|
||||||
|
|
||||||
|
const ctx = this.canvas.getContext("2d");
|
||||||
|
ctx!.drawImage(frame, 0, 0, this.canvas.width, this.canvas.height) // TODO aspect ratio
|
||||||
|
|
||||||
|
this.last = frame.timestamp;
|
||||||
|
frame.close()
|
||||||
|
|
||||||
|
if (this.queue.length > 0) {
|
||||||
|
this.render = self.requestAnimationFrame(this.draw.bind(this))
|
||||||
|
} else {
|
||||||
|
// Break the loop for now
|
||||||
|
this.render = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,22 @@
|
||||||
|
import Renderer from "./renderer"
|
||||||
|
import Decoder from "./decoder"
|
||||||
|
import * as Message from "./message"
|
||||||
|
|
||||||
|
let decoder: Decoder;
|
||||||
|
let renderer: Renderer;
|
||||||
|
|
||||||
|
self.addEventListener('message', async (e: MessageEvent) => {
|
||||||
|
if (e.data.config) {
|
||||||
|
const config = e.data.config as Message.Config
|
||||||
|
|
||||||
|
renderer = new Renderer(config)
|
||||||
|
decoder = new Decoder(renderer)
|
||||||
|
} else if (e.data.init) {
|
||||||
|
const init = e.data.init as Message.Init
|
||||||
|
await decoder.init(init)
|
||||||
|
} else if (e.data.segment) {
|
||||||
|
const segment = e.data.segment as Message.Segment
|
||||||
|
await decoder.decode(segment)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
|
@ -3,11 +3,9 @@
|
||||||
"src/**/*"
|
"src/**/*"
|
||||||
],
|
],
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "es2021",
|
"target": "es2022",
|
||||||
|
"module": "es2022",
|
||||||
|
"moduleResolution": "node",
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"typeRoots": [
|
|
||||||
"src/types"
|
|
||||||
],
|
|
||||||
"allowJs": true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
1532
player/yarn.lock
1532
player/yarn.lock
File diff suppressed because it is too large
Load Diff
|
@ -20,7 +20,7 @@ require (
|
||||||
github.com/marten-seemann/qtls-go1-18 v0.1.3 // indirect
|
github.com/marten-seemann/qtls-go1-18 v0.1.3 // indirect
|
||||||
github.com/marten-seemann/qtls-go1-19 v0.1.1 // indirect
|
github.com/marten-seemann/qtls-go1-19 v0.1.1 // indirect
|
||||||
github.com/onsi/ginkgo/v2 v2.2.0 // indirect
|
github.com/onsi/ginkgo/v2 v2.2.0 // indirect
|
||||||
golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871 // indirect
|
golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29 // indirect
|
||||||
golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e // indirect
|
golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e // indirect
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect
|
||||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b // indirect
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b // indirect
|
||||||
|
|
|
@ -77,7 +77,6 @@ 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.3/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||||
github.com/kr/pty v1.1.8/go.mod h1:O1sed60cT9XZ5uDucP5qwvh+TE3NnUj51EiZO/lmSfw=
|
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/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||||
github.com/lucas-clemente/quic-go v0.31.0 h1:MfNp3fk0wjWRajw6quMFA3ap1AVtlU+2mtwmbVogB2M=
|
|
||||||
github.com/lunixbochs/vtclean v1.0.0/go.mod h1:pHhQNgMf3btfWnGBVipUOjRYhoOsdGqdm/+2c2E2WMI=
|
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/mailru/easyjson v0.0.0-20190312143242-1de009706dbe/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc=
|
||||||
github.com/marten-seemann/qpack v0.3.0 h1:UiWstOgT8+znlkDPOg2+3rIuYXJ2CnGDkGUXN6ki6hE=
|
github.com/marten-seemann/qpack v0.3.0 h1:UiWstOgT8+znlkDPOg2+3rIuYXJ2CnGDkGUXN6ki6hE=
|
||||||
|
@ -153,8 +152,8 @@ golang.org/x/crypto v0.0.0-20181030102418-4d3f4d9ffa16/go.mod h1:6SG95UA2DQfeDnf
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
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-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-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||||
golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871 h1:/pEO3GD/ABYAjuakUS6xSEmmlyVS4kxBNkeA9tLJiTI=
|
golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29 h1:tkVvjkPTB7pnW3jnid7kNyAMPVWllTNOf/qKDze4p9o=
|
||||||
golang.org/x/crypto v0.0.0-20211117183948-ae814b36b871/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
golang.org/x/crypto v0.0.0-20220331220935-ae2d96664a29/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e h1:+WEEuIdZHnUeJJmEUjyYC2gfUMj69yZXw17EnHg/otA=
|
golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e h1:+WEEuIdZHnUeJJmEUjyYC2gfUMj69yZXw17EnHg/otA=
|
||||||
golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e/go.mod h1:Kr81I6Kryrl9sr8s2FK3vxD90NdsKWRuOIl2O4CvYbA=
|
golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e/go.mod h1:Kr81I6Kryrl9sr8s2FK3vxD90NdsKWRuOIl2O4CvYbA=
|
||||||
|
|
|
@ -176,6 +176,11 @@ func (s *Session) writeInit(ctx context.Context, init *MediaInit) (err error) {
|
||||||
return fmt.Errorf("failed to create stream: %w", err)
|
return fmt.Errorf("failed to create stream: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if temp == nil {
|
||||||
|
// Not sure when this happens, perhaps when closing a connection?
|
||||||
|
return fmt.Errorf("received a nil stream from quic-go")
|
||||||
|
}
|
||||||
|
|
||||||
// Wrap the stream in an object that buffers writes instead of blocking.
|
// Wrap the stream in an object that buffers writes instead of blocking.
|
||||||
stream := NewStream(temp)
|
stream := NewStream(temp)
|
||||||
s.streams.Add(stream.Run)
|
s.streams.Add(stream.Run)
|
||||||
|
@ -200,6 +205,11 @@ func (s *Session) writeInit(ctx context.Context, init *MediaInit) (err error) {
|
||||||
return fmt.Errorf("failed to write init data: %w", err)
|
return fmt.Errorf("failed to write init data: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = stream.Close()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to close init stream: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -210,6 +220,11 @@ func (s *Session) writeSegment(ctx context.Context, segment *MediaSegment) (err
|
||||||
return fmt.Errorf("failed to create stream: %w", err)
|
return fmt.Errorf("failed to create stream: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if temp == nil {
|
||||||
|
// Not sure when this happens, perhaps when closing a connection?
|
||||||
|
return fmt.Errorf("received a nil stream from quic-go")
|
||||||
|
}
|
||||||
|
|
||||||
// Wrap the stream in an object that buffers writes instead of blocking.
|
// Wrap the stream in an object that buffers writes instead of blocking.
|
||||||
stream := NewStream(temp)
|
stream := NewStream(temp)
|
||||||
s.streams.Add(stream.Run)
|
s.streams.Add(stream.Run)
|
||||||
|
|
Loading…
Reference in New Issue