nixamp 0.7.34 → 0.7.36

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/audio.js CHANGED
@@ -190,7 +190,9 @@ export class Stream {
190
190
  this.output.stdin?.on("error", () => { });
191
191
  }
192
192
  let stderr = "";
193
- this.decoder.stderr?.on("data", (c) => { stderr += c.toString(); });
193
+ // The tail only: what went wrong is on the last line, and a film with a
194
+ // damaged audio track can say so once a frame for two hours.
195
+ this.decoder.stderr?.on("data", (c) => { stderr = (stderr + c.toString()).slice(-2000); });
194
196
  this.decoder.stdout?.on("data", (chunk) => {
195
197
  if (this.stopped || generation !== this.generation)
196
198
  return;
@@ -11,11 +11,35 @@ export interface ChannelInfo {
11
11
  /** The container it is sending, e.g. webm from a browser, flv over RTMP. */
12
12
  format: string;
13
13
  /** How it arrived. */
14
- via: "http" | "rtmp";
14
+ via: "http" | "rtmp" | "pull";
15
15
  startedAt: number;
16
16
  bytes: number;
17
17
  listeners: number;
18
+ /**
19
+ * Whether there is a picture, which decides what a listener is sent and
20
+ * what the response calls it. A channel that says audio/mpeg while sending
21
+ * MP4 plays as nothing at all.
22
+ */
23
+ kind?: "audio" | "video";
24
+ /** For a channel we pull ourselves: where from. Never shown to a listener. */
25
+ source?: string;
26
+ /** The last thing ffmpeg complained about, for whoever administers this. */
27
+ error?: string;
28
+ /** How many times the source has been dialled again since it started. */
29
+ redials?: number;
18
30
  }
31
+ /** How long to wait before dialling a dropped source again. */
32
+ export declare const REDIAL = 2000;
33
+ /** How many times in a row a source may fail without ever sending anything. */
34
+ export declare const GIVE_UP = 5;
35
+ /**
36
+ * How long a pulled source may say nothing before it is treated as gone.
37
+ *
38
+ * ffmpeg's own reconnect covers a connection that errors. It does not cover
39
+ * one that simply stops sending, and neither does anything else: a television
40
+ * channel that is quiet for half a minute is not being quiet, it is dead.
41
+ */
42
+ export declare const STALL = 30000;
19
43
  /** A name that can sit in a URL and be read back in a list. */
20
44
  export declare function cleanId(value: unknown, fallback?: string): string;
21
45
  export interface ChannelOptions {
@@ -36,8 +60,74 @@ export declare class Channel {
36
60
  readonly listeners: Set<Listener>;
37
61
  private child;
38
62
  private closing;
63
+ /** Set for a channel that carries pictures, which cannot be joined blind. */
64
+ private fragments;
65
+ /** For a pulled channel: what to run, and how many times it has failed. */
66
+ private redial;
67
+ private failures;
68
+ private timer;
69
+ /** Fires when a pulled source has said nothing for STALL. */
70
+ private watchdog;
71
+ private stall;
72
+ private stderr;
39
73
  constructor(info: ChannelInfo, options: ChannelOptions, onGone: (id: string) => void);
40
74
  start(format: string): void;
75
+ /**
76
+ * Fetch a source ourselves, rather than waiting to be sent one.
77
+ *
78
+ * This is what makes a re-stream a channel instead of a track. A track is
79
+ * played by the one player a server has, so a second one is a second thing
80
+ * that server cannot do at the same time; a channel is its own process with
81
+ * its own audience, and a server can carry as many as it can decode. Two
82
+ * channels means two tabs, or two panels of a multiview.
83
+ *
84
+ * It keeps running with nobody listening. Live television does not pause
85
+ * because you looked away, and a room where the picture depends on who is
86
+ * in it is not a room anybody can be invited to.
87
+ */
88
+ pull(source: string, encode: string[], paced?: boolean, stall?: number): void;
89
+ /**
90
+ * Start the source over, now.
91
+ *
92
+ * For a pulled channel only: a publisher's stream cannot be dialled again
93
+ * from this end. The current ffmpeg is killed and a new one started at
94
+ * once, with the count of failures cleared -- somebody asking for this has
95
+ * decided the thing is worth another go, and should not inherit the four
96
+ * strikes a dead CDN ran up an hour ago.
97
+ */
98
+ restart(): boolean;
99
+ /**
100
+ * The stream that was is over; the next ffmpeg is a new one.
101
+ *
102
+ * New opening boxes, timestamps from zero again. Whoever was listening
103
+ * cannot follow that mid-picture, and a newcomer must not be handed the old
104
+ * opening boxes in front of the new fragments -- so the header is dropped
105
+ * and the audience is ended, to come back to the stream as it now is. The
106
+ * player rejoins on its own. Done the moment the source is known to be
107
+ * gone, not when the redial happens: somebody joining in between gets the
108
+ * new beginning as it is written, rather than a stale one first.
109
+ */
110
+ private startOver;
111
+ /** Expect output within STALL, or treat the source as gone and dial again. */
112
+ private rearm;
113
+ /** End everybody listening; the stream they were on is over. */
114
+ private hangUp;
115
+ /**
116
+ * A source that stopped. Try it again, unless it never worked at all.
117
+ *
118
+ * The difference matters: a channel that ran for six hours and dropped is
119
+ * worth dialling again, and a URL that has never once produced a byte is a
120
+ * mistake somebody made, and retrying it for ever helps nobody.
121
+ */
122
+ private dropped;
123
+ /**
124
+ * Out to the audience, whole boxes at a time when there are boxes.
125
+ *
126
+ * Video listeners are only ever sent complete boxes, so that a new one can
127
+ * be given the opening boxes and then join at the next fragment and have it
128
+ * make sense.
129
+ */
130
+ private emit;
41
131
  /** Feed the source. */
42
132
  write(chunk: Buffer): boolean;
43
133
  pump(body: Readable): Promise<void>;
@@ -75,6 +165,24 @@ export declare class Channels {
75
165
  * *different* channel is exactly what this class exists for.
76
166
  */
77
167
  publish(id: string, name: string, format: string, via: ChannelInfo["via"]): Channel | null;
168
+ /**
169
+ * Carry a source of our own: a re-stream, or a film on this disk shown live.
170
+ *
171
+ * Null when that channel is taken, the same as publishing. Everything else
172
+ * about it is the same too, which is the point -- a re-stream stops being a
173
+ * special case and becomes one more thing that is on.
174
+ */
175
+ pull(id: string, name: string, source: string, encode: string[], kind: "audio" | "video", paced?: boolean, stall?: number): Channel | null;
176
+ /**
177
+ * Dial a pulled channel's source again, now. False for a channel that is
178
+ * not there or is not ours to dial: a publisher's stream restarts at the
179
+ * publisher's end.
180
+ */
181
+ restart(id: string): boolean;
182
+ /** Whether a channel is one we fetch ourselves, and so can start over. */
183
+ pulled(id: string): boolean;
184
+ /** What a listener should be told this channel is. */
185
+ contentType(id: string): string;
78
186
  /** Attach a listener, or null when nothing is playing on that channel. */
79
187
  listen(id: string, listener: Listener): (() => void) | null;
80
188
  /** Feed a channel that already exists, for a publisher sending chunks. */
package/dist/channels.js CHANGED
@@ -16,6 +16,45 @@
16
16
  */
17
17
  import { spawn } from "node:child_process";
18
18
  import { randomBytes } from "node:crypto";
19
+ import { Fragments } from "./fragments.js";
20
+ /** How long to wait before dialling a dropped source again. */
21
+ export const REDIAL = 2000;
22
+ /** How many times in a row a source may fail without ever sending anything. */
23
+ export const GIVE_UP = 5;
24
+ /**
25
+ * How long a pulled source may say nothing before it is treated as gone.
26
+ *
27
+ * ffmpeg's own reconnect covers a connection that errors. It does not cover
28
+ * one that simply stops sending, and neither does anything else: a television
29
+ * channel that is quiet for half a minute is not being quiet, it is dead.
30
+ */
31
+ export const STALL = 30_000;
32
+ /** How much of what ffmpeg said to keep, for the last line when it dies. */
33
+ const TAIL = 2000;
34
+ /**
35
+ * Read everything a child says on stderr, keeping only the end of it.
36
+ *
37
+ * This is not optional. A pipe nobody reads fills, at 64 KiB on Linux, and
38
+ * the child then blocks on its next write to it -- every thread it has waits
39
+ * on the one that is stuck, and it produces nothing more, for ever, without
40
+ * exiting. A channel carrying an IPTV transport stream logs a line for every
41
+ * corrupt packet, and over twelve hours that is more than 64 KiB. Measured on
42
+ * the real server: CNN "on the air" with a full stderr socket, its decoder
43
+ * thread asleep in the kernel on that write, its byte count frozen, and a
44
+ * listener handed the opening boxes and then nothing at all.
45
+ */
46
+ function drain(stream, keep) {
47
+ let tail = "";
48
+ stream?.on("data", (chunk) => {
49
+ tail = (tail + chunk.toString("utf8")).slice(-TAIL);
50
+ keep(tail);
51
+ });
52
+ stream?.on("error", () => undefined);
53
+ }
54
+ /** The last thing ffmpeg said, which is where it says what went wrong. */
55
+ function lastLine(tail) {
56
+ return tail.trim().split("\n").pop() ?? "";
57
+ }
19
58
  /** A name that can sit in a URL and be read back in a list. */
20
59
  export function cleanId(value, fallback = "main") {
21
60
  if (typeof value !== "string")
@@ -36,6 +75,16 @@ export class Channel {
36
75
  listeners = new Set();
37
76
  child = null;
38
77
  closing = false;
78
+ /** Set for a channel that carries pictures, which cannot be joined blind. */
79
+ fragments = null;
80
+ /** For a pulled channel: what to run, and how many times it has failed. */
81
+ redial = null;
82
+ failures = 0;
83
+ timer = null;
84
+ /** Fires when a pulled source has said nothing for STALL. */
85
+ watchdog = null;
86
+ stall = STALL;
87
+ stderr = "";
39
88
  constructor(info, options, onGone) {
40
89
  this.info = info;
41
90
  this.options = options;
@@ -61,6 +110,7 @@ export class Channel {
61
110
  this.info.bytes += chunk.byteLength;
62
111
  this.send(chunk);
63
112
  });
113
+ drain(child.stderr, (tail) => { this.stderr = tail; });
64
114
  // A publisher that hangs up mid-write breaks the pipe, and an unhandled
65
115
  // EPIPE takes the whole server with it.
66
116
  child.stdin?.on("error", () => this.close());
@@ -70,6 +120,194 @@ export class Channel {
70
120
  this.child = child;
71
121
  this.options.onStart?.(this.info);
72
122
  }
123
+ /**
124
+ * Fetch a source ourselves, rather than waiting to be sent one.
125
+ *
126
+ * This is what makes a re-stream a channel instead of a track. A track is
127
+ * played by the one player a server has, so a second one is a second thing
128
+ * that server cannot do at the same time; a channel is its own process with
129
+ * its own audience, and a server can carry as many as it can decode. Two
130
+ * channels means two tabs, or two panels of a multiview.
131
+ *
132
+ * It keeps running with nobody listening. Live television does not pause
133
+ * because you looked away, and a room where the picture depends on who is
134
+ * in it is not a room anybody can be invited to.
135
+ */
136
+ pull(source, encode, paced = true, stall = STALL) {
137
+ this.stall = stall;
138
+ if (this.info.kind === "video")
139
+ this.fragments = new Fragments();
140
+ const [command, ...prefix] = this.options.ffmpeg;
141
+ const remote = /^https?:\/\//i.test(source);
142
+ const dial = () => {
143
+ if (this.closing)
144
+ return;
145
+ this.stderr = "";
146
+ const child = spawn(command, [
147
+ ...prefix,
148
+ "-hide_banner",
149
+ "-loglevel", "error",
150
+ // A dropped source is normal over hours, and a channel that dies
151
+ // the first time a CDN hiccups is not a channel anybody can rely
152
+ // on. ffmpeg redials on its own before we have to.
153
+ ...(remote ? ["-reconnect", "1", "-reconnect_streamed", "1", "-reconnect_delay_max", "5"] : []),
154
+ // A connection that stops answering is an error after this long,
155
+ // and an error is a thing the reconnect above knows what to do
156
+ // with. Without it a silent socket is waited on for ever. In
157
+ // microseconds, as ffmpeg wants it.
158
+ ...(remote ? ["-rw_timeout", String(stall * 1000)] : []),
159
+ // Real time, always. A file read as fast as the disk allows is an
160
+ // hour of film in ninety seconds and a room that cannot be in it
161
+ // together; a live source is already paced and loses nothing.
162
+ ...(paced ? ["-re"] : []),
163
+ "-i", source,
164
+ ...encode,
165
+ "pipe:1",
166
+ ], { stdio: ["ignore", "pipe", "pipe"] });
167
+ let sent = false;
168
+ this.child = child;
169
+ this.rearm(child);
170
+ child.stdout?.on("data", (chunk) => {
171
+ // An ffmpeg that was replaced can still have a chunk in the pipe.
172
+ if (this.child !== child)
173
+ return;
174
+ sent = true;
175
+ this.info.bytes += chunk.byteLength;
176
+ this.rearm(child);
177
+ this.emit(chunk);
178
+ });
179
+ child.stdout?.on("error", () => undefined);
180
+ drain(child.stderr, (tail) => { this.stderr = tail; });
181
+ // Only the ffmpeg we are currently running gets to say the source
182
+ // dropped. One that was killed to make way for a restart is not news.
183
+ child.on("error", () => { if (this.child === child)
184
+ this.dropped(sent); });
185
+ child.on("close", () => { if (this.child === child)
186
+ this.dropped(sent); });
187
+ };
188
+ this.redial = dial;
189
+ dial();
190
+ this.options.onStart?.(this.info);
191
+ }
192
+ /**
193
+ * Start the source over, now.
194
+ *
195
+ * For a pulled channel only: a publisher's stream cannot be dialled again
196
+ * from this end. The current ffmpeg is killed and a new one started at
197
+ * once, with the count of failures cleared -- somebody asking for this has
198
+ * decided the thing is worth another go, and should not inherit the four
199
+ * strikes a dead CDN ran up an hour ago.
200
+ */
201
+ restart() {
202
+ const dial = this.redial;
203
+ if (!dial || this.closing)
204
+ return false;
205
+ if (this.timer)
206
+ clearTimeout(this.timer);
207
+ this.timer = null;
208
+ if (this.watchdog)
209
+ clearTimeout(this.watchdog);
210
+ this.watchdog = null;
211
+ this.failures = 0;
212
+ this.info.redials = (this.info.redials ?? 0) + 1;
213
+ this.info.error = undefined;
214
+ const old = this.child;
215
+ this.child = null;
216
+ old?.kill("SIGKILL");
217
+ this.startOver();
218
+ dial();
219
+ return true;
220
+ }
221
+ /**
222
+ * The stream that was is over; the next ffmpeg is a new one.
223
+ *
224
+ * New opening boxes, timestamps from zero again. Whoever was listening
225
+ * cannot follow that mid-picture, and a newcomer must not be handed the old
226
+ * opening boxes in front of the new fragments -- so the header is dropped
227
+ * and the audience is ended, to come back to the stream as it now is. The
228
+ * player rejoins on its own. Done the moment the source is known to be
229
+ * gone, not when the redial happens: somebody joining in between gets the
230
+ * new beginning as it is written, rather than a stale one first.
231
+ */
232
+ startOver() {
233
+ if (this.info.kind === "video")
234
+ this.fragments = new Fragments();
235
+ this.hangUp();
236
+ }
237
+ /** Expect output within STALL, or treat the source as gone and dial again. */
238
+ rearm(child) {
239
+ if (this.watchdog)
240
+ clearTimeout(this.watchdog);
241
+ this.watchdog = setTimeout(() => {
242
+ this.watchdog = null;
243
+ if (this.child !== child || this.closing)
244
+ return;
245
+ this.info.error = `no data from the source for ${Math.round(this.stall / 1000)}s`;
246
+ // Its close handler is what dials again.
247
+ child.kill("SIGKILL");
248
+ }, this.stall);
249
+ this.watchdog.unref?.();
250
+ }
251
+ /** End everybody listening; the stream they were on is over. */
252
+ hangUp() {
253
+ for (const listener of this.listeners) {
254
+ try {
255
+ listener.end();
256
+ }
257
+ catch {
258
+ // Gone already.
259
+ }
260
+ }
261
+ this.listeners.clear();
262
+ this.info.listeners = 0;
263
+ }
264
+ /**
265
+ * A source that stopped. Try it again, unless it never worked at all.
266
+ *
267
+ * The difference matters: a channel that ran for six hours and dropped is
268
+ * worth dialling again, and a URL that has never once produced a byte is a
269
+ * mistake somebody made, and retrying it for ever helps nobody.
270
+ */
271
+ dropped(sent) {
272
+ if (this.closing || !this.redial)
273
+ return;
274
+ this.child = null;
275
+ if (this.watchdog)
276
+ clearTimeout(this.watchdog);
277
+ this.watchdog = null;
278
+ const said = lastLine(this.stderr);
279
+ if (said)
280
+ this.info.error = said;
281
+ this.failures = sent ? 0 : this.failures + 1;
282
+ if (this.failures >= GIVE_UP) {
283
+ this.close();
284
+ return;
285
+ }
286
+ this.info.redials = (this.info.redials ?? 0) + 1;
287
+ this.startOver();
288
+ const dial = this.redial;
289
+ this.timer = setTimeout(() => {
290
+ this.timer = null;
291
+ dial();
292
+ }, REDIAL);
293
+ // A redial is not a reason to keep the process alive at exit.
294
+ this.timer.unref?.();
295
+ }
296
+ /**
297
+ * Out to the audience, whole boxes at a time when there are boxes.
298
+ *
299
+ * Video listeners are only ever sent complete boxes, so that a new one can
300
+ * be given the opening boxes and then join at the next fragment and have it
301
+ * make sense.
302
+ */
303
+ emit(chunk) {
304
+ if (!this.fragments) {
305
+ this.send(chunk);
306
+ return;
307
+ }
308
+ for (const box of this.fragments.push(chunk))
309
+ this.send(box);
310
+ }
73
311
  /** Feed the source. */
74
312
  write(chunk) {
75
313
  return this.child?.stdin?.write(chunk) ?? false;
@@ -106,6 +344,17 @@ export class Channel {
106
344
  this.info.listeners = this.listeners.size;
107
345
  }
108
346
  listen(listener) {
347
+ // What the stream is, before any of what it is currently saying. Without
348
+ // this a listener who arrives after the first second gets fragments that
349
+ // reference tracks they were never told about: a blank panel, no error.
350
+ if (this.fragments?.ready) {
351
+ try {
352
+ listener.write(this.fragments.header);
353
+ }
354
+ catch {
355
+ // Gone before it began; the detach below still tidies up.
356
+ }
357
+ }
109
358
  this.listeners.add(listener);
110
359
  this.info.listeners = this.listeners.size;
111
360
  return () => {
@@ -117,6 +366,16 @@ export class Channel {
117
366
  if (this.closing)
118
367
  return;
119
368
  this.closing = true;
369
+ this.redial = null;
370
+ if (this.timer)
371
+ clearTimeout(this.timer);
372
+ this.timer = null;
373
+ if (this.watchdog)
374
+ clearTimeout(this.watchdog);
375
+ this.watchdog = null;
376
+ const said = lastLine(this.stderr);
377
+ if (said && !this.info.error)
378
+ this.info.error = said;
120
379
  const child = this.child;
121
380
  this.child = null;
122
381
  try {
@@ -194,6 +453,47 @@ export class Channels {
194
453
  channel.start(format);
195
454
  return channel;
196
455
  }
456
+ /**
457
+ * Carry a source of our own: a re-stream, or a film on this disk shown live.
458
+ *
459
+ * Null when that channel is taken, the same as publishing. Everything else
460
+ * about it is the same too, which is the point -- a re-stream stops being a
461
+ * special case and becomes one more thing that is on.
462
+ */
463
+ pull(id, name, source, encode, kind, paced = true, stall = STALL) {
464
+ if (this.open.has(id))
465
+ return null;
466
+ const channel = new Channel({
467
+ id,
468
+ name: name || source,
469
+ format: kind === "video" ? "mp4" : "mp3",
470
+ via: "pull",
471
+ startedAt: Date.now(),
472
+ bytes: 0,
473
+ listeners: 0,
474
+ kind,
475
+ source,
476
+ }, this.options, (gone) => this.open.delete(gone));
477
+ this.open.set(id, channel);
478
+ channel.pull(source, encode, paced, stall);
479
+ return channel;
480
+ }
481
+ /**
482
+ * Dial a pulled channel's source again, now. False for a channel that is
483
+ * not there or is not ours to dial: a publisher's stream restarts at the
484
+ * publisher's end.
485
+ */
486
+ restart(id) {
487
+ return this.open.get(id)?.restart() ?? false;
488
+ }
489
+ /** Whether a channel is one we fetch ourselves, and so can start over. */
490
+ pulled(id) {
491
+ return this.open.get(id)?.info.via === "pull";
492
+ }
493
+ /** What a listener should be told this channel is. */
494
+ contentType(id) {
495
+ return this.open.get(id)?.info.kind === "video" ? "video/mp4" : "audio/mpeg";
496
+ }
197
497
  /** Attach a listener, or null when nothing is playing on that channel. */
198
498
  listen(id, listener) {
199
499
  const channel = this.open.get(id);
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Splitting a fragmented MP4 into the pieces a late arrival needs.
3
+ *
4
+ * MP3 can be joined halfway through because every frame says what it is: a
5
+ * player finds the next frame boundary and carries on. Fragmented MP4 cannot.
6
+ * It opens with an `ftyp` and a `moov` that describe the tracks -- how many,
7
+ * which codecs, what timescale -- and everything after that is a `moof` and an
8
+ * `mdat` that mean nothing without them. Hand somebody the middle of that
9
+ * stream and their browser has no idea what it is holding, which is a black
10
+ * panel and no error.
11
+ *
12
+ * So the opening boxes are kept, and a listener who arrives an hour late is
13
+ * given them before the live bytes. Fragments written with `frag_keyframe`
14
+ * each begin at a keyframe, so the picture starts at the first one rather than
15
+ * with a screen of blocks catching up.
16
+ *
17
+ * This also means listeners are only ever written whole boxes. A chunk from a
18
+ * pipe ends wherever the pipe felt like ending it, and half a `moof` is not
19
+ * something to send anybody.
20
+ */
21
+ export interface Box {
22
+ type: string;
23
+ bytes: Buffer;
24
+ }
25
+ /**
26
+ * The first whole box in a buffer, or null when it has not all arrived.
27
+ *
28
+ * Null is also the answer for anything malformed, because the difference does
29
+ * not matter to a caller who can only wait or give up, and guessing at a
30
+ * broken length walks off the end of the stream.
31
+ */
32
+ export declare function firstBox(buffer: Buffer): {
33
+ box: Box;
34
+ rest: Buffer;
35
+ } | null;
36
+ /** The boxes that describe the stream rather than carry it. */
37
+ export declare function isOpening(type: string): boolean;
38
+ /**
39
+ * A fragmented MP4 arriving in pieces, handed back a box at a time.
40
+ *
41
+ * Keeps the opening boxes so they can be replayed to whoever turns up later.
42
+ * If the bytes turn out not to be an MP4 at all -- a source that failed, a
43
+ * format nobody expected -- it stops trying to parse and passes them through,
44
+ * on the grounds that a stream somebody might be able to play beats a stream
45
+ * nobody can.
46
+ */
47
+ export declare class Fragments {
48
+ private held;
49
+ private opening;
50
+ private confused;
51
+ /** The `ftyp` and `moov` seen so far, ready to send to a new listener. */
52
+ get header(): Buffer;
53
+ /** Whether enough has arrived to describe the stream to somebody new. */
54
+ get ready(): boolean;
55
+ /** Feed bytes in; get whole boxes out, in order. */
56
+ push(chunk: Buffer): Buffer[];
57
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Splitting a fragmented MP4 into the pieces a late arrival needs.
3
+ *
4
+ * MP3 can be joined halfway through because every frame says what it is: a
5
+ * player finds the next frame boundary and carries on. Fragmented MP4 cannot.
6
+ * It opens with an `ftyp` and a `moov` that describe the tracks -- how many,
7
+ * which codecs, what timescale -- and everything after that is a `moof` and an
8
+ * `mdat` that mean nothing without them. Hand somebody the middle of that
9
+ * stream and their browser has no idea what it is holding, which is a black
10
+ * panel and no error.
11
+ *
12
+ * So the opening boxes are kept, and a listener who arrives an hour late is
13
+ * given them before the live bytes. Fragments written with `frag_keyframe`
14
+ * each begin at a keyframe, so the picture starts at the first one rather than
15
+ * with a screen of blocks catching up.
16
+ *
17
+ * This also means listeners are only ever written whole boxes. A chunk from a
18
+ * pipe ends wherever the pipe felt like ending it, and half a `moof` is not
19
+ * something to send anybody.
20
+ */
21
+ /** The header of an MP4 box: four bytes of length, four of name. */
22
+ const HEADER = 8;
23
+ /** A length of 1 means the real one is the eight bytes that follow. */
24
+ const BIG = 16;
25
+ /**
26
+ * The first whole box in a buffer, or null when it has not all arrived.
27
+ *
28
+ * Null is also the answer for anything malformed, because the difference does
29
+ * not matter to a caller who can only wait or give up, and guessing at a
30
+ * broken length walks off the end of the stream.
31
+ */
32
+ export function firstBox(buffer) {
33
+ if (buffer.length < HEADER)
34
+ return null;
35
+ const stated = buffer.readUInt32BE(0);
36
+ const type = buffer.toString("latin1", 4, HEADER);
37
+ // A name is four printable characters. Anything else means we are not
38
+ // looking at a box header, and no length read from here can be trusted.
39
+ if (!/^[\x20-\x7e]{4}$/.test(type))
40
+ return null;
41
+ let size = stated;
42
+ let header = HEADER;
43
+ if (stated === 1) {
44
+ if (buffer.length < BIG)
45
+ return null;
46
+ const large = buffer.readBigUInt64BE(HEADER);
47
+ if (large > BigInt(Number.MAX_SAFE_INTEGER))
48
+ return null;
49
+ size = Number(large);
50
+ header = BIG;
51
+ }
52
+ // Zero means "to the end of the file", which a live stream does not have.
53
+ if (size < header)
54
+ return null;
55
+ if (buffer.length < size)
56
+ return null;
57
+ return { box: { type, bytes: buffer.subarray(0, size) }, rest: buffer.subarray(size) };
58
+ }
59
+ /** The boxes that describe the stream rather than carry it. */
60
+ export function isOpening(type) {
61
+ return type === "ftyp" || type === "moov";
62
+ }
63
+ /**
64
+ * A fragmented MP4 arriving in pieces, handed back a box at a time.
65
+ *
66
+ * Keeps the opening boxes so they can be replayed to whoever turns up later.
67
+ * If the bytes turn out not to be an MP4 at all -- a source that failed, a
68
+ * format nobody expected -- it stops trying to parse and passes them through,
69
+ * on the grounds that a stream somebody might be able to play beats a stream
70
+ * nobody can.
71
+ */
72
+ export class Fragments {
73
+ held = Buffer.alloc(0);
74
+ opening = [];
75
+ confused = false;
76
+ /** The `ftyp` and `moov` seen so far, ready to send to a new listener. */
77
+ get header() {
78
+ return this.opening.length === 0 ? Buffer.alloc(0) : Buffer.concat(this.opening);
79
+ }
80
+ /** Whether enough has arrived to describe the stream to somebody new. */
81
+ get ready() {
82
+ return this.opening.length > 0;
83
+ }
84
+ /** Feed bytes in; get whole boxes out, in order. */
85
+ push(chunk) {
86
+ if (this.confused)
87
+ return [chunk];
88
+ this.held = this.held.length === 0 ? chunk : Buffer.concat([this.held, chunk]);
89
+ const out = [];
90
+ for (;;) {
91
+ const next = firstBox(this.held);
92
+ if (!next)
93
+ break;
94
+ this.held = next.rest;
95
+ if (isOpening(next.box.type))
96
+ this.opening.push(next.box.bytes);
97
+ out.push(next.box.bytes);
98
+ }
99
+ // Nothing parses and the buffer keeps growing: this is not an MP4. Let it
100
+ // through rather than swallowing a stream into memory for ever.
101
+ if (this.opening.length === 0 && this.held.length > 4 * 1024 * 1024) {
102
+ this.confused = true;
103
+ const everything = this.held;
104
+ this.held = Buffer.alloc(0);
105
+ return [everything];
106
+ }
107
+ return out;
108
+ }
109
+ }
package/dist/rtmp-in.js CHANGED
@@ -59,6 +59,10 @@ export class RtmpListeners {
59
59
  channel?.feed(chunk);
60
60
  });
61
61
  child.stdout?.on("error", () => child.kill("SIGKILL"));
62
+ // Read and dropped. A pipe nobody reads fills at 64 KiB, and ffmpeg then
63
+ // blocks on its next complaint and stops producing anything -- a
64
+ // publisher whose stream hiccups enough would take the slot down with it.
65
+ child.stderr?.resume();
62
66
  child.on("error", () => this.done(slot, child, channel));
63
67
  child.on("close", () => this.done(slot, child, channel));
64
68
  }