@volter/world-core 3.0.151 → 3.0.153
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/src/derived-core.d.ts +8 -0
- package/dist/src/derived-core.js +2 -0
- package/dist/src/derived.d.ts +4 -0
- package/dist/src/events.d.ts +4 -4
- package/dist/src/events.js +39 -15
- package/dist/src/index.d.ts +3 -1
- package/dist/src/index.js +2 -1
- package/dist/src/live/access.d.ts +22 -0
- package/dist/src/live/access.js +83 -0
- package/dist/src/live/amf0.d.ts +7 -0
- package/dist/src/live/amf0.js +122 -0
- package/dist/src/live/flv.d.ts +42 -0
- package/dist/src/live/flv.js +148 -0
- package/dist/src/live/fmp4.d.ts +25 -0
- package/dist/src/live/fmp4.js +96 -0
- package/dist/src/live/hls.d.ts +22 -0
- package/dist/src/live/hls.js +46 -0
- package/dist/src/live/index.d.ts +9 -0
- package/dist/src/live/index.js +8 -0
- package/dist/src/live/ingest.d.ts +25 -0
- package/dist/src/live/ingest.js +229 -0
- package/dist/src/live/rtmp.d.ts +35 -0
- package/dist/src/live/rtmp.js +456 -0
- package/dist/src/live/segmenter.d.ts +57 -0
- package/dist/src/live/segmenter.js +204 -0
- package/dist/src/live/types.d.ts +102 -0
- package/dist/src/live/types.js +5 -0
- package/dist/src/pack-fetch.d.ts +4 -0
- package/dist/src/pack-fetch.js +24 -2
- package/dist/src/packRegistry.d.ts +1 -0
- package/dist/src/serve-http.d.ts +7 -1
- package/dist/src/serve-http.js +36 -3
- package/dist/src/twin-fetch.d.ts +4 -1
- package/package.json +5 -1
- package/src/derived-core.ts +10 -0
- package/src/derived.ts +4 -0
- package/src/events.ts +40 -16
- package/src/index.ts +4 -1
- package/src/live/access.ts +89 -0
- package/src/live/amf0.ts +80 -0
- package/src/live/flv.ts +145 -0
- package/src/live/fmp4.ts +102 -0
- package/src/live/hls.ts +59 -0
- package/src/live/index.ts +13 -0
- package/src/live/ingest.ts +210 -0
- package/src/live/rtmp.ts +375 -0
- package/src/live/segmenter.ts +218 -0
- package/src/live/types.ts +105 -0
- package/src/pack-fetch.ts +28 -2
- package/src/packRegistry.ts +1 -1
- package/src/serve-http.ts +29 -4
- package/src/twin-fetch.ts +4 -2
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// An FLV tag's body as RTMP carries it (FLV 10.1, Annex E.4.2 and E.4.3): video as AVC (codec 7, its packet type 0 the
|
|
2
|
+
// AVCDecoderConfigurationRecord, 1 length-prefixed NAL units with a composition time offset), audio as AAC (format 10, its
|
|
3
|
+
// packet type 0 the AudioSpecificConfig, 1 a raw frame). Anything else is named as the codec it is and not read: other
|
|
4
|
+
// codecs, and Enhanced RTMP's ex-header (its first byte's high bit), which carries HEVC, AV1 and multitrack video.
|
|
5
|
+
const VIDEO_CODECS = { 2: 'Sorenson H.263', 3: 'Screen video', 4: 'On2 VP6', 5: 'On2 VP6 with alpha', 6: 'Screen video 2', 12: 'HEVC', 13: 'AV1' };
|
|
6
|
+
const AUDIO_FORMATS = { 0: 'linear PCM', 1: 'ADPCM', 2: 'MP3', 3: 'linear PCM (little-endian)', 4: 'Nellymoser 16 kHz', 5: 'Nellymoser 8 kHz', 6: 'Nellymoser', 7: 'G.711 A-law', 8: 'G.711 mu-law', 11: 'Speex', 14: 'MP3 8 kHz', 15: 'device-specific' };
|
|
7
|
+
const hex2 = (n) => n.toString(16).padStart(2, '0');
|
|
8
|
+
export function readVideoTag(p) {
|
|
9
|
+
if (p.length < 1)
|
|
10
|
+
return { kind: 'other' };
|
|
11
|
+
if (p[0] & 0x80) {
|
|
12
|
+
const fourcc = p.length >= 5 ? String.fromCharCode(p[1], p[2], p[3], p[4]) : '?';
|
|
13
|
+
return { kind: 'unsupported', codec: `Enhanced RTMP ${fourcc}` };
|
|
14
|
+
}
|
|
15
|
+
const frameType = p[0] >> 4;
|
|
16
|
+
const codec = p[0] & 0x0f;
|
|
17
|
+
if (codec !== 7)
|
|
18
|
+
return { kind: 'unsupported', codec: VIDEO_CODECS[codec] ?? `video codec ${codec}` };
|
|
19
|
+
// a video info/command frame (frame type 5) carries no picture
|
|
20
|
+
if (frameType === 5 || p.length < 5)
|
|
21
|
+
return { kind: 'other' };
|
|
22
|
+
const packetType = p[1];
|
|
23
|
+
const cts = ((p[2] << 16) | (p[3] << 8) | p[4]) << 8 >> 8;
|
|
24
|
+
const data = p.subarray(5);
|
|
25
|
+
if (packetType === 0)
|
|
26
|
+
return { kind: 'config', config: avcConfig(data) };
|
|
27
|
+
if (packetType === 1)
|
|
28
|
+
return { kind: 'frame', key: frameType === 1, cts, data };
|
|
29
|
+
return { kind: 'other' };
|
|
30
|
+
}
|
|
31
|
+
export function readAudioTag(p) {
|
|
32
|
+
if (p.length < 2)
|
|
33
|
+
return { kind: 'frame', data: new Uint8Array(0) };
|
|
34
|
+
const format = p[0] >> 4;
|
|
35
|
+
if (format !== 10)
|
|
36
|
+
return { kind: 'unsupported', codec: AUDIO_FORMATS[format] ?? `audio format ${format}` };
|
|
37
|
+
if (p[1] === 0)
|
|
38
|
+
return { kind: 'config', config: aacConfig(p.subarray(2)) };
|
|
39
|
+
return { kind: 'frame', data: p.subarray(2) };
|
|
40
|
+
}
|
|
41
|
+
function avcConfig(record) {
|
|
42
|
+
const copy = record.slice();
|
|
43
|
+
const codec = copy.length >= 4 ? `avc1.${hex2(copy[1])}${hex2(copy[2])}${hex2(copy[3])}` : 'avc1.42e01e';
|
|
44
|
+
let width = 0;
|
|
45
|
+
let height = 0;
|
|
46
|
+
// the first SPS: after the version, profile, compatibility, level, length size and SPS count
|
|
47
|
+
if (copy.length > 8 && (copy[5] & 0x1f) > 0) {
|
|
48
|
+
const length = (copy[6] << 8) | copy[7];
|
|
49
|
+
try {
|
|
50
|
+
({ width, height } = spsSize(copy.subarray(8, 8 + length)));
|
|
51
|
+
}
|
|
52
|
+
catch { /* the metadata's size stands in */ }
|
|
53
|
+
}
|
|
54
|
+
return { record: copy, codec, width, height };
|
|
55
|
+
}
|
|
56
|
+
function aacConfig(asc) {
|
|
57
|
+
const copy = asc.slice();
|
|
58
|
+
const RATES = [96000, 88200, 64000, 48000, 44100, 32000, 24000, 22050, 16000, 12000, 11025, 8000, 7350];
|
|
59
|
+
const bits = new Bits(copy);
|
|
60
|
+
let objectType = bits.u(5);
|
|
61
|
+
if (objectType === 31)
|
|
62
|
+
objectType = 32 + bits.u(6);
|
|
63
|
+
const index = bits.u(4);
|
|
64
|
+
const sampleRate = index === 15 ? bits.u(24) : RATES[index] ?? 44100;
|
|
65
|
+
const channels = bits.u(4);
|
|
66
|
+
return { asc: copy, codec: `mp4a.40.${objectType}`, objectType, sampleRate, channels };
|
|
67
|
+
}
|
|
68
|
+
/** A bit reader over bytes, MSB first; a read past the end reads zeros. */
|
|
69
|
+
class Bits {
|
|
70
|
+
bytes;
|
|
71
|
+
at = 0;
|
|
72
|
+
constructor(bytes) {
|
|
73
|
+
this.bytes = bytes;
|
|
74
|
+
}
|
|
75
|
+
bit() { const byte = this.bytes[this.at >> 3] ?? 0; const v = (byte >> (7 - (this.at & 7))) & 1; this.at++; return v; }
|
|
76
|
+
u(n) { let v = 0; for (let i = 0; i < n; i++)
|
|
77
|
+
v = v * 2 + this.bit(); return v; }
|
|
78
|
+
ue() { let zeros = 0; while (this.bit() === 0) {
|
|
79
|
+
if (++zeros > 31)
|
|
80
|
+
throw new Error('exp-Golomb code too long');
|
|
81
|
+
} return 2 ** zeros - 1 + this.u(zeros); }
|
|
82
|
+
se() { const k = this.ue(); return k % 2 ? (k + 1) / 2 : -(k / 2); }
|
|
83
|
+
}
|
|
84
|
+
/** A sequence parameter set's picture size (H.264 §7.3.2.1.1), its cropping applied. */
|
|
85
|
+
function spsSize(nal) {
|
|
86
|
+
// the NAL unit's RBSP: its header byte off, each emulation-prevention byte (00 00 03) removed
|
|
87
|
+
const rbsp = [];
|
|
88
|
+
for (let i = 1; i < nal.length; i++) {
|
|
89
|
+
if (i >= 3 && nal[i] === 3 && nal[i - 1] === 0 && nal[i - 2] === 0)
|
|
90
|
+
continue;
|
|
91
|
+
rbsp.push(nal[i]);
|
|
92
|
+
}
|
|
93
|
+
const b = new Bits(Uint8Array.from(rbsp));
|
|
94
|
+
const profile = b.u(8);
|
|
95
|
+
b.u(16);
|
|
96
|
+
b.ue();
|
|
97
|
+
let chroma = 1;
|
|
98
|
+
if ([100, 110, 122, 244, 44, 83, 86, 118, 128, 138, 139, 134, 135].includes(profile)) {
|
|
99
|
+
chroma = b.ue();
|
|
100
|
+
if (chroma === 3)
|
|
101
|
+
b.bit();
|
|
102
|
+
b.ue();
|
|
103
|
+
b.ue();
|
|
104
|
+
b.bit();
|
|
105
|
+
if (b.bit()) {
|
|
106
|
+
for (let i = 0; i < (chroma === 3 ? 12 : 8); i++) {
|
|
107
|
+
if (!b.bit())
|
|
108
|
+
continue;
|
|
109
|
+
let last = 8;
|
|
110
|
+
let next = 8;
|
|
111
|
+
for (let j = 0; j < (i < 6 ? 16 : 64); j++) {
|
|
112
|
+
if (next !== 0)
|
|
113
|
+
next = (last + b.se() + 256) % 256;
|
|
114
|
+
last = next === 0 ? last : next;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
b.ue();
|
|
120
|
+
const poc = b.ue();
|
|
121
|
+
if (poc === 0)
|
|
122
|
+
b.ue();
|
|
123
|
+
else if (poc === 1) {
|
|
124
|
+
b.bit();
|
|
125
|
+
b.se();
|
|
126
|
+
b.se();
|
|
127
|
+
const n = b.ue();
|
|
128
|
+
for (let i = 0; i < n; i++)
|
|
129
|
+
b.se();
|
|
130
|
+
}
|
|
131
|
+
b.ue();
|
|
132
|
+
b.bit();
|
|
133
|
+
const widthMbs = b.ue() + 1;
|
|
134
|
+
const heightUnits = b.ue() + 1;
|
|
135
|
+
const frameMbsOnly = b.bit();
|
|
136
|
+
if (!frameMbsOnly)
|
|
137
|
+
b.bit();
|
|
138
|
+
b.bit();
|
|
139
|
+
let crop = [0, 0, 0, 0];
|
|
140
|
+
if (b.bit())
|
|
141
|
+
crop = [b.ue(), b.ue(), b.ue(), b.ue()];
|
|
142
|
+
const unitX = chroma === 1 || chroma === 2 ? 2 : 1;
|
|
143
|
+
const unitY = (chroma === 1 ? 2 : 1) * (2 - frameMbsOnly);
|
|
144
|
+
return {
|
|
145
|
+
width: widthMbs * 16 - (crop[0] + crop[1]) * unitX,
|
|
146
|
+
height: (2 - frameMbsOnly) * heightUnits * 16 - (crop[2] + crop[3]) * unitY,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { AacConfig, AvcConfig } from './flv.js';
|
|
2
|
+
export declare const VIDEO_TRACK = 1;
|
|
3
|
+
export declare const AUDIO_TRACK = 2;
|
|
4
|
+
export declare const VIDEO_TIMESCALE = 90000;
|
|
5
|
+
/** The init segment for the tracks a publish carries. */
|
|
6
|
+
export declare function initSegment(tracks: {
|
|
7
|
+
video?: AvcConfig & {
|
|
8
|
+
width: number;
|
|
9
|
+
height: number;
|
|
10
|
+
};
|
|
11
|
+
audio?: AacConfig;
|
|
12
|
+
}): Uint8Array;
|
|
13
|
+
export type Sample = {
|
|
14
|
+
duration: number;
|
|
15
|
+
data: Uint8Array;
|
|
16
|
+
key: boolean;
|
|
17
|
+
cts?: number;
|
|
18
|
+
};
|
|
19
|
+
export type Fragment = {
|
|
20
|
+
track: number;
|
|
21
|
+
baseTime: number;
|
|
22
|
+
samples: Sample[];
|
|
23
|
+
};
|
|
24
|
+
/** A media segment: one `moof` over the fragments, then their samples in one `mdat`, video's first. */
|
|
25
|
+
export declare function mediaSegment(sequence: number, fragments: Fragment[]): Uint8Array;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
export const VIDEO_TRACK = 1;
|
|
2
|
+
export const AUDIO_TRACK = 2;
|
|
3
|
+
export const VIDEO_TIMESCALE = 90_000;
|
|
4
|
+
const ascii = (s) => Array.from(s, (c) => c.charCodeAt(0));
|
|
5
|
+
const u16 = (n) => [(n >> 8) & 0xff, n & 0xff];
|
|
6
|
+
const u32 = (n) => [(n >>> 24) & 0xff, (n >>> 16) & 0xff, (n >>> 8) & 0xff, n & 0xff];
|
|
7
|
+
const u64 = (n) => [...u32(Math.floor(n / 2 ** 32)), ...u32(n % 2 ** 32)];
|
|
8
|
+
const zeros = (n) => new Array(n).fill(0);
|
|
9
|
+
function box(type, ...parts) {
|
|
10
|
+
const size = 8 + parts.reduce((n, p) => n + p.length, 0);
|
|
11
|
+
const out = new Uint8Array(size);
|
|
12
|
+
out.set(u32(size), 0);
|
|
13
|
+
out.set(ascii(type), 4);
|
|
14
|
+
let at = 8;
|
|
15
|
+
for (const p of parts) {
|
|
16
|
+
out.set(p, at);
|
|
17
|
+
at += p.length;
|
|
18
|
+
}
|
|
19
|
+
return out;
|
|
20
|
+
}
|
|
21
|
+
const full = (type, version, flags, ...parts) => box(type, [version, (flags >> 16) & 0xff, (flags >> 8) & 0xff, flags & 0xff], ...parts);
|
|
22
|
+
const MATRIX = [...u32(0x00010000), ...u32(0), ...u32(0), ...u32(0), ...u32(0x00010000), ...u32(0), ...u32(0), ...u32(0), ...u32(0x40000000)];
|
|
23
|
+
function descriptor(tag, ...parts) {
|
|
24
|
+
const body = parts.flatMap((p) => Array.from(p));
|
|
25
|
+
// the size as four bytes of seven bits, as MP4 writers write it
|
|
26
|
+
const n = body.length;
|
|
27
|
+
return [tag, 0x80 | ((n >> 21) & 0x7f), 0x80 | ((n >> 14) & 0x7f), 0x80 | ((n >> 7) & 0x7f), n & 0x7f, ...body];
|
|
28
|
+
}
|
|
29
|
+
function trak(id, kind, timescale, width, height, entry) {
|
|
30
|
+
const tkhd = full('tkhd', 0, 3, u32(0), u32(0), u32(id), u32(0), u32(0), zeros(8), u16(0), u16(0), u16(kind === 'audio' ? 0x0100 : 0), u16(0), MATRIX, u32(width * 65536), u32(height * 65536));
|
|
31
|
+
const mdhd = full('mdhd', 0, 0, u32(0), u32(0), u32(timescale), u32(0), u16(0x55c4), u16(0));
|
|
32
|
+
const hdlr = full('hdlr', 0, 0, u32(0), ascii(kind === 'video' ? 'vide' : 'soun'), zeros(12), [...ascii(kind === 'video' ? 'VideoHandler' : 'SoundHandler'), 0]);
|
|
33
|
+
const header = kind === 'video' ? full('vmhd', 0, 1, u16(0), zeros(6)) : full('smhd', 0, 0, u16(0), u16(0));
|
|
34
|
+
const dinf = box('dinf', full('dref', 0, 0, u32(1), full('url ', 0, 1)));
|
|
35
|
+
const stbl = box('stbl', full('stsd', 0, 0, u32(1), entry), full('stts', 0, 0, u32(0)), full('stsc', 0, 0, u32(0)), full('stsz', 0, 0, u32(0), u32(0)), full('stco', 0, 0, u32(0)));
|
|
36
|
+
return box('trak', tkhd, box('mdia', mdhd, hdlr, box('minf', header, dinf, stbl)));
|
|
37
|
+
}
|
|
38
|
+
/** The init segment for the tracks a publish carries. */
|
|
39
|
+
export function initSegment(tracks) {
|
|
40
|
+
const traks = [];
|
|
41
|
+
const trex = [];
|
|
42
|
+
const brands = ['isom', 'iso6', ...(tracks.video ? ['avc1'] : []), 'mp41'];
|
|
43
|
+
if (tracks.video) {
|
|
44
|
+
const v = tracks.video;
|
|
45
|
+
const avc1 = box('avc1', zeros(6), u16(1), u16(0), u16(0), zeros(12), u16(v.width), u16(v.height), u32(0x00480000), u32(0x00480000), u32(0), u16(1), zeros(32), u16(0x0018), u16(0xffff), box('avcC', v.record));
|
|
46
|
+
traks.push(trak(VIDEO_TRACK, 'video', VIDEO_TIMESCALE, v.width, v.height, avc1));
|
|
47
|
+
trex.push(full('trex', 0, 0, u32(VIDEO_TRACK), u32(1), u32(0), u32(0), u32(0)));
|
|
48
|
+
}
|
|
49
|
+
if (tracks.audio) {
|
|
50
|
+
const a = tracks.audio;
|
|
51
|
+
const esds = full('esds', 0, 0, descriptor(3, u16(0), [0], descriptor(4, [0x40, 0x15], zeros(3), u32(0), u32(0), descriptor(5, a.asc)), descriptor(6, [0x02])));
|
|
52
|
+
const mp4a = box('mp4a', zeros(6), u16(1), zeros(8), u16(Math.max(1, a.channels)), u16(16), u16(0), u16(0), u32((a.sampleRate > 0xffff ? 0 : a.sampleRate) * 65536), esds);
|
|
53
|
+
traks.push(trak(AUDIO_TRACK, 'audio', a.sampleRate, 0, 0, mp4a));
|
|
54
|
+
trex.push(full('trex', 0, 0, u32(AUDIO_TRACK), u32(1), u32(0), u32(0), u32(0)));
|
|
55
|
+
}
|
|
56
|
+
const mvhd = full('mvhd', 0, 0, u32(0), u32(0), u32(1000), u32(0), u32(0x00010000), u16(0x0100), u16(0), zeros(8), MATRIX, zeros(24), u32(3));
|
|
57
|
+
const ftyp = box('ftyp', ascii('isom'), u32(0x200), ...brands.map(ascii));
|
|
58
|
+
const moov = box('moov', mvhd, ...traks, box('mvex', ...trex));
|
|
59
|
+
const out = new Uint8Array(ftyp.length + moov.length);
|
|
60
|
+
out.set(ftyp, 0);
|
|
61
|
+
out.set(moov, ftyp.length);
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
/** A media segment: one `moof` over the fragments, then their samples in one `mdat`, video's first. */
|
|
65
|
+
export function mediaSegment(sequence, fragments) {
|
|
66
|
+
const present = fragments.filter((f) => f.samples.length > 0);
|
|
67
|
+
const moofOf = (offsets) => box('moof', full('mfhd', 0, 0, u32(sequence)), ...present.map((f, i) => {
|
|
68
|
+
const video = f.track === VIDEO_TRACK;
|
|
69
|
+
// data offset, duration, size, flags and (video) composition offset per sample; version 1 for signed offsets
|
|
70
|
+
const flags = video ? 0x000f01 : 0x000301;
|
|
71
|
+
const rows = f.samples.flatMap((s) => [
|
|
72
|
+
...u32(s.duration), ...u32(s.data.length),
|
|
73
|
+
...(video ? [...u32(s.key ? 0x02000000 : 0x01010000), ...u32((s.cts ?? 0) | 0)] : []),
|
|
74
|
+
]);
|
|
75
|
+
return box('traf', full('tfhd', 0, 0x020000, u32(f.track)), full('tfdt', 1, 0, u64(f.baseTime)), full('trun', video ? 1 : 0, flags, u32(f.samples.length), u32(offsets[i] | 0), rows));
|
|
76
|
+
}));
|
|
77
|
+
const sized = moofOf(present.map(() => 0)).length;
|
|
78
|
+
const offsets = [];
|
|
79
|
+
let at = sized + 8;
|
|
80
|
+
for (const f of present) {
|
|
81
|
+
offsets.push(at);
|
|
82
|
+
at += f.samples.reduce((n, s) => n + s.data.length, 0);
|
|
83
|
+
}
|
|
84
|
+
const moof = moofOf(offsets);
|
|
85
|
+
// one buffer for the whole segment, the samples written straight into its mdat: a segment's bytes are copied once
|
|
86
|
+
const out = new Uint8Array(at);
|
|
87
|
+
out.set(moof, 0);
|
|
88
|
+
out.set([...u32(at - moof.length), ...ascii('mdat')], moof.length);
|
|
89
|
+
let to = moof.length + 8;
|
|
90
|
+
for (const f of present)
|
|
91
|
+
for (const s of f.samples) {
|
|
92
|
+
out.set(s.data, to);
|
|
93
|
+
to += s.data.length;
|
|
94
|
+
}
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { LiveAudio, LiveVideo } from './types.js';
|
|
2
|
+
/** A segment as the recording keeps it. */
|
|
3
|
+
export type HlsSegment = {
|
|
4
|
+
seq: number;
|
|
5
|
+
init: number;
|
|
6
|
+
duration: number;
|
|
7
|
+
size: number;
|
|
8
|
+
};
|
|
9
|
+
/** The media playlist: the last `window` segments while the recording is published, every segment once it ended (or
|
|
10
|
+
* with `window: 'all'`, an event playlist). */
|
|
11
|
+
export declare function mediaPlaylist(recording: {
|
|
12
|
+
state: 'publishing' | 'ended';
|
|
13
|
+
window?: number;
|
|
14
|
+
}, segments: ReadonlyArray<HlsSegment>, options?: {
|
|
15
|
+
window?: number | 'all';
|
|
16
|
+
uri?: (file: string) => string;
|
|
17
|
+
}): string;
|
|
18
|
+
/** The multivariant playlist naming the recording's one rendition, its peak bandwidth measured from its segments. */
|
|
19
|
+
export declare function masterPlaylist(recording: {
|
|
20
|
+
video?: LiveVideo;
|
|
21
|
+
audio?: LiveAudio;
|
|
22
|
+
}, segments: ReadonlyArray<HlsSegment>, media: string): string;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
const DEFAULT_WINDOW = 6;
|
|
2
|
+
/** The media playlist: the last `window` segments while the recording is published, every segment once it ended (or
|
|
3
|
+
* with `window: 'all'`, an event playlist). */
|
|
4
|
+
export function mediaPlaylist(recording, segments, options = {}) {
|
|
5
|
+
const uri = options.uri ?? ((file) => file);
|
|
6
|
+
const ended = recording.state === 'ended';
|
|
7
|
+
const window = options.window ?? recording.window ?? DEFAULT_WINDOW;
|
|
8
|
+
const listed = ended || window === 'all' ? [...segments] : segments.slice(Math.max(0, segments.length - Math.max(1, window)));
|
|
9
|
+
// every EXTINF, rounded, is at most the target (§4.3.3.1)
|
|
10
|
+
const target = Math.max(1, ...segments.map((s) => Math.round(s.duration)));
|
|
11
|
+
const first = listed[0];
|
|
12
|
+
const lines = ['#EXTM3U', '#EXT-X-VERSION:7', `#EXT-X-TARGETDURATION:${target}`, `#EXT-X-MEDIA-SEQUENCE:${first?.seq ?? 0}`];
|
|
13
|
+
// each init after the recording's first is one discontinuity: those before the window are its sequence (§4.3.3.3)
|
|
14
|
+
if (first && first.init > 0)
|
|
15
|
+
lines.push(`#EXT-X-DISCONTINUITY-SEQUENCE:${first.init}`);
|
|
16
|
+
if (ended)
|
|
17
|
+
lines.push('#EXT-X-PLAYLIST-TYPE:VOD');
|
|
18
|
+
else if (window === 'all')
|
|
19
|
+
lines.push('#EXT-X-PLAYLIST-TYPE:EVENT');
|
|
20
|
+
lines.push('#EXT-X-INDEPENDENT-SEGMENTS');
|
|
21
|
+
let init;
|
|
22
|
+
for (const s of listed) {
|
|
23
|
+
if (s.init !== init) {
|
|
24
|
+
if (init !== undefined)
|
|
25
|
+
lines.push('#EXT-X-DISCONTINUITY');
|
|
26
|
+
lines.push(`#EXT-X-MAP:URI="${uri(`init-${s.init}.mp4`)}"`);
|
|
27
|
+
init = s.init;
|
|
28
|
+
}
|
|
29
|
+
lines.push(`#EXTINF:${s.duration.toFixed(3)},`, uri(`seg-${s.seq}.m4s`));
|
|
30
|
+
}
|
|
31
|
+
if (ended)
|
|
32
|
+
lines.push('#EXT-X-ENDLIST');
|
|
33
|
+
return `${lines.join('\n')}\n`;
|
|
34
|
+
}
|
|
35
|
+
/** The multivariant playlist naming the recording's one rendition, its peak bandwidth measured from its segments. */
|
|
36
|
+
export function masterPlaylist(recording, segments, media) {
|
|
37
|
+
const peak = Math.max(1, ...segments.filter((s) => s.duration > 0).map((s) => Math.ceil((s.size * 8) / s.duration)));
|
|
38
|
+
const codecs = [recording.video?.codec, recording.audio?.codec].filter(Boolean).join(',');
|
|
39
|
+
const attributes = [
|
|
40
|
+
`BANDWIDTH=${peak}`,
|
|
41
|
+
...(codecs ? [`CODECS="${codecs}"`] : []),
|
|
42
|
+
...(recording.video && recording.video.width && recording.video.height ? [`RESOLUTION=${recording.video.width}x${recording.video.height}`] : []),
|
|
43
|
+
...(recording.video?.frameRate ? [`FRAME-RATE=${recording.video.frameRate.toFixed(3)}`] : []),
|
|
44
|
+
];
|
|
45
|
+
return `#EXTM3U\n#EXT-X-VERSION:7\n#EXT-X-INDEPENDENT-SEGMENTS\n#EXT-X-STREAM-INF:${attributes.join(',')}\n${media}\n`;
|
|
46
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type { LiveAccess, LiveAdmission, LiveAudio, LiveDecl, LiveEngine, LivePublish, LiveRecording, LiveRefusal, LiveServeOptions, LiveVideo } from './types.js';
|
|
2
|
+
export { liveIngest, type LiveIngestOptions, type LiveWorld } from './ingest.js';
|
|
3
|
+
export { liveAccess, LIVE, LIVE_SEGMENT, type LiveReads } from './access.js';
|
|
4
|
+
export { masterPlaylist, mediaPlaylist, type HlsSegment } from './hls.js';
|
|
5
|
+
export { rtmpSession, type RtmpPublisher } from './rtmp.js';
|
|
6
|
+
export { decodeAmf0, encodeAmf0, type Amf0Value } from './amf0.js';
|
|
7
|
+
export { readAudioTag, readVideoTag, type AacConfig, type AudioTag, type AvcConfig, type VideoTag } from './flv.js';
|
|
8
|
+
export { initSegment, mediaSegment, type Fragment, type Sample } from './fmp4.js';
|
|
9
|
+
export { Segmenter, type Cut } from './segmenter.js';
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { liveIngest } from "./ingest.js";
|
|
2
|
+
export { liveAccess, LIVE, LIVE_SEGMENT } from "./access.js";
|
|
3
|
+
export { masterPlaylist, mediaPlaylist } from "./hls.js";
|
|
4
|
+
export { rtmpSession } from "./rtmp.js";
|
|
5
|
+
export { decodeAmf0, encodeAmf0 } from "./amf0.js";
|
|
6
|
+
export { readAudioTag, readVideoTag } from "./flv.js";
|
|
7
|
+
export { initSegment, mediaSegment } from "./fmp4.js";
|
|
8
|
+
export { Segmenter } from "./segmenter.js";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { TwinStream } from '../twin-fetch.js';
|
|
2
|
+
import { type LiveReads } from './access.js';
|
|
3
|
+
import type { LiveDecl, LiveEngine, LivePublish } from './types.js';
|
|
4
|
+
/** What the ingest reads and writes of the World, bound by the pack's fetch to its service and root. */
|
|
5
|
+
export type LiveWorld = LiveReads & {
|
|
6
|
+
/** a bookkeeping subject written (or, with `deleted: true`, deleted) at the World's time */
|
|
7
|
+
record(type: string, id: string, fields: Record<string, unknown>, operation: string): Promise<void>;
|
|
8
|
+
putBlob(key: string, bytes: Uint8Array): Promise<void>;
|
|
9
|
+
removeBlob(key: string): Promise<void>;
|
|
10
|
+
now(): string;
|
|
11
|
+
};
|
|
12
|
+
export type LiveIngestOptions<C> = {
|
|
13
|
+
/** the World this ingest's recordings are kept in (its service and root): one publisher per stream within it */
|
|
14
|
+
world: string;
|
|
15
|
+
decls: ReadonlyArray<LiveDecl>;
|
|
16
|
+
engines: Record<string, LiveEngine<C>>;
|
|
17
|
+
readOnly: boolean;
|
|
18
|
+
/** the handler contract's context a hook runs over, opened at the moment it runs */
|
|
19
|
+
open(decl: LiveDecl, ask: LivePublish): Promise<C>;
|
|
20
|
+
/** runs a hook and the writes it causes as the vendor's own move, one correlation */
|
|
21
|
+
run<T>(fn: () => Promise<T>): Promise<T>;
|
|
22
|
+
store: LiveWorld;
|
|
23
|
+
log?(line: string): void;
|
|
24
|
+
};
|
|
25
|
+
export declare function liveIngest<C>(options: LiveIngestOptions<C>): TwinStream;
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
import { initBlob, LIVE, LIVE_SEGMENT, recordingOf, segmentBlob, segmentsOf } from "./access.js";
|
|
2
|
+
import { readAudioTag, readVideoTag } from "./flv.js";
|
|
3
|
+
import { RtmpRefusal, rtmpSession } from "./rtmp.js";
|
|
4
|
+
import { LiveOverrun, Segmenter } from "./segmenter.js";
|
|
5
|
+
const STREAM_ID = /^[A-Za-z0-9._-]{1,128}$/;
|
|
6
|
+
/** Connections one ingest's listener takes at once: publishers and the connections still on their way to publishing. */
|
|
7
|
+
const MAX_CONNECTIONS = 32;
|
|
8
|
+
/** The streams being published now, per World: a second publisher to one is refused, as a vendor's ingest refuses it. */
|
|
9
|
+
const publishing = new Map();
|
|
10
|
+
export function liveIngest(options) {
|
|
11
|
+
for (const d of options.decls)
|
|
12
|
+
if (!options.engines[d.id])
|
|
13
|
+
throw new Error(`the manifest declares the ingest ${d.id}, and semantics/live.ts exports no ${d.id}`);
|
|
14
|
+
const log = options.log ?? ((line) => console.error(`live: ${line}`));
|
|
15
|
+
const store = options.store;
|
|
16
|
+
const active = publishing.get(options.world) ?? publishing.set(options.world, new Set()).get(options.world);
|
|
17
|
+
// the listener's cap (serveStream reads it): a World's publishers are its encoders, a few at once
|
|
18
|
+
return Object.assign((sink, peer) => {
|
|
19
|
+
let decl;
|
|
20
|
+
let ask;
|
|
21
|
+
let stream;
|
|
22
|
+
let admitted;
|
|
23
|
+
let segmenter;
|
|
24
|
+
let initIndex = -1;
|
|
25
|
+
let priorInit;
|
|
26
|
+
let started = false;
|
|
27
|
+
let metadata;
|
|
28
|
+
let refusedCodec;
|
|
29
|
+
const recording = () => {
|
|
30
|
+
const row = store.rows(LIVE).find((r) => r.id === stream);
|
|
31
|
+
return recordingOf(row, segmentsOf(store, stream));
|
|
32
|
+
};
|
|
33
|
+
const hook = async (name) => {
|
|
34
|
+
const engine = options.engines[decl.id];
|
|
35
|
+
const fn = engine[name];
|
|
36
|
+
if (!fn)
|
|
37
|
+
return;
|
|
38
|
+
try {
|
|
39
|
+
await options.run(async () => fn.call(engine, await options.open(decl, ask), recording()));
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
log(`${decl.id}'s ${name} for ${stream} was not written: ${String(error?.message ?? error)}`);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
/** A closed segment kept: its init first when it opens one, its bytes, its subject; then what `retain` lets go. */
|
|
46
|
+
const keep = async (cut) => {
|
|
47
|
+
if (!stream || !admitted)
|
|
48
|
+
return;
|
|
49
|
+
if (cut.init) {
|
|
50
|
+
// a publisher that reconnects with the encoder it had plays on under the recording's last init, its timeline
|
|
51
|
+
// carried on, so no player meets a discontinuity; a changed configuration opens the next init
|
|
52
|
+
const prior = initIndex < 0 && priorInit !== undefined ? await store.blob(initBlob(stream, priorInit)) : null;
|
|
53
|
+
if (prior && sameBytes(prior, cut.init.bytes))
|
|
54
|
+
initIndex = priorInit;
|
|
55
|
+
else {
|
|
56
|
+
initIndex = initIndex < 0 ? (priorInit ?? -1) + 1 : initIndex + 1;
|
|
57
|
+
await store.putBlob(initBlob(stream, initIndex), cut.init.bytes);
|
|
58
|
+
}
|
|
59
|
+
await options.run(() => store.record(LIVE, stream, {
|
|
60
|
+
...(cut.init.video ? { video: cut.init.video } : {}),
|
|
61
|
+
...(cut.init.audio ? { audio: cut.init.audio } : {}),
|
|
62
|
+
...(metadata ? { metadata } : {}),
|
|
63
|
+
}, 'live.configure'));
|
|
64
|
+
}
|
|
65
|
+
const blob = segmentBlob(stream, cut.sequence);
|
|
66
|
+
await store.putBlob(blob, cut.bytes);
|
|
67
|
+
await options.run(() => store.record(LIVE_SEGMENT, `${stream}:${cut.sequence}`, {
|
|
68
|
+
stream, seq: cut.sequence, init: initIndex, start: round(cut.start), duration: round(cut.duration), size: cut.bytes.length, blob,
|
|
69
|
+
}, 'live.segment'));
|
|
70
|
+
if (admitted.retain !== undefined && admitted.retain > 0) {
|
|
71
|
+
for (const old of segmentsOf(store, stream).slice(0, -admitted.retain)) {
|
|
72
|
+
await store.removeBlob(old.blob);
|
|
73
|
+
await options.run(() => store.record(LIVE_SEGMENT, `${stream}:${old.seq}`, { deleted: true }, 'live.segment.expire'));
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
if (!started) {
|
|
77
|
+
started = true;
|
|
78
|
+
await hook('started');
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
const keepAll = async (cuts) => { for (const cut of cuts)
|
|
82
|
+
await keep(cut); };
|
|
83
|
+
return rtmpSession(sink, {
|
|
84
|
+
connect(app) {
|
|
85
|
+
decl = options.decls.find((d) => d.app === app);
|
|
86
|
+
return decl ? undefined : `There is no application ${app || '(none)'} here.`;
|
|
87
|
+
},
|
|
88
|
+
async publish(asked) {
|
|
89
|
+
if (!decl)
|
|
90
|
+
return { code: 'NetStream.Publish.BadName', description: 'No application was connected.' };
|
|
91
|
+
if (options.readOnly)
|
|
92
|
+
return { code: 'NetStream.Publish.Denied', description: 'This World is read-only: it records nothing.' };
|
|
93
|
+
const [key = '', query = ''] = splitOnce(asked.name, '?');
|
|
94
|
+
ask = {
|
|
95
|
+
app: asked.app, key, query: Object.fromEntries(new URLSearchParams(query)), peer,
|
|
96
|
+
...(asked.tcUrl !== undefined ? { tcUrl: asked.tcUrl } : {}), ...(asked.flashVer !== undefined ? { flashVer: asked.flashVer } : {}), ...(asked.type !== undefined ? { type: asked.type } : {}),
|
|
97
|
+
};
|
|
98
|
+
const engine = options.engines[decl.id];
|
|
99
|
+
const ruling = await options.run(async () => engine.publish(await options.open(decl, ask), ask));
|
|
100
|
+
if ('refused' in ruling)
|
|
101
|
+
return { code: ruling.refused, description: ruling.description };
|
|
102
|
+
if (!STREAM_ID.test(ruling.stream))
|
|
103
|
+
throw new Error(`${decl.id}'s publish named the stream ${JSON.stringify(ruling.stream)}; a stream is 1-128 letters, digits, '.', '_' or '-'`);
|
|
104
|
+
if (active.has(ruling.stream))
|
|
105
|
+
return { code: 'NetStream.Publish.BadName', description: `The stream ${ruling.stream} is already being published.` };
|
|
106
|
+
const named = ruling.stream;
|
|
107
|
+
active.add(named);
|
|
108
|
+
try {
|
|
109
|
+
// a recording continued: its segments' numbers, its inits' and its timeline carry on from where it stood
|
|
110
|
+
const prior = store.rows(LIVE).find((r) => r.id === named && r.deleted !== true);
|
|
111
|
+
const kept = segmentsOf(store, named);
|
|
112
|
+
const last = kept[kept.length - 1];
|
|
113
|
+
const offsetMs = last ? Math.ceil((last.start + last.duration) * 1000) : 0;
|
|
114
|
+
const now = store.now();
|
|
115
|
+
await options.run(() => store.record(LIVE, named, {
|
|
116
|
+
app: ask.app, key: ask.key, state: 'publishing', startedAt: typeof prior?.startedAt === 'string' ? prior.startedAt : now, endedAt: null,
|
|
117
|
+
publishes: Number(prior?.publishes ?? 0) + 1, window: ruling.window ?? 6, segmentSeconds: ruling.segmentSeconds ?? 2, ...(ruling.retain !== undefined ? { retain: ruling.retain } : {}),
|
|
118
|
+
}, 'live.publish'));
|
|
119
|
+
priorInit = last?.init;
|
|
120
|
+
segmenter = new Segmenter({ targetMs: Math.max(0.5, ruling.segmentSeconds ?? 2) * 1000, offsetMs, firstSequence: last ? last.seq + 1 : 0 });
|
|
121
|
+
stream = named;
|
|
122
|
+
admitted = ruling;
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
// the publish was not recorded: the stream is free again, and the publisher refused (rtmp.ts)
|
|
126
|
+
active.delete(named);
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
return undefined;
|
|
130
|
+
},
|
|
131
|
+
metadata(meta) {
|
|
132
|
+
const flat = flatMetadata(meta);
|
|
133
|
+
metadata = { ...(metadata ?? {}), ...flat };
|
|
134
|
+
segmenter?.metadata(flat);
|
|
135
|
+
},
|
|
136
|
+
async video(timestamp, payload) {
|
|
137
|
+
if (!segmenter)
|
|
138
|
+
return;
|
|
139
|
+
const tag = readVideoTag(payload);
|
|
140
|
+
if (tag.kind === 'unsupported') {
|
|
141
|
+
if (refusedCodec !== tag.codec)
|
|
142
|
+
log(`${stream}: ${tag.codec} video is not recorded (H.264 is)`);
|
|
143
|
+
refusedCodec = tag.codec;
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
const s = segmenter;
|
|
147
|
+
if (tag.kind === 'config')
|
|
148
|
+
await keepAll(s.videoConfig(tag.config));
|
|
149
|
+
else if (tag.kind === 'frame')
|
|
150
|
+
await keepAll(overrun(() => s.videoFrame(timestamp, tag.key, tag.cts, tag.data)));
|
|
151
|
+
},
|
|
152
|
+
async audio(timestamp, payload) {
|
|
153
|
+
if (!segmenter)
|
|
154
|
+
return;
|
|
155
|
+
const tag = readAudioTag(payload);
|
|
156
|
+
if (tag.kind === 'unsupported') {
|
|
157
|
+
if (refusedCodec !== tag.codec)
|
|
158
|
+
log(`${stream}: ${tag.codec} audio is not recorded (AAC is)`);
|
|
159
|
+
refusedCodec = tag.codec;
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
const s = segmenter;
|
|
163
|
+
if (tag.kind === 'config')
|
|
164
|
+
await keepAll(s.audioConfig(tag.config));
|
|
165
|
+
else
|
|
166
|
+
await keepAll(overrun(() => s.audioFrame(timestamp, tag.data)));
|
|
167
|
+
},
|
|
168
|
+
async unpublish() {
|
|
169
|
+
if (!stream || !segmenter)
|
|
170
|
+
return;
|
|
171
|
+
const s = segmenter;
|
|
172
|
+
segmenter = undefined;
|
|
173
|
+
// the recording always ends: what was cut is kept where it can be, and the end is written whatever the last
|
|
174
|
+
// segment did, so no playlist is left waiting on a publisher that is gone
|
|
175
|
+
try {
|
|
176
|
+
await keepAll(s.flush());
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
log(`${stream}: the last segment was not kept: ${String(error?.message ?? error)}`);
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
await options.run(() => store.record(LIVE, stream, { state: 'ended', endedAt: store.now() }, 'live.end'));
|
|
183
|
+
}
|
|
184
|
+
catch (error) {
|
|
185
|
+
log(`${stream}: its end was not written: ${String(error?.message ?? error)}`);
|
|
186
|
+
}
|
|
187
|
+
finally {
|
|
188
|
+
active.delete(stream);
|
|
189
|
+
}
|
|
190
|
+
await hook('ended');
|
|
191
|
+
},
|
|
192
|
+
}, log);
|
|
193
|
+
}, { maxConnections: MAX_CONNECTIONS });
|
|
194
|
+
}
|
|
195
|
+
const round = (n) => Math.round(n * 1000) / 1000;
|
|
196
|
+
/** What a segmenter refuses, as RTMP's refusal of the publish (rtmp.ts ends it and closes the connection). */
|
|
197
|
+
function overrun(fn) {
|
|
198
|
+
try {
|
|
199
|
+
return fn();
|
|
200
|
+
}
|
|
201
|
+
catch (error) {
|
|
202
|
+
throw error instanceof LiveOverrun ? new RtmpRefusal('NetStream.Failed', error.message) : error;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/** The publisher's metadata as the World keeps it: its flat values (`onMetaData` is one level: an encoder's name, sizes,
|
|
206
|
+
* rates), at most 64 of them, a string at most 256 characters; nested values are not kept, so nothing it sent (a
|
|
207
|
+
* reference back to itself among them) can make the recording's row unwritable. */
|
|
208
|
+
function flatMetadata(meta) {
|
|
209
|
+
const out = {};
|
|
210
|
+
for (const [key, value] of Object.entries(meta).slice(0, 64)) {
|
|
211
|
+
if (typeof value === 'number' ? Number.isFinite(value) : typeof value === 'boolean')
|
|
212
|
+
out[key.slice(0, 64)] = value;
|
|
213
|
+
else if (typeof value === 'string')
|
|
214
|
+
out[key.slice(0, 64)] = value.slice(0, 256);
|
|
215
|
+
}
|
|
216
|
+
return out;
|
|
217
|
+
}
|
|
218
|
+
function sameBytes(a, b) {
|
|
219
|
+
if (a.length !== b.length)
|
|
220
|
+
return false;
|
|
221
|
+
for (let i = 0; i < a.length; i++)
|
|
222
|
+
if (a[i] !== b[i])
|
|
223
|
+
return false;
|
|
224
|
+
return true;
|
|
225
|
+
}
|
|
226
|
+
function splitOnce(s, sep) {
|
|
227
|
+
const at = s.indexOf(sep);
|
|
228
|
+
return at < 0 ? [s, ''] : [s.slice(0, at), s.slice(at + 1)];
|
|
229
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { TwinStreamConnection, TwinStreamSink } from '../twin-fetch.js';
|
|
2
|
+
import { type Amf0Value } from './amf0.js';
|
|
3
|
+
/** What the session asks of its caller. */
|
|
4
|
+
export type RtmpPublisher = {
|
|
5
|
+
/** `connect`: the application the publisher names, and its command object; a string refuses it, with that reason */
|
|
6
|
+
connect(app: string, command: Record<string, Amf0Value>): string | undefined;
|
|
7
|
+
/** `publish`: whether the stream may be published, or RTMP's refusal (its code and description) */
|
|
8
|
+
publish(ask: {
|
|
9
|
+
app: string;
|
|
10
|
+
name: string;
|
|
11
|
+
type?: string;
|
|
12
|
+
tcUrl?: string;
|
|
13
|
+
flashVer?: string;
|
|
14
|
+
}): Promise<{
|
|
15
|
+
code: string;
|
|
16
|
+
description: string;
|
|
17
|
+
} | undefined>;
|
|
18
|
+
/** `@setDataFrame` / `onMetaData`, as published */
|
|
19
|
+
metadata(meta: Record<string, Amf0Value>): void | Promise<void>;
|
|
20
|
+
/** an FLV audio or video tag's body, at its RTMP timestamp (milliseconds); an `RtmpRefusal` thrown ends the publish */
|
|
21
|
+
audio(timestamp: number, payload: Uint8Array): void | Promise<void>;
|
|
22
|
+
video(timestamp: number, payload: Uint8Array): void | Promise<void>;
|
|
23
|
+
/** the publish ended (`FCUnpublish`, `closeStream`, `deleteStream`, the connection ended while publishing, or a
|
|
24
|
+
* refusal of what was published) */
|
|
25
|
+
unpublish(): void | Promise<void>;
|
|
26
|
+
};
|
|
27
|
+
/** What a publisher's media can draw mid-publish: its `onStatus` error, then the publish ended and the connection closed. */
|
|
28
|
+
export declare class RtmpRefusal extends Error {
|
|
29
|
+
readonly code: string;
|
|
30
|
+
constructor(code: string, description: string);
|
|
31
|
+
}
|
|
32
|
+
/** One publisher's connection: its bytes in, the server's answers out through the sink. */
|
|
33
|
+
export declare function rtmpSession(sink: TwinStreamSink, publisher: RtmpPublisher, log?: (line: string) => void): TwinStreamConnection & {
|
|
34
|
+
backlog(): number;
|
|
35
|
+
};
|