mirror of
https://github.com/nestriness/nestri.git
synced 2025-12-12 16:55:37 +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:
218
packages/moq/media/catalog/index.ts
Normal file
218
packages/moq/media/catalog/index.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { Connection } from "../../transport"
|
||||
import { asError } from "../../common/error"
|
||||
|
||||
export interface CommonTrackFields {
|
||||
namespace?: string
|
||||
packaging?: string
|
||||
renderGroup?: number
|
||||
altGroup?: number
|
||||
}
|
||||
|
||||
export interface Root {
|
||||
version: number
|
||||
streamingFormat: number
|
||||
streamingFormatVersion: string
|
||||
supportsDeltaUpdates: boolean
|
||||
commonTrackFields: CommonTrackFields
|
||||
tracks: Track[]
|
||||
}
|
||||
|
||||
export function encode(catalog: Root): Uint8Array {
|
||||
const encoder = new TextEncoder()
|
||||
const str = JSON.stringify(catalog)
|
||||
return encoder.encode(str)
|
||||
}
|
||||
|
||||
export function decode(raw: Uint8Array): Root {
|
||||
const decoder = new TextDecoder()
|
||||
const str = decoder.decode(raw)
|
||||
|
||||
const catalog = JSON.parse(str)
|
||||
if (!isRoot(catalog)) {
|
||||
throw new Error("invalid catalog")
|
||||
}
|
||||
|
||||
// Merge common track fields into each track.
|
||||
for (const track of catalog.tracks) {
|
||||
track.altGroup ??= catalog.commonTrackFields.altGroup
|
||||
track.namespace ??= catalog.commonTrackFields.namespace
|
||||
track.packaging ??= catalog.commonTrackFields.packaging
|
||||
track.renderGroup ??= catalog.commonTrackFields.renderGroup
|
||||
}
|
||||
|
||||
return catalog
|
||||
}
|
||||
|
||||
export async function fetch(connection: Connection, namespace: string): Promise<Root> {
|
||||
const subscribe = await connection.subscribe(namespace, ".catalog")
|
||||
try {
|
||||
const segment = await subscribe.data()
|
||||
if (!segment) throw new Error("no catalog data")
|
||||
|
||||
const chunk = await segment.read()
|
||||
if (!chunk) throw new Error("no catalog chunk")
|
||||
|
||||
await segment.close()
|
||||
await subscribe.close() // we done
|
||||
|
||||
if (chunk.payload instanceof Uint8Array) {
|
||||
return decode(chunk.payload)
|
||||
} else {
|
||||
throw new Error("invalid catalog chunk")
|
||||
}
|
||||
} catch (e) {
|
||||
const err = asError(e)
|
||||
|
||||
// Close the subscription after we're done.
|
||||
await subscribe.close(1n, err.message)
|
||||
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
export function isRoot(catalog: any): catalog is Root {
|
||||
if (!isCatalogFieldValid(catalog, "packaging")) return false
|
||||
if (!isCatalogFieldValid(catalog, "namespace")) return false
|
||||
if (!Array.isArray(catalog.tracks)) return false
|
||||
return catalog.tracks.every((track: any) => isTrack(track))
|
||||
}
|
||||
|
||||
export interface Track {
|
||||
namespace?: string
|
||||
name: string
|
||||
depends?: any[]
|
||||
packaging?: string
|
||||
renderGroup?: number
|
||||
selectionParams: SelectionParams // technically optional but not really
|
||||
altGroup?: number
|
||||
initTrack?: string
|
||||
initData?: string
|
||||
}
|
||||
|
||||
export interface Mp4Track extends Track {
|
||||
initTrack?: string
|
||||
initData?: string
|
||||
selectionParams: Mp4SelectionParams
|
||||
}
|
||||
|
||||
export interface SelectionParams {
|
||||
codec?: string
|
||||
mimeType?: string
|
||||
bitrate?: number
|
||||
lang?: string
|
||||
}
|
||||
|
||||
export interface Mp4SelectionParams extends SelectionParams {
|
||||
mimeType: "video/mp4"
|
||||
}
|
||||
|
||||
export interface AudioTrack extends Track {
|
||||
name: string
|
||||
selectionParams: AudioSelectionParams
|
||||
}
|
||||
|
||||
export interface AudioSelectionParams extends SelectionParams {
|
||||
samplerate: number
|
||||
channelConfig: string
|
||||
}
|
||||
|
||||
export interface VideoTrack extends Track {
|
||||
name: string
|
||||
selectionParams: VideoSelectionParams
|
||||
temporalId?: number
|
||||
spatialId?: number
|
||||
}
|
||||
|
||||
export interface VideoSelectionParams extends SelectionParams {
|
||||
width: number
|
||||
height: number
|
||||
displayWidth?: number
|
||||
displayHeight?: number
|
||||
framerate?: number
|
||||
}
|
||||
|
||||
export function isTrack(track: any): track is Track {
|
||||
if (typeof track.name !== "string") return false
|
||||
return true
|
||||
}
|
||||
|
||||
export function isMp4Track(track: any): track is Mp4Track {
|
||||
if (!isTrack(track)) return false
|
||||
if (typeof track.initTrack !== "string" && typeof track.initData !== "string") return false
|
||||
if (typeof track.selectionParams.mimeType !== "string") return false
|
||||
return true
|
||||
}
|
||||
|
||||
export function isVideoTrack(track: any): track is VideoTrack {
|
||||
if (!isTrack(track)) return false
|
||||
return isVideoSelectionParams(track.selectionParams)
|
||||
}
|
||||
|
||||
export function isVideoSelectionParams(params: any): params is VideoSelectionParams {
|
||||
if (typeof params.width !== "number") return false
|
||||
if (typeof params.height !== "number") return false
|
||||
return true
|
||||
}
|
||||
|
||||
export function isAudioTrack(track: any): track is AudioTrack {
|
||||
if (!isTrack(track)) return false
|
||||
return isAudioSelectionParams(track.selectionParams)
|
||||
}
|
||||
|
||||
export function isAudioSelectionParams(params: any): params is AudioSelectionParams {
|
||||
if (typeof params.channelConfig !== "string") return false
|
||||
if (typeof params.samplerate !== "number") return false
|
||||
return true
|
||||
}
|
||||
|
||||
function isCatalogFieldValid(catalog: any, field: string): boolean {
|
||||
//packaging,namespace if common would be listed in commonTrackFields but if fields
|
||||
//in commonTrackFields are mentiond in Tracks , the fields in Tracks precedes
|
||||
|
||||
function isValidPackaging(packaging: any): boolean {
|
||||
return packaging === "cmaf" || packaging === "loc"
|
||||
}
|
||||
|
||||
function isValidNamespace(namespace: any): boolean {
|
||||
return typeof namespace === "string"
|
||||
}
|
||||
|
||||
let isValidField: (value: any) => boolean
|
||||
if (field === "packaging") {
|
||||
isValidField = isValidPackaging
|
||||
} else if (field === "namespace") {
|
||||
isValidField = isValidNamespace
|
||||
} else {
|
||||
throw new Error(`Invalid field: ${field}`)
|
||||
}
|
||||
|
||||
if (catalog.commonTrackFields[field] !== undefined && !isValidField(catalog.commonTrackFields[field])) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (const track of catalog.tracks) {
|
||||
if (track[field] !== undefined && !isValidField(track[field])) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export function isMediaTrack(track: any): track is Track {
|
||||
if (track.name.toLowerCase().includes("audio") || track.name.toLowerCase().includes("video")) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (track.selectionParams && track.selectionParams.codec) {
|
||||
const codec = track.selectionParams.codec.toLowerCase()
|
||||
const acceptedCodecs = ["mp4a", "avc1"]
|
||||
|
||||
for (const acceptedCodec of acceptedCodecs) {
|
||||
if (codec.includes(acceptedCodec)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
37
packages/moq/media/mp4/index.ts
Normal file
37
packages/moq/media/mp4/index.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
// Rename some stuff so it's on brand.
|
||||
// We need a separate file so this file can use the rename too.
|
||||
import * as MP4 from "./rename"
|
||||
export * from "./rename"
|
||||
|
||||
export * from "./parser"
|
||||
|
||||
export function isAudioTrack(track: MP4.Track): track is MP4.AudioTrack {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
return (track as MP4.AudioTrack).audio !== undefined
|
||||
}
|
||||
|
||||
export function isVideoTrack(track: MP4.Track): track is MP4.VideoTrack {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
return (track as MP4.VideoTrack).video !== undefined
|
||||
}
|
||||
|
||||
// TODO contribute to mp4box
|
||||
MP4.BoxParser.dOpsBox.prototype.write = function (stream: MP4.Stream) {
|
||||
this.size = this.ChannelMappingFamily === 0 ? 11 : 13 + this.ChannelMapping!.length
|
||||
this.writeHeader(stream)
|
||||
|
||||
stream.writeUint8(this.Version)
|
||||
stream.writeUint8(this.OutputChannelCount)
|
||||
stream.writeUint16(this.PreSkip)
|
||||
stream.writeUint32(this.InputSampleRate)
|
||||
stream.writeInt16(this.OutputGain)
|
||||
stream.writeUint8(this.ChannelMappingFamily)
|
||||
|
||||
if (this.ChannelMappingFamily !== 0) {
|
||||
stream.writeUint8(this.StreamCount!)
|
||||
stream.writeUint8(this.CoupledCount!)
|
||||
for (const mapping of this.ChannelMapping!) {
|
||||
stream.writeUint8(mapping)
|
||||
}
|
||||
}
|
||||
}
|
||||
71
packages/moq/media/mp4/parser.ts
Normal file
71
packages/moq/media/mp4/parser.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import * as MP4 from "./index"
|
||||
|
||||
export interface Frame {
|
||||
track: MP4.Track // The track this frame belongs to
|
||||
sample: MP4.Sample // The actual sample contain the frame data
|
||||
}
|
||||
|
||||
// Decode a MP4 container into individual samples.
|
||||
export class Parser {
|
||||
info!: MP4.Info
|
||||
|
||||
#mp4 = MP4.New()
|
||||
#offset = 0
|
||||
|
||||
#samples: Array<Frame> = []
|
||||
|
||||
constructor(init: Uint8Array) {
|
||||
this.#mp4.onError = (err) => {
|
||||
console.error("MP4 error", err)
|
||||
}
|
||||
|
||||
this.#mp4.onReady = (info: MP4.Info) => {
|
||||
this.info = info
|
||||
|
||||
// Extract all of the tracks, because we don't know if it's audio or video.
|
||||
for (const track of info.tracks) {
|
||||
this.#mp4.setExtractionOptions(track.id, track, { nbSamples: 1 })
|
||||
}
|
||||
}
|
||||
|
||||
this.#mp4.onSamples = (_track_id: number, track: MP4.Track, samples: MP4.Sample[]) => {
|
||||
for (const sample of samples) {
|
||||
this.#samples.push({ track, sample })
|
||||
}
|
||||
}
|
||||
|
||||
this.#mp4.start()
|
||||
|
||||
// For some reason we need to modify the underlying ArrayBuffer with offset
|
||||
const copy = new Uint8Array(init)
|
||||
const buffer = copy.buffer as MP4.ArrayBuffer
|
||||
buffer.fileStart = this.#offset
|
||||
|
||||
this.#mp4.appendBuffer(buffer)
|
||||
this.#offset += buffer.byteLength
|
||||
this.#mp4.flush()
|
||||
|
||||
if (!this.info) {
|
||||
throw new Error("could not parse MP4 info")
|
||||
}
|
||||
}
|
||||
|
||||
decode(chunk: Uint8Array): Array<Frame> {
|
||||
const copy = new Uint8Array(chunk)
|
||||
|
||||
// For some reason we need to modify the underlying ArrayBuffer with offset
|
||||
const buffer = copy.buffer as MP4.ArrayBuffer
|
||||
buffer.fileStart = this.#offset
|
||||
|
||||
// Parse the data
|
||||
this.#mp4.appendBuffer(buffer)
|
||||
this.#mp4.flush()
|
||||
|
||||
this.#offset += buffer.byteLength
|
||||
|
||||
const samples = [...this.#samples]
|
||||
this.#samples.length = 0
|
||||
|
||||
return samples
|
||||
}
|
||||
}
|
||||
13
packages/moq/media/mp4/rename.ts
Normal file
13
packages/moq/media/mp4/rename.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
// Rename some stuff so it's on brand.
|
||||
export { createFile as New, DataStream as Stream, ISOFile, BoxParser, Log } from "mp4box"
|
||||
|
||||
export type {
|
||||
MP4ArrayBuffer as ArrayBuffer,
|
||||
MP4Info as Info,
|
||||
MP4Track as Track,
|
||||
MP4AudioTrack as AudioTrack,
|
||||
MP4VideoTrack as VideoTrack,
|
||||
Sample,
|
||||
TrackOptions,
|
||||
SampleOptions,
|
||||
} from "mp4box"
|
||||
15
packages/moq/media/tsconfig.json
Normal file
15
packages/moq/media/tsconfig.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"extends": "../tsconfig.json",
|
||||
"include": ["."],
|
||||
"compilerOptions": {
|
||||
"types": ["mp4box"]
|
||||
},
|
||||
"references": [
|
||||
{
|
||||
"path": "../transport"
|
||||
},
|
||||
{
|
||||
"path": "../common"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user