mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-12 08:45:38 +02:00
We are hosting a [MoQ](https://quic.video) relay on a remote (bare metal) server on Hetzner With a lot of help from @victorpahuus
171 lines
4.2 KiB
TypeScript
171 lines
4.2 KiB
TypeScript
import { Segment } from "./segment"
|
|
import { Notify } from "../common/async"
|
|
import { Chunk } from "./chunk"
|
|
import { Container } from "./container"
|
|
import { BroadcastConfig } from "./broadcast"
|
|
|
|
import * as Audio from "./audio"
|
|
import * as Video from "./video"
|
|
|
|
export class Track {
|
|
name: string
|
|
|
|
#init?: Uint8Array
|
|
#segments: Segment[] = []
|
|
|
|
#offset = 0 // number of segments removed from the front of the queue
|
|
#closed = false
|
|
#error?: Error
|
|
#notify = new Notify()
|
|
|
|
constructor(media: MediaStreamTrack, config: BroadcastConfig) {
|
|
this.name = media.kind
|
|
|
|
// We need to split based on type because Typescript is hard
|
|
if (isAudioTrack(media)) {
|
|
if (!config.audio) throw new Error("no audio config")
|
|
this.#runAudio(media, config.audio).catch((err) => this.#close(err))
|
|
} else if (isVideoTrack(media)) {
|
|
if (!config.video) throw new Error("no video config")
|
|
this.#runVideo(media, config.video).catch((err) => this.#close(err))
|
|
} else {
|
|
throw new Error(`unknown track type: ${media.kind}`)
|
|
}
|
|
}
|
|
|
|
async #runAudio(track: MediaStreamAudioTrack, config: AudioEncoderConfig) {
|
|
const source = new MediaStreamTrackProcessor({ track })
|
|
const encoder = new Audio.Encoder(config)
|
|
const container = new Container()
|
|
|
|
// Split the container at keyframe boundaries
|
|
const segments = new WritableStream({
|
|
write: (chunk) => this.#write(chunk),
|
|
close: () => this.#close(),
|
|
abort: (e) => this.#close(e),
|
|
})
|
|
|
|
return source.readable.pipeThrough(encoder.frames).pipeThrough(container.encode).pipeTo(segments)
|
|
}
|
|
|
|
async #runVideo(track: MediaStreamVideoTrack, config: VideoEncoderConfig) {
|
|
const source = new MediaStreamTrackProcessor({ track })
|
|
const encoder = new Video.Encoder(config)
|
|
const container = new Container()
|
|
|
|
// Split the container at keyframe boundaries
|
|
const segments = new WritableStream({
|
|
write: (chunk) => this.#write(chunk),
|
|
close: () => this.#close(),
|
|
abort: (e) => this.#close(e),
|
|
})
|
|
|
|
return source.readable.pipeThrough(encoder.frames).pipeThrough(container.encode).pipeTo(segments)
|
|
}
|
|
|
|
async #write(chunk: Chunk) {
|
|
if (chunk.type === "init") {
|
|
this.#init = chunk.data
|
|
this.#notify.wake()
|
|
return
|
|
}
|
|
|
|
let current = this.#segments.at(-1)
|
|
if (!current || chunk.type === "key") {
|
|
if (current) {
|
|
await current.input.close()
|
|
}
|
|
|
|
const segment = new Segment(this.#offset + this.#segments.length)
|
|
this.#segments.push(segment)
|
|
|
|
this.#notify.wake()
|
|
|
|
current = segment
|
|
|
|
// Clear old segments
|
|
while (this.#segments.length > 1) {
|
|
const first = this.#segments[0]
|
|
|
|
// Expire after 10s
|
|
if (chunk.timestamp - first.timestamp < 10_000_000) break
|
|
this.#segments.shift()
|
|
this.#offset += 1
|
|
|
|
await first.input.abort("expired")
|
|
}
|
|
}
|
|
|
|
const writer = current.input.getWriter()
|
|
|
|
if ((writer.desiredSize || 0) > 0) {
|
|
await writer.write(chunk)
|
|
} else {
|
|
console.warn("dropping chunk", writer.desiredSize)
|
|
}
|
|
|
|
writer.releaseLock()
|
|
}
|
|
|
|
async #close(e?: Error) {
|
|
this.#error = e
|
|
|
|
const current = this.#segments.at(-1)
|
|
if (current) {
|
|
await current.input.close()
|
|
}
|
|
|
|
this.#closed = true
|
|
this.#notify.wake()
|
|
}
|
|
|
|
async init(): Promise<Uint8Array> {
|
|
while (!this.#init) {
|
|
if (this.#closed) throw new Error("track closed")
|
|
await this.#notify.wait()
|
|
}
|
|
|
|
return this.#init
|
|
}
|
|
|
|
// TODO generize this
|
|
segments(): ReadableStream<Segment> {
|
|
let pos = this.#offset
|
|
|
|
return new ReadableStream({
|
|
pull: async (controller) => {
|
|
for (;;) {
|
|
let index = pos - this.#offset
|
|
if (index < 0) index = 0
|
|
|
|
if (index < this.#segments.length) {
|
|
controller.enqueue(this.#segments[index])
|
|
pos += 1
|
|
return // Called again when more data is requested
|
|
}
|
|
|
|
if (this.#error) {
|
|
controller.error(this.#error)
|
|
return
|
|
} else if (this.#closed) {
|
|
controller.close()
|
|
return
|
|
}
|
|
|
|
// Pull again on wakeup
|
|
// NOTE: We can't return until we enqueue at least one segment.
|
|
await this.#notify.wait()
|
|
}
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
function isAudioTrack(track: MediaStreamTrack): track is MediaStreamAudioTrack {
|
|
return track.kind === "audio"
|
|
}
|
|
|
|
function isVideoTrack(track: MediaStreamTrack): track is MediaStreamVideoTrack {
|
|
return track.kind === "video"
|
|
}
|