mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-12 08:45:38 +02:00
✨ feat: Host a relay on Hetzner (#114)
We are hosting a [MoQ](https://quic.video) relay on a remote (bare metal) server on Hetzner With a lot of help from @victorpahuus
This commit is contained in:
75
packages/moq/contribute/audio.ts
Normal file
75
packages/moq/contribute/audio.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
const SUPPORTED = [
|
||||
// TODO support AAC
|
||||
// "mp4a"
|
||||
"Opus",
|
||||
]
|
||||
|
||||
export class Encoder {
|
||||
#encoder!: AudioEncoder
|
||||
#encoderConfig: AudioEncoderConfig
|
||||
#decoderConfig?: AudioDecoderConfig
|
||||
|
||||
frames: TransformStream<AudioData, AudioDecoderConfig | EncodedAudioChunk>
|
||||
|
||||
constructor(config: AudioEncoderConfig) {
|
||||
this.#encoderConfig = config
|
||||
|
||||
this.frames = new TransformStream({
|
||||
start: this.#start.bind(this),
|
||||
transform: this.#transform.bind(this),
|
||||
flush: this.#flush.bind(this),
|
||||
})
|
||||
}
|
||||
|
||||
#start(controller: TransformStreamDefaultController<AudioDecoderConfig | EncodedAudioChunk>) {
|
||||
this.#encoder = new AudioEncoder({
|
||||
output: (frame, metadata) => {
|
||||
this.#enqueue(controller, frame, metadata)
|
||||
},
|
||||
error: (err) => {
|
||||
throw err
|
||||
},
|
||||
})
|
||||
|
||||
this.#encoder.configure(this.#encoderConfig)
|
||||
}
|
||||
|
||||
#transform(frame: AudioData) {
|
||||
this.#encoder.encode(frame)
|
||||
frame.close()
|
||||
}
|
||||
|
||||
#enqueue(
|
||||
controller: TransformStreamDefaultController<AudioDecoderConfig | EncodedAudioChunk>,
|
||||
frame: EncodedAudioChunk,
|
||||
metadata?: EncodedAudioChunkMetadata,
|
||||
) {
|
||||
const config = metadata?.decoderConfig
|
||||
if (config && !this.#decoderConfig) {
|
||||
const config = metadata.decoderConfig
|
||||
if (!config) throw new Error("missing decoder config")
|
||||
|
||||
controller.enqueue(config)
|
||||
this.#decoderConfig = config
|
||||
}
|
||||
|
||||
controller.enqueue(frame)
|
||||
}
|
||||
|
||||
#flush() {
|
||||
this.#encoder.close()
|
||||
}
|
||||
|
||||
static async isSupported(config: AudioEncoderConfig) {
|
||||
// Check if we support a specific codec family
|
||||
const short = config.codec.substring(0, 4)
|
||||
if (!SUPPORTED.includes(short)) return false
|
||||
|
||||
const res = await AudioEncoder.isConfigSupported(config)
|
||||
return !!res.supported
|
||||
}
|
||||
|
||||
get config() {
|
||||
return this.#encoderConfig
|
||||
}
|
||||
}
|
||||
241
packages/moq/contribute/broadcast.ts
Normal file
241
packages/moq/contribute/broadcast.ts
Normal file
@@ -0,0 +1,241 @@
|
||||
import { Connection, SubscribeRecv } from "../transport"
|
||||
import { asError } from "../common/error"
|
||||
import { Segment } from "./segment"
|
||||
import { Track } from "./track"
|
||||
import * as Catalog from "../media/catalog"
|
||||
|
||||
import { isAudioTrackSettings, isVideoTrackSettings } from "../common/settings"
|
||||
|
||||
export interface BroadcastConfig {
|
||||
namespace: string
|
||||
connection: Connection
|
||||
media: MediaStream
|
||||
|
||||
audio?: AudioEncoderConfig
|
||||
video?: VideoEncoderConfig
|
||||
}
|
||||
|
||||
export interface BroadcastConfigTrack {
|
||||
codec: string
|
||||
bitrate: number
|
||||
}
|
||||
|
||||
export class Broadcast {
|
||||
#tracks = new Map<string, Track>()
|
||||
|
||||
readonly config: BroadcastConfig
|
||||
readonly catalog: Catalog.Root
|
||||
readonly connection: Connection
|
||||
readonly namespace: string
|
||||
|
||||
#running: Promise<void>
|
||||
|
||||
constructor(config: BroadcastConfig) {
|
||||
this.connection = config.connection
|
||||
this.config = config
|
||||
this.namespace = config.namespace
|
||||
|
||||
const tracks: Catalog.Track[] = []
|
||||
|
||||
for (const media of this.config.media.getTracks()) {
|
||||
const track = new Track(media, config)
|
||||
this.#tracks.set(track.name, track)
|
||||
|
||||
const settings = media.getSettings()
|
||||
|
||||
if (isVideoTrackSettings(settings)) {
|
||||
if (!config.video) {
|
||||
throw new Error("no video configuration provided")
|
||||
}
|
||||
|
||||
const video: Catalog.VideoTrack = {
|
||||
namespace: this.namespace,
|
||||
name: `${track.name}.m4s`,
|
||||
initTrack: `${track.name}.mp4`,
|
||||
selectionParams: {
|
||||
mimeType: "video/mp4",
|
||||
codec: config.video.codec,
|
||||
width: settings.width,
|
||||
height: settings.height,
|
||||
framerate: settings.frameRate,
|
||||
bitrate: config.video.bitrate,
|
||||
},
|
||||
}
|
||||
|
||||
tracks.push(video)
|
||||
} else if (isAudioTrackSettings(settings)) {
|
||||
if (!config.audio) {
|
||||
throw new Error("no audio configuration provided")
|
||||
}
|
||||
|
||||
const audio: Catalog.AudioTrack = {
|
||||
namespace: this.namespace,
|
||||
name: `${track.name}.m4s`,
|
||||
initTrack: `${track.name}.mp4`,
|
||||
selectionParams: {
|
||||
mimeType: "audio/ogg",
|
||||
codec: config.audio.codec,
|
||||
samplerate: settings.sampleRate,
|
||||
//sampleSize: settings.sampleSize,
|
||||
channelConfig: `${settings.channelCount}`,
|
||||
bitrate: config.audio.bitrate,
|
||||
},
|
||||
}
|
||||
|
||||
tracks.push(audio)
|
||||
} else {
|
||||
throw new Error(`unknown track type: ${media.kind}`)
|
||||
}
|
||||
}
|
||||
|
||||
this.catalog = {
|
||||
version: 1,
|
||||
streamingFormat: 1,
|
||||
streamingFormatVersion: "0.2",
|
||||
supportsDeltaUpdates: false,
|
||||
commonTrackFields: {
|
||||
packaging: "cmaf",
|
||||
renderGroup: 1,
|
||||
},
|
||||
tracks,
|
||||
}
|
||||
|
||||
this.#running = this.#run()
|
||||
}
|
||||
|
||||
async #run() {
|
||||
await this.connection.announce(this.namespace)
|
||||
|
||||
for (;;) {
|
||||
const subscriber = await this.connection.subscribed()
|
||||
if (!subscriber) break
|
||||
|
||||
// Run an async task to serve each subscription.
|
||||
this.#serveSubscribe(subscriber).catch((e) => {
|
||||
const err = asError(e)
|
||||
console.warn("failed to serve subscribe", err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async #serveSubscribe(subscriber: SubscribeRecv) {
|
||||
try {
|
||||
const [base, ext] = splitExt(subscriber.track)
|
||||
if (ext === "catalog") {
|
||||
await this.#serveCatalog(subscriber, base)
|
||||
} else if (ext === "mp4") {
|
||||
await this.#serveInit(subscriber, base)
|
||||
} else if (ext === "m4s") {
|
||||
await this.#serveTrack(subscriber, base)
|
||||
} else {
|
||||
throw new Error(`unknown subscription: ${subscriber.track}`)
|
||||
}
|
||||
} catch (e) {
|
||||
const err = asError(e)
|
||||
await subscriber.close(1n, `failed to process subscribe: ${err.message}`)
|
||||
} finally {
|
||||
// TODO we can't close subscribers because there's no support for clean termination
|
||||
// await subscriber.close()
|
||||
}
|
||||
}
|
||||
|
||||
async #serveCatalog(subscriber: SubscribeRecv, name: string) {
|
||||
// We only support ".catalog"
|
||||
if (name !== "") throw new Error(`unknown catalog: ${name}`)
|
||||
|
||||
const bytes = Catalog.encode(this.catalog)
|
||||
|
||||
// Send a SUBSCRIBE_OK
|
||||
await subscriber.ack()
|
||||
|
||||
const stream = await subscriber.group({ group: 0 })
|
||||
await stream.write({ object: 0, payload: bytes })
|
||||
await stream.close()
|
||||
}
|
||||
|
||||
async #serveInit(subscriber: SubscribeRecv, name: string) {
|
||||
const track = this.#tracks.get(name)
|
||||
if (!track) throw new Error(`no track with name ${subscriber.track}`)
|
||||
|
||||
// Send a SUBSCRIBE_OK
|
||||
await subscriber.ack()
|
||||
|
||||
const init = await track.init()
|
||||
|
||||
const stream = await subscriber.group({ group: 0 })
|
||||
await stream.write({ object: 0, payload: init })
|
||||
await stream.close()
|
||||
}
|
||||
|
||||
async #serveTrack(subscriber: SubscribeRecv, name: string) {
|
||||
const track = this.#tracks.get(name)
|
||||
if (!track) throw new Error(`no track with name ${subscriber.track}`)
|
||||
|
||||
// Send a SUBSCRIBE_OK
|
||||
await subscriber.ack()
|
||||
|
||||
const segments = track.segments().getReader()
|
||||
|
||||
for (;;) {
|
||||
const { value: segment, done } = await segments.read()
|
||||
if (done) break
|
||||
|
||||
// Serve the segment and log any errors that occur.
|
||||
this.#serveSegment(subscriber, segment).catch((e) => {
|
||||
const err = asError(e)
|
||||
console.warn("failed to serve segment", err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async #serveSegment(subscriber: SubscribeRecv, segment: Segment) {
|
||||
// Create a new stream for each segment.
|
||||
const stream = await subscriber.group({
|
||||
group: segment.id,
|
||||
priority: 0, // TODO
|
||||
})
|
||||
|
||||
let object = 0
|
||||
|
||||
// Pipe the segment to the stream.
|
||||
const chunks = segment.chunks().getReader()
|
||||
for (;;) {
|
||||
const { value, done } = await chunks.read()
|
||||
if (done) break
|
||||
|
||||
await stream.write({
|
||||
object,
|
||||
payload: value,
|
||||
})
|
||||
|
||||
object += 1
|
||||
}
|
||||
|
||||
await stream.close()
|
||||
}
|
||||
|
||||
// Attach the captured video stream to the given video element.
|
||||
attach(video: HTMLVideoElement) {
|
||||
video.srcObject = this.config.media
|
||||
}
|
||||
|
||||
close() {
|
||||
// TODO implement publish close
|
||||
}
|
||||
|
||||
// Returns the error message when the connection is closed
|
||||
async closed(): Promise<Error> {
|
||||
try {
|
||||
await this.#running
|
||||
return new Error("closed") // clean termination
|
||||
} catch (e) {
|
||||
return asError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function splitExt(s: string): [string, string] {
|
||||
const i = s.lastIndexOf(".")
|
||||
if (i < 0) throw new Error(`no extension found`)
|
||||
return [s.substring(0, i), s.substring(i + 1)]
|
||||
}
|
||||
7
packages/moq/contribute/chunk.ts
Normal file
7
packages/moq/contribute/chunk.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// Extends EncodedVideoChunk, allowing a new "init" type
|
||||
export interface Chunk {
|
||||
type: "init" | "key" | "delta"
|
||||
timestamp: number // microseconds
|
||||
duration: number // microseconds
|
||||
data: Uint8Array
|
||||
}
|
||||
165
packages/moq/contribute/container.ts
Normal file
165
packages/moq/contribute/container.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import * as MP4 from "../media/mp4"
|
||||
import { Chunk } from "./chunk"
|
||||
|
||||
type DecoderConfig = AudioDecoderConfig | VideoDecoderConfig
|
||||
type EncodedChunk = EncodedAudioChunk | EncodedVideoChunk
|
||||
|
||||
export class Container {
|
||||
#mp4: MP4.ISOFile
|
||||
#frame?: EncodedAudioChunk | EncodedVideoChunk // 1 frame buffer
|
||||
#track?: number
|
||||
#segment = 0
|
||||
|
||||
encode: TransformStream<DecoderConfig | EncodedChunk, Chunk>
|
||||
|
||||
constructor() {
|
||||
this.#mp4 = new MP4.ISOFile()
|
||||
this.#mp4.init()
|
||||
|
||||
this.encode = new TransformStream({
|
||||
transform: (frame, controller) => {
|
||||
if (isDecoderConfig(frame)) {
|
||||
return this.#init(frame, controller)
|
||||
} else {
|
||||
return this.#enqueue(frame, controller)
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#init(frame: DecoderConfig, controller: TransformStreamDefaultController<Chunk>) {
|
||||
if (this.#track) throw new Error("duplicate decoder config")
|
||||
|
||||
let codec = frame.codec.substring(0, 4)
|
||||
if (codec == "opus") {
|
||||
codec = "Opus"
|
||||
}
|
||||
|
||||
const options: MP4.TrackOptions = {
|
||||
type: codec,
|
||||
timescale: 1_000_000,
|
||||
}
|
||||
|
||||
if (isVideoConfig(frame)) {
|
||||
options.width = frame.codedWidth
|
||||
options.height = frame.codedHeight
|
||||
} else {
|
||||
options.channel_count = frame.numberOfChannels
|
||||
options.samplerate = frame.sampleRate
|
||||
}
|
||||
|
||||
if (!frame.description) throw new Error("missing frame description")
|
||||
const desc = frame.description as ArrayBufferLike
|
||||
|
||||
if (codec === "avc1") {
|
||||
options.avcDecoderConfigRecord = desc
|
||||
} else if (codec === "hev1") {
|
||||
options.hevcDecoderConfigRecord = desc
|
||||
} else if (codec === "Opus") {
|
||||
// description is an identification header: https://datatracker.ietf.org/doc/html/rfc7845#section-5.1
|
||||
// The first 8 bytes are the magic string "OpusHead", followed by what we actually want.
|
||||
const dops = new MP4.BoxParser.dOpsBox(undefined)
|
||||
|
||||
// Annoyingly, the header is little endian while MP4 is big endian, so we have to parse.
|
||||
const data = new MP4.Stream(desc, 8, MP4.Stream.LITTLE_ENDIAN)
|
||||
dops.parse(data)
|
||||
|
||||
dops.Version = 0
|
||||
options.description = dops
|
||||
options.hdlr = "soun"
|
||||
} else {
|
||||
throw new Error(`unsupported codec: ${codec}`)
|
||||
}
|
||||
|
||||
this.#track = this.#mp4.addTrack(options)
|
||||
if (!this.#track) throw new Error("failed to initialize MP4 track")
|
||||
|
||||
const buffer = MP4.ISOFile.writeInitializationSegment(this.#mp4.ftyp!, this.#mp4.moov!, 0, 0)
|
||||
const data = new Uint8Array(buffer)
|
||||
|
||||
controller.enqueue({
|
||||
type: "init",
|
||||
timestamp: 0,
|
||||
duration: 0,
|
||||
data,
|
||||
})
|
||||
}
|
||||
|
||||
#enqueue(frame: EncodedChunk, controller: TransformStreamDefaultController<Chunk>) {
|
||||
// Check if we should create a new segment
|
||||
if (frame.type == "key") {
|
||||
this.#segment += 1
|
||||
} else if (this.#segment == 0) {
|
||||
throw new Error("must start with keyframe")
|
||||
}
|
||||
|
||||
// We need a one frame buffer to compute the duration
|
||||
if (!this.#frame) {
|
||||
this.#frame = frame
|
||||
return
|
||||
}
|
||||
|
||||
const duration = frame.timestamp - this.#frame.timestamp
|
||||
|
||||
// TODO avoid this extra copy by writing to the mdat directly
|
||||
// ...which means changing mp4box.js to take an offset instead of ArrayBuffer
|
||||
const buffer = new Uint8Array(this.#frame.byteLength)
|
||||
this.#frame.copyTo(buffer)
|
||||
|
||||
if (!this.#track) throw new Error("missing decoder config")
|
||||
|
||||
// Add the sample to the container
|
||||
this.#mp4.addSample(this.#track, buffer, {
|
||||
duration,
|
||||
dts: this.#frame.timestamp,
|
||||
cts: this.#frame.timestamp,
|
||||
is_sync: this.#frame.type == "key",
|
||||
})
|
||||
|
||||
const stream = new MP4.Stream(undefined, 0, MP4.Stream.BIG_ENDIAN)
|
||||
|
||||
// Moof and mdat atoms are written in pairs.
|
||||
// TODO remove the moof/mdat from the Box to reclaim memory once everything works
|
||||
for (;;) {
|
||||
const moof = this.#mp4.moofs.shift()
|
||||
const mdat = this.#mp4.mdats.shift()
|
||||
|
||||
if (!moof && !mdat) break
|
||||
if (!moof) throw new Error("moof missing")
|
||||
if (!mdat) throw new Error("mdat missing")
|
||||
|
||||
moof.write(stream)
|
||||
mdat.write(stream)
|
||||
}
|
||||
|
||||
// TODO avoid this extra copy by writing to the buffer provided in copyTo
|
||||
const data = new Uint8Array(stream.buffer)
|
||||
|
||||
controller.enqueue({
|
||||
type: this.#frame.type,
|
||||
timestamp: this.#frame.timestamp,
|
||||
duration: this.#frame.duration ?? 0,
|
||||
data,
|
||||
})
|
||||
|
||||
this.#frame = frame
|
||||
}
|
||||
|
||||
/* TODO flush the last frame
|
||||
#flush(controller: TransformStreamDefaultController<Chunk>) {
|
||||
if (this.#frame) {
|
||||
// TODO guess the duration
|
||||
this.#enqueue(this.#frame, 0, controller)
|
||||
}
|
||||
}
|
||||
*/
|
||||
}
|
||||
|
||||
function isDecoderConfig(frame: DecoderConfig | EncodedChunk): frame is DecoderConfig {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
return (frame as DecoderConfig).codec !== undefined
|
||||
}
|
||||
|
||||
function isVideoConfig(frame: DecoderConfig): frame is VideoDecoderConfig {
|
||||
return (frame as VideoDecoderConfig).codedWidth !== undefined
|
||||
}
|
||||
5
packages/moq/contribute/index.ts
Normal file
5
packages/moq/contribute/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { Broadcast } from "./broadcast"
|
||||
export type { BroadcastConfig, BroadcastConfigTrack } from "./broadcast"
|
||||
|
||||
export { Encoder as VideoEncoder } from "./video"
|
||||
export { Encoder as AudioEncoder } from "./audio"
|
||||
45
packages/moq/contribute/segment.ts
Normal file
45
packages/moq/contribute/segment.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { Chunk } from "./chunk"
|
||||
|
||||
export class Segment {
|
||||
id: number
|
||||
|
||||
// Take in a stream of chunks
|
||||
input: WritableStream<Chunk>
|
||||
|
||||
// Output a stream of bytes, which we fork for each new subscriber.
|
||||
#cache: ReadableStream<Uint8Array>
|
||||
|
||||
timestamp = 0
|
||||
|
||||
constructor(id: number) {
|
||||
this.id = id
|
||||
|
||||
// Set a max size for each segment, dropping the tail if it gets too long.
|
||||
// We tee the reader, so this limit applies to the FASTEST reader.
|
||||
const backpressure = new ByteLengthQueuingStrategy({ highWaterMark: 8_000_000 })
|
||||
|
||||
const transport = new TransformStream<Chunk, Uint8Array>(
|
||||
{
|
||||
transform: (chunk: Chunk, controller) => {
|
||||
// Compute the max timestamp of the segment
|
||||
this.timestamp = Math.max(chunk.timestamp + chunk.duration)
|
||||
|
||||
// Push the chunk to any listeners.
|
||||
controller.enqueue(chunk.data)
|
||||
},
|
||||
},
|
||||
undefined,
|
||||
backpressure,
|
||||
)
|
||||
|
||||
this.input = transport.writable
|
||||
this.#cache = transport.readable
|
||||
}
|
||||
|
||||
// Split the output reader into two parts.
|
||||
chunks(): ReadableStream<Uint8Array> {
|
||||
const [tee, cache] = this.#cache.tee()
|
||||
this.#cache = cache
|
||||
return tee
|
||||
}
|
||||
}
|
||||
170
packages/moq/contribute/track.ts
Normal file
170
packages/moq/contribute/track.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
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"
|
||||
}
|
||||
18
packages/moq/contribute/tsconfig.json
Normal file
18
packages/moq/contribute/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"include": ["."],
|
||||
"compilerOptions": {
|
||||
"types": ["dom-mediacapture-transform", "dom-webcodecs"]
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"path": "../common"
|
||||
},
|
||||
{
|
||||
"path": "../transport"
|
||||
},
|
||||
{
|
||||
"path": "../media"
|
||||
}
|
||||
]
|
||||
}
|
||||
111
packages/moq/contribute/video.ts
Normal file
111
packages/moq/contribute/video.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
const SUPPORTED = [
|
||||
"avc1", // H.264
|
||||
"hev1", // HEVC (aka h.265)
|
||||
// "av01", // TDOO support AV1
|
||||
]
|
||||
|
||||
export interface EncoderSupported {
|
||||
codecs: string[]
|
||||
}
|
||||
|
||||
export class Encoder {
|
||||
#encoder!: VideoEncoder
|
||||
#encoderConfig: VideoEncoderConfig
|
||||
#decoderConfig?: VideoDecoderConfig
|
||||
|
||||
// true if we should insert a keyframe, undefined when the encoder should decide
|
||||
#keyframeNext: true | undefined = true
|
||||
|
||||
// Count the number of frames without a keyframe.
|
||||
#keyframeCounter = 0
|
||||
|
||||
// Converts raw rames to encoded frames.
|
||||
frames: TransformStream<VideoFrame, VideoDecoderConfig | EncodedVideoChunk>
|
||||
|
||||
constructor(config: VideoEncoderConfig) {
|
||||
config.bitrateMode ??= "constant"
|
||||
config.latencyMode ??= "realtime"
|
||||
|
||||
this.#encoderConfig = config
|
||||
|
||||
this.frames = new TransformStream({
|
||||
start: this.#start.bind(this),
|
||||
transform: this.#transform.bind(this),
|
||||
flush: this.#flush.bind(this),
|
||||
})
|
||||
}
|
||||
|
||||
static async isSupported(config: VideoEncoderConfig) {
|
||||
// Check if we support a specific codec family
|
||||
const short = config.codec.substring(0, 4)
|
||||
if (!SUPPORTED.includes(short)) return false
|
||||
|
||||
// Default to hardware encoding
|
||||
config.hardwareAcceleration ??= "prefer-hardware"
|
||||
|
||||
// Default to CBR
|
||||
config.bitrateMode ??= "constant"
|
||||
|
||||
// Default to realtime encoding
|
||||
config.latencyMode ??= "realtime"
|
||||
|
||||
const res = await VideoEncoder.isConfigSupported(config)
|
||||
return !!res.supported
|
||||
}
|
||||
|
||||
#start(controller: TransformStreamDefaultController<EncodedVideoChunk>) {
|
||||
this.#encoder = new VideoEncoder({
|
||||
output: (frame, metadata) => {
|
||||
this.#enqueue(controller, frame, metadata)
|
||||
},
|
||||
error: (err) => {
|
||||
throw err
|
||||
},
|
||||
})
|
||||
|
||||
this.#encoder.configure(this.#encoderConfig)
|
||||
}
|
||||
|
||||
#transform(frame: VideoFrame) {
|
||||
const encoder = this.#encoder
|
||||
|
||||
// Set keyFrame to undefined when we're not sure so the encoder can decide.
|
||||
encoder.encode(frame, { keyFrame: this.#keyframeNext })
|
||||
this.#keyframeNext = undefined
|
||||
|
||||
frame.close()
|
||||
}
|
||||
|
||||
#enqueue(
|
||||
controller: TransformStreamDefaultController<VideoDecoderConfig | EncodedVideoChunk>,
|
||||
frame: EncodedVideoChunk,
|
||||
metadata?: EncodedVideoChunkMetadata,
|
||||
) {
|
||||
if (!this.#decoderConfig) {
|
||||
const config = metadata?.decoderConfig
|
||||
if (!config) throw new Error("missing decoder config")
|
||||
|
||||
controller.enqueue(config)
|
||||
this.#decoderConfig = config
|
||||
}
|
||||
|
||||
if (frame.type === "key") {
|
||||
this.#keyframeCounter = 0
|
||||
} else {
|
||||
this.#keyframeCounter += 1
|
||||
if (this.#keyframeCounter + this.#encoder.encodeQueueSize >= 2 * this.#encoderConfig.framerate!) {
|
||||
this.#keyframeNext = true
|
||||
}
|
||||
}
|
||||
|
||||
controller.enqueue(frame)
|
||||
}
|
||||
|
||||
#flush() {
|
||||
this.#encoder.close()
|
||||
}
|
||||
|
||||
get config() {
|
||||
return this.#encoderConfig
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user