nixamp 0.23.6 → 0.23.7
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/README.md +12 -0
- package/dist/channels.d.ts +31 -0
- package/dist/channels.js +99 -4
- package/dist/outro.d.ts +48 -0
- package/dist/outro.js +225 -0
- package/dist/rtmp-in.js +6 -1
- package/dist/server.js +9 -0
- package/package.json +1 -1
- package/src/channels.ts +107 -4
- package/src/outro.ts +245 -0
- package/src/rtmp-in.ts +5 -2
- package/src/server.ts +9 -0
- package/web/dist/assets/{hls-3VKVEQE3-CqYm-w53.js → hls-3VKVEQE3-CI1U7kbP.js} +1 -1
- package/web/dist/assets/index-5H3sUGHu.js +1 -0
- package/web/dist/assets/{mpegts-BXa-l6Og.js → mpegts-CPOYjgRP.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-CkxdQRL_.js → mpegts-LO6RVLD6-CE8YPjx1.js} +1 -1
- package/web/dist/index.html +1 -1
- package/web/dist/sw.js +5 -5
- package/web/dist/assets/index-Qo7uZEAJ.js +0 -1
package/README.md
CHANGED
|
@@ -297,6 +297,18 @@ something else.
|
|
|
297
297
|
Entries expire a few minutes after a stream stops renewing, so the list is
|
|
298
298
|
always what is actually live.
|
|
299
299
|
|
|
300
|
+
**A live that ends says so.** A film or a podcast that plays to its end, a
|
|
301
|
+
list whose last entry did, or a publisher who stopped, used to start again
|
|
302
|
+
from the top for ever. Now the channel plays its outro: five seconds of
|
|
303
|
+
"THIS LIVE STREAM HAS ENDED" on the plate with the mark (a soft chime, on a
|
|
304
|
+
channel with no picture), looped for an hour, so whoever joins late is told
|
|
305
|
+
by the picture and by the page (an ENDED chip and a line in the Log), and
|
|
306
|
+
the room's trollbox stays open. Then the channel closes on its own. The
|
|
307
|
+
clip is drawn by the server itself with its ffmpeg the first time it is
|
|
308
|
+
needed and kept beside the keys; **Start over** on the channel brings the
|
|
309
|
+
show back from its beginning. A live feed that drops mid-stream is still
|
|
310
|
+
dialled again, as before: only a clean end is an end.
|
|
311
|
+
|
|
300
312
|
## The trollbox, and saying a line out loud
|
|
301
313
|
|
|
302
314
|
Every live room has a trollbox: the chat for whoever has joined that stream,
|
package/dist/channels.d.ts
CHANGED
|
@@ -80,6 +80,12 @@ export interface ChannelInfo {
|
|
|
80
80
|
* plain URL is read this way, and only when a policy asks for it.
|
|
81
81
|
*/
|
|
82
82
|
teed?: boolean;
|
|
83
|
+
/**
|
|
84
|
+
* When its show ended, wall clock, while the outro plays. A channel with
|
|
85
|
+
* this set is still on the air -- the picture says the stream has ended
|
|
86
|
+
* -- and closes on its own an hour later. Absent while the show is on.
|
|
87
|
+
*/
|
|
88
|
+
ended?: number;
|
|
83
89
|
/**
|
|
84
90
|
* A picture of it from somewhere else: the thumbnail the site offered
|
|
85
91
|
* for a pasted link, the logo a catalog gave a channel. A channel with
|
|
@@ -136,6 +142,8 @@ export declare const GIVE_UP = 5;
|
|
|
136
142
|
export declare const STALL = 30000;
|
|
137
143
|
/** How long an on-demand channel stays up with nobody watching. */
|
|
138
144
|
export declare const IDLE = 60000;
|
|
145
|
+
/** How long a channel plays its outro after the show, before it closes. */
|
|
146
|
+
export declare const OUTRO_MS: number;
|
|
139
147
|
/**
|
|
140
148
|
* How much of the recent stream a newcomer is handed. About six seconds of
|
|
141
149
|
* 720p television, and a couple of seconds of 192k MP3: enough to play
|
|
@@ -181,6 +189,16 @@ export interface ChannelOptions {
|
|
|
181
189
|
ffmpeg: string[];
|
|
182
190
|
onStart?: (info: ChannelInfo) => void;
|
|
183
191
|
onEnd?: (info: ChannelInfo) => void;
|
|
192
|
+
/**
|
|
193
|
+
* The outro: the clip a channel plays once its show is over, by kind,
|
|
194
|
+
* encoded as the wire wants it (see outro.ts). Null, or absent, means a
|
|
195
|
+
* show that ends closes its channel as it always did.
|
|
196
|
+
*/
|
|
197
|
+
outro?: (kind: "audio" | "video") => Promise<string | null>;
|
|
198
|
+
/** How long the outro plays before the channel closes. An hour. */
|
|
199
|
+
outroMs?: number;
|
|
200
|
+
/** Said once, when a show ends and the outro begins. */
|
|
201
|
+
onOutro?: (info: ChannelInfo) => void;
|
|
184
202
|
/** How long an on-demand channel outlives its last viewer. Tests shorten it. */
|
|
185
203
|
idleMs?: number;
|
|
186
204
|
/** Unsent bytes a listener may hold before it is dropped. Tests shrink it. */
|
|
@@ -214,6 +232,11 @@ export declare class Channel {
|
|
|
214
232
|
/** Fires when a pulled source has said nothing for STALL. */
|
|
215
233
|
private watchdog;
|
|
216
234
|
private stall;
|
|
235
|
+
/** Whether the outro is what is playing now: the show is over. */
|
|
236
|
+
private outroOn;
|
|
237
|
+
private outroTimer;
|
|
238
|
+
/** How the show was dialled, so a restart after the outro is the show again. */
|
|
239
|
+
private dialed;
|
|
217
240
|
private stderr;
|
|
218
241
|
/**
|
|
219
242
|
* The last few seconds, for whoever joins next.
|
|
@@ -259,6 +282,14 @@ export declare class Channel {
|
|
|
259
282
|
* in it is not a room anybody can be invited to.
|
|
260
283
|
*/
|
|
261
284
|
pull(source: string, encode: string[], paced?: boolean, stall?: number, input?: string[], audio?: string, resume?: PullResume): void;
|
|
285
|
+
/**
|
|
286
|
+
* The show is over: the outro, then the end. A publisher's channel is
|
|
287
|
+
* told this when the publisher goes, so whoever joins in the next hour
|
|
288
|
+
* is shown that the stream has ended rather than nothing at all. Without
|
|
289
|
+
* an outro to play it is the same as close().
|
|
290
|
+
*/
|
|
291
|
+
finish(): void;
|
|
292
|
+
private endShow;
|
|
262
293
|
/**
|
|
263
294
|
* Start the source over, now.
|
|
264
295
|
*
|
package/dist/channels.js
CHANGED
|
@@ -40,6 +40,13 @@ export const GIVE_UP = 5;
|
|
|
40
40
|
export const STALL = 30_000;
|
|
41
41
|
/** How long an on-demand channel stays up with nobody watching. */
|
|
42
42
|
export const IDLE = 60_000;
|
|
43
|
+
/** How long a channel plays its outro after the show, before it closes. */
|
|
44
|
+
export const OUTRO_MS = 60 * 60 * 1000;
|
|
45
|
+
/** The encode the outro is copied through: what a channel sends on the wire. */
|
|
46
|
+
const OUTRO_ENCODE = {
|
|
47
|
+
video: ["-c", "copy", "-f", "mp4", "-movflags", "frag_keyframe+empty_moov+default_base_moof"],
|
|
48
|
+
audio: ["-c", "copy", "-f", "mp3"],
|
|
49
|
+
};
|
|
43
50
|
/** How much of what ffmpeg said to keep, for the last line when it dies. */
|
|
44
51
|
const TAIL = 2000;
|
|
45
52
|
/**
|
|
@@ -140,6 +147,11 @@ export class Channel {
|
|
|
140
147
|
/** Fires when a pulled source has said nothing for STALL. */
|
|
141
148
|
watchdog = null;
|
|
142
149
|
stall = STALL;
|
|
150
|
+
/** Whether the outro is what is playing now: the show is over. */
|
|
151
|
+
outroOn = false;
|
|
152
|
+
outroTimer = null;
|
|
153
|
+
/** How the show was dialled, so a restart after the outro is the show again. */
|
|
154
|
+
dialed = null;
|
|
143
155
|
stderr = "";
|
|
144
156
|
/**
|
|
145
157
|
* The last few seconds, for whoever joins next.
|
|
@@ -219,6 +231,8 @@ export class Channel {
|
|
|
219
231
|
*/
|
|
220
232
|
pull(source, encode, paced = true, stall = STALL, input = [], audio = "", resume = { live: true, position: 0 }) {
|
|
221
233
|
this.stall = stall;
|
|
234
|
+
if (!this.outroOn)
|
|
235
|
+
this.dialed = { source, encode, paced, stall, input: [...input], audio, resume };
|
|
222
236
|
if (this.info.kind === "video")
|
|
223
237
|
this.fragments = new Fragments();
|
|
224
238
|
const [command, ...prefix] = this.options.ffmpeg;
|
|
@@ -235,9 +249,13 @@ export class Channel {
|
|
|
235
249
|
this.info.playlistAt = at;
|
|
236
250
|
}
|
|
237
251
|
let current = list ? list[at] : source;
|
|
252
|
+
// The last entry is the end of the show, not a way back to the first:
|
|
253
|
+
// a list that played through is over, and says so with the outro.
|
|
238
254
|
this.advance = list && list.length > 1
|
|
239
255
|
? () => {
|
|
240
|
-
|
|
256
|
+
if (at + 1 >= list.length)
|
|
257
|
+
return false;
|
|
258
|
+
at += 1;
|
|
241
259
|
this.info.playlistAt = at;
|
|
242
260
|
current = list[at];
|
|
243
261
|
return true;
|
|
@@ -267,7 +285,8 @@ export class Channel {
|
|
|
267
285
|
// policy set after the channel started applies at its next restart.
|
|
268
286
|
this.throughAbort?.abort();
|
|
269
287
|
this.throughAbort = null;
|
|
270
|
-
|
|
288
|
+
// The outro is a file read by ffmpeg itself: a pipe cannot loop.
|
|
289
|
+
const through = this.outroOn ? null : this.options.through?.(this.info, from, input, audio) ?? null;
|
|
271
290
|
this.info.teed = through !== null;
|
|
272
291
|
const child = spawn(command, [
|
|
273
292
|
...prefix,
|
|
@@ -362,7 +381,46 @@ export class Channel {
|
|
|
362
381
|
};
|
|
363
382
|
this.redial = dial;
|
|
364
383
|
dial();
|
|
365
|
-
|
|
384
|
+
if (!this.outroOn)
|
|
385
|
+
this.options.onStart?.(this.info);
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* The show is over: the outro, then the end. A publisher's channel is
|
|
389
|
+
* told this when the publisher goes, so whoever joins in the next hour
|
|
390
|
+
* is shown that the stream has ended rather than nothing at all. Without
|
|
391
|
+
* an outro to play it is the same as close().
|
|
392
|
+
*/
|
|
393
|
+
finish() {
|
|
394
|
+
if (this.closing || this.outroOn)
|
|
395
|
+
return;
|
|
396
|
+
void this.endShow();
|
|
397
|
+
}
|
|
398
|
+
async endShow() {
|
|
399
|
+
const kind = this.info.kind ?? "audio";
|
|
400
|
+
const old = this.child;
|
|
401
|
+
this.child = null;
|
|
402
|
+
if (this.watchdog)
|
|
403
|
+
clearTimeout(this.watchdog);
|
|
404
|
+
this.watchdog = null;
|
|
405
|
+
if (this.timer)
|
|
406
|
+
clearTimeout(this.timer);
|
|
407
|
+
this.timer = null;
|
|
408
|
+
old?.kill("SIGKILL");
|
|
409
|
+
const clip = this.options.outro ? await this.options.outro(kind).catch(() => null) : null;
|
|
410
|
+
if (this.closing)
|
|
411
|
+
return;
|
|
412
|
+
if (!clip) {
|
|
413
|
+
this.close();
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
this.outroOn = true;
|
|
417
|
+
this.info.ended = Date.now();
|
|
418
|
+
this.info.error = undefined;
|
|
419
|
+
this.startOver();
|
|
420
|
+
this.options.onOutro?.(this.info);
|
|
421
|
+
this.pull(clip, OUTRO_ENCODE[kind], true, this.stall, ["-stream_loop", "-1"], "", { live: true, position: 0 });
|
|
422
|
+
this.outroTimer = setTimeout(() => this.close(), this.options.outroMs ?? OUTRO_MS);
|
|
423
|
+
this.outroTimer.unref?.();
|
|
366
424
|
}
|
|
367
425
|
/**
|
|
368
426
|
* Start the source over, now.
|
|
@@ -374,8 +432,28 @@ export class Channel {
|
|
|
374
432
|
* strikes a dead CDN ran up an hour ago.
|
|
375
433
|
*/
|
|
376
434
|
restart() {
|
|
435
|
+
if (this.closing)
|
|
436
|
+
return false;
|
|
437
|
+
// Started over from the outro: the show itself, from its beginning.
|
|
438
|
+
if (this.outroOn && this.dialed) {
|
|
439
|
+
this.outroOn = false;
|
|
440
|
+
delete this.info.ended;
|
|
441
|
+
if (this.outroTimer)
|
|
442
|
+
clearTimeout(this.outroTimer);
|
|
443
|
+
this.outroTimer = null;
|
|
444
|
+
const old = this.child;
|
|
445
|
+
this.child = null;
|
|
446
|
+
old?.kill("SIGKILL");
|
|
447
|
+
this.failures = 0;
|
|
448
|
+
this.info.error = undefined;
|
|
449
|
+
this.info.playlistAt = 0;
|
|
450
|
+
this.startOver();
|
|
451
|
+
const show = this.dialed;
|
|
452
|
+
this.pull(show.source, show.encode, show.paced, show.stall, show.input, show.audio, { ...show.resume, position: 0 });
|
|
453
|
+
return true;
|
|
454
|
+
}
|
|
377
455
|
const dial = this.redial;
|
|
378
|
-
if (!dial
|
|
456
|
+
if (!dial)
|
|
379
457
|
return false;
|
|
380
458
|
if (this.timer)
|
|
381
459
|
clearTimeout(this.timer);
|
|
@@ -469,6 +547,11 @@ export class Channel {
|
|
|
469
547
|
dropped(sent) {
|
|
470
548
|
if (this.closing || !this.redial)
|
|
471
549
|
return;
|
|
550
|
+
// An outro that stopped is over; there is nothing after it.
|
|
551
|
+
if (this.outroOn) {
|
|
552
|
+
this.close();
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
472
555
|
this.child = null;
|
|
473
556
|
if (this.watchdog)
|
|
474
557
|
clearTimeout(this.watchdog);
|
|
@@ -486,6 +569,15 @@ export class Channel {
|
|
|
486
569
|
// gave nothing is skipped the same way, counted as the failure it was,
|
|
487
570
|
// so a list of dead links gives up rather than cycling for ever.
|
|
488
571
|
const moved = this.advance?.() ?? false;
|
|
572
|
+
// A show that ended: a film or a podcast that played to its end, a
|
|
573
|
+
// list whose last entry did. That is not a source that dropped, and it
|
|
574
|
+
// is not dialled again from the top; it is over, and the outro says
|
|
575
|
+
// so. A live feed that stopped is a feed that dropped, and is redialled.
|
|
576
|
+
const list = (this.info.playlist?.length ?? 0) > 0;
|
|
577
|
+
if (sent && !moved && (this.info.live === false || list)) {
|
|
578
|
+
void this.endShow();
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
489
581
|
const ended = moved && sent;
|
|
490
582
|
if (ended)
|
|
491
583
|
this.info.error = undefined;
|
|
@@ -786,6 +878,9 @@ export class Channel {
|
|
|
786
878
|
if (this.idle)
|
|
787
879
|
clearTimeout(this.idle);
|
|
788
880
|
this.idle = null;
|
|
881
|
+
if (this.outroTimer)
|
|
882
|
+
clearTimeout(this.outroTimer);
|
|
883
|
+
this.outroTimer = null;
|
|
789
884
|
const said = lastLine(this.stderr);
|
|
790
885
|
if (said && !this.info.error)
|
|
791
886
|
this.info.error = said;
|
package/dist/outro.d.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/** How long the outro plays after a show ends, before the channel closes. */
|
|
2
|
+
export declare const OUTRO_MS: number;
|
|
3
|
+
/** The clip's length; it loops. */
|
|
4
|
+
export declare const OUTRO_SECONDS = 5;
|
|
5
|
+
/** Bumped when the drawing changes, so an old clip on disk is drawn again. */
|
|
6
|
+
export declare const OUTRO_VERSION = 1;
|
|
7
|
+
export interface Rgba {
|
|
8
|
+
r: number;
|
|
9
|
+
g: number;
|
|
10
|
+
b: number;
|
|
11
|
+
a: number;
|
|
12
|
+
}
|
|
13
|
+
/** A canvas of straight RGBA bytes. */
|
|
14
|
+
export declare class Bitmap {
|
|
15
|
+
readonly width: number;
|
|
16
|
+
readonly height: number;
|
|
17
|
+
readonly pixels: Uint8Array;
|
|
18
|
+
constructor(width: number, height: number);
|
|
19
|
+
fill(color: Rgba): void;
|
|
20
|
+
rect(x: number, y: number, w: number, h: number, color: Rgba): void;
|
|
21
|
+
private set;
|
|
22
|
+
}
|
|
23
|
+
/** Encode as a non-interlaced 8-bit RGBA PNG. */
|
|
24
|
+
export declare function encodePng(bitmap: Bitmap): Buffer;
|
|
25
|
+
export declare function textWidth(text: string, cell: number): number;
|
|
26
|
+
export declare function drawText(bitmap: Bitmap, text: string, x: number, y: number, cell: number, color: Rgba): void;
|
|
27
|
+
/** The picture: the mark above the words, on the plate. 1280x720. */
|
|
28
|
+
export declare function drawOutro(width?: number, height?: number): Bitmap;
|
|
29
|
+
/** The encode a channel copies an outro through: what a channel sends on the wire. */
|
|
30
|
+
export declare const OUTRO_ENCODE: Record<"audio" | "video", string[]>;
|
|
31
|
+
export interface OutroOptions {
|
|
32
|
+
ffmpeg: string[];
|
|
33
|
+
/** Where the clips are kept. */
|
|
34
|
+
dir: string;
|
|
35
|
+
onEvent?: (message: string) => void;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* The clips, drawn on first use and kept. `clip(kind)` answers the path,
|
|
39
|
+
* or null when there is no ffmpeg to make one, in which case a channel
|
|
40
|
+
* that ends simply closes as it used to.
|
|
41
|
+
*/
|
|
42
|
+
export declare class Outro {
|
|
43
|
+
private readonly options;
|
|
44
|
+
private made;
|
|
45
|
+
constructor(options: OutroOptions);
|
|
46
|
+
clip(kind: "audio" | "video"): Promise<string | null>;
|
|
47
|
+
private make;
|
|
48
|
+
}
|
package/dist/outro.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The outro: what a channel plays once its show is over.
|
|
3
|
+
*
|
|
4
|
+
* A live that ended used to start again from the top, for ever, because a
|
|
5
|
+
* source that finished cleanly was dialled like a source that dropped. Now
|
|
6
|
+
* it plays this: five seconds of "THIS LIVE STREAM HAS ENDED" on the
|
|
7
|
+
* plate with the mark, looped for an hour, so whoever joins late is told
|
|
8
|
+
* so by the picture and not by a blank panel. Then the channel closes.
|
|
9
|
+
*
|
|
10
|
+
* Drawn here, never committed as a file, the way the app's icons are:
|
|
11
|
+
* a bitmap, a 5x7 pixel face, and ffmpeg to make a clip of it. Encoded
|
|
12
|
+
* exactly as a channel is sent on the wire (H.264 and AAC for a picture,
|
|
13
|
+
* MP3 for sound alone) so the channel can copy it through without knowing
|
|
14
|
+
* how its own source was encoded. Made once per server and kept beside the
|
|
15
|
+
* keys; a missing or stale one is drawn again.
|
|
16
|
+
*/
|
|
17
|
+
import { spawnSync } from "node:child_process";
|
|
18
|
+
import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { join } from "node:path";
|
|
20
|
+
import { deflateSync } from "node:zlib";
|
|
21
|
+
/** How long the outro plays after a show ends, before the channel closes. */
|
|
22
|
+
export const OUTRO_MS = 60 * 60 * 1000;
|
|
23
|
+
/** The clip's length; it loops. */
|
|
24
|
+
export const OUTRO_SECONDS = 5;
|
|
25
|
+
/** Bumped when the drawing changes, so an old clip on disk is drawn again. */
|
|
26
|
+
export const OUTRO_VERSION = 1;
|
|
27
|
+
const BACKGROUND = { r: 8, g: 12, b: 9, a: 255 };
|
|
28
|
+
const GREEN = { r: 74, g: 246, b: 137, a: 255 };
|
|
29
|
+
const DIM = { r: 34, g: 122, b: 74, a: 255 };
|
|
30
|
+
const MUTED = { r: 120, g: 150, b: 130, a: 255 };
|
|
31
|
+
const BARS = [0.30, 0.55, 0.85, 0.62, 1.0, 0.72, 0.45, 0.25];
|
|
32
|
+
/** A canvas of straight RGBA bytes. */
|
|
33
|
+
export class Bitmap {
|
|
34
|
+
width;
|
|
35
|
+
height;
|
|
36
|
+
pixels;
|
|
37
|
+
constructor(width, height) {
|
|
38
|
+
this.width = width;
|
|
39
|
+
this.height = height;
|
|
40
|
+
this.pixels = new Uint8Array(width * height * 4);
|
|
41
|
+
}
|
|
42
|
+
fill(color) {
|
|
43
|
+
for (let i = 0; i < this.pixels.length; i += 4)
|
|
44
|
+
this.set(i, color);
|
|
45
|
+
}
|
|
46
|
+
rect(x, y, w, h, color) {
|
|
47
|
+
const x0 = Math.max(0, Math.round(x));
|
|
48
|
+
const y0 = Math.max(0, Math.round(y));
|
|
49
|
+
const x1 = Math.min(this.width, Math.round(x + w));
|
|
50
|
+
const y1 = Math.min(this.height, Math.round(y + h));
|
|
51
|
+
for (let py = y0; py < y1; py++) {
|
|
52
|
+
for (let px = x0; px < x1; px++)
|
|
53
|
+
this.set((py * this.width + px) * 4, color);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
set(offset, { r, g, b, a }) {
|
|
57
|
+
this.pixels[offset] = r;
|
|
58
|
+
this.pixels[offset + 1] = g;
|
|
59
|
+
this.pixels[offset + 2] = b;
|
|
60
|
+
this.pixels[offset + 3] = a;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
const CRC_TABLE = (() => {
|
|
64
|
+
const table = new Uint32Array(256);
|
|
65
|
+
for (let n = 0; n < 256; n++) {
|
|
66
|
+
let c = n;
|
|
67
|
+
for (let k = 0; k < 8; k++)
|
|
68
|
+
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
69
|
+
table[n] = c >>> 0;
|
|
70
|
+
}
|
|
71
|
+
return table;
|
|
72
|
+
})();
|
|
73
|
+
function crc32(bytes) {
|
|
74
|
+
let c = 0xffffffff;
|
|
75
|
+
for (const byte of bytes)
|
|
76
|
+
c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8);
|
|
77
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
78
|
+
}
|
|
79
|
+
function chunk(type, data) {
|
|
80
|
+
const head = Buffer.alloc(8);
|
|
81
|
+
head.writeUInt32BE(data.length, 0);
|
|
82
|
+
head.write(type, 4, "ascii");
|
|
83
|
+
const body = Buffer.concat([head.subarray(4), Buffer.from(data)]);
|
|
84
|
+
const tail = Buffer.alloc(4);
|
|
85
|
+
tail.writeUInt32BE(crc32(body), 0);
|
|
86
|
+
return Buffer.concat([head.subarray(0, 4), body, tail]);
|
|
87
|
+
}
|
|
88
|
+
/** Encode as a non-interlaced 8-bit RGBA PNG. */
|
|
89
|
+
export function encodePng(bitmap) {
|
|
90
|
+
const { width, height, pixels } = bitmap;
|
|
91
|
+
const stride = width * 4;
|
|
92
|
+
const raw = Buffer.alloc((stride + 1) * height);
|
|
93
|
+
for (let y = 0; y < height; y++) {
|
|
94
|
+
raw[y * (stride + 1)] = 0;
|
|
95
|
+
Buffer.from(pixels.subarray(y * stride, (y + 1) * stride)).copy(raw, y * (stride + 1) + 1);
|
|
96
|
+
}
|
|
97
|
+
const ihdr = Buffer.alloc(13);
|
|
98
|
+
ihdr.writeUInt32BE(width, 0);
|
|
99
|
+
ihdr.writeUInt32BE(height, 4);
|
|
100
|
+
ihdr[8] = 8;
|
|
101
|
+
ihdr[9] = 6;
|
|
102
|
+
return Buffer.concat([
|
|
103
|
+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
104
|
+
chunk("IHDR", ihdr),
|
|
105
|
+
chunk("IDAT", deflateSync(raw, { level: 9 })),
|
|
106
|
+
chunk("IEND", new Uint8Array(0)),
|
|
107
|
+
]);
|
|
108
|
+
}
|
|
109
|
+
/** A 5x7 pixel face: the letters the outro needs and nothing else. */
|
|
110
|
+
const GLYPHS = {
|
|
111
|
+
A: [".XXX.", "X...X", "X...X", "XXXXX", "X...X", "X...X", "X...X"],
|
|
112
|
+
C: [".XXXX", "X....", "X....", "X....", "X....", "X....", ".XXXX"],
|
|
113
|
+
D: ["XXXX.", "X...X", "X...X", "X...X", "X...X", "X...X", "XXXX."],
|
|
114
|
+
E: ["XXXXX", "X....", "X....", "XXXX.", "X....", "X....", "XXXXX"],
|
|
115
|
+
H: ["X...X", "X...X", "X...X", "XXXXX", "X...X", "X...X", "X...X"],
|
|
116
|
+
I: ["XXXXX", "..X..", "..X..", "..X..", "..X..", "..X..", "XXXXX"],
|
|
117
|
+
L: ["X....", "X....", "X....", "X....", "X....", "X....", "XXXXX"],
|
|
118
|
+
M: ["X...X", "XX.XX", "X.X.X", "X.X.X", "X...X", "X...X", "X...X"],
|
|
119
|
+
N: ["X...X", "XX..X", "X.X.X", "X..XX", "X...X", "X...X", "X...X"],
|
|
120
|
+
O: [".XXX.", "X...X", "X...X", "X...X", "X...X", "X...X", ".XXX."],
|
|
121
|
+
P: ["XXXX.", "X...X", "X...X", "XXXX.", "X....", "X....", "X...."],
|
|
122
|
+
R: ["XXXX.", "X...X", "X...X", "XXXX.", "X.X..", "X..X.", "X...X"],
|
|
123
|
+
S: [".XXXX", "X....", "X....", ".XXX.", "....X", "....X", "XXXX."],
|
|
124
|
+
T: ["XXXXX", "..X..", "..X..", "..X..", "..X..", "..X..", "..X.."],
|
|
125
|
+
V: ["X...X", "X...X", "X...X", "X...X", "X...X", ".X.X.", "..X.."],
|
|
126
|
+
X: ["X...X", "X...X", ".X.X.", "..X..", ".X.X.", "X...X", "X...X"],
|
|
127
|
+
".": [".....", ".....", ".....", ".....", ".....", "..XX.", "..XX."],
|
|
128
|
+
};
|
|
129
|
+
export function textWidth(text, cell) {
|
|
130
|
+
return text.length * 6 * cell - cell;
|
|
131
|
+
}
|
|
132
|
+
export function drawText(bitmap, text, x, y, cell, color) {
|
|
133
|
+
let at = x;
|
|
134
|
+
for (const letter of text) {
|
|
135
|
+
const rows = GLYPHS[letter];
|
|
136
|
+
if (rows) {
|
|
137
|
+
rows.forEach((row, r) => {
|
|
138
|
+
for (let c = 0; c < row.length; c++)
|
|
139
|
+
if (row[c] === "X")
|
|
140
|
+
bitmap.rect(at + c * cell, y + r * cell, cell, cell, color);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
at += 6 * cell;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function drawBars(bitmap, left, bottom, width, tallest, scale) {
|
|
147
|
+
const slot = width / BARS.length;
|
|
148
|
+
const bar = slot * 0.62;
|
|
149
|
+
BARS.forEach((value, i) => {
|
|
150
|
+
const height = Math.max(2, tallest * value);
|
|
151
|
+
const x = left + i * slot + (slot - bar) / 2;
|
|
152
|
+
bitmap.rect(x, bottom - height, bar, height, GREEN);
|
|
153
|
+
const peak = Math.max(2, scale * 0.018);
|
|
154
|
+
bitmap.rect(x, bottom - height - peak * 2.4, bar, peak, DIM);
|
|
155
|
+
});
|
|
156
|
+
bitmap.rect(left, bottom, width, Math.max(2, scale * 0.028), DIM);
|
|
157
|
+
}
|
|
158
|
+
/** The picture: the mark above the words, on the plate. 1280x720. */
|
|
159
|
+
export function drawOutro(width = 1280, height = 720) {
|
|
160
|
+
const bitmap = new Bitmap(width, height);
|
|
161
|
+
bitmap.fill(BACKGROUND);
|
|
162
|
+
const mark = height * 0.3;
|
|
163
|
+
drawBars(bitmap, (width - mark) / 2, height * 0.42, mark, mark - height * 0.02, mark * 1.8);
|
|
164
|
+
const line = "THIS LIVE STREAM HAS ENDED";
|
|
165
|
+
const cell = Math.floor(Math.min((width * 0.86) / (line.length * 6), height / 40));
|
|
166
|
+
drawText(bitmap, line, (width - textWidth(line, cell)) / 2, height * 0.56, cell, GREEN);
|
|
167
|
+
const small = Math.max(2, Math.floor(cell * 0.6));
|
|
168
|
+
const site = "NIXAMP.COM";
|
|
169
|
+
drawText(bitmap, site, (width - textWidth(site, small)) / 2, height * 0.56 + cell * 7 + small * 3, small, MUTED);
|
|
170
|
+
return bitmap;
|
|
171
|
+
}
|
|
172
|
+
/** The encode a channel copies an outro through: what a channel sends on the wire. */
|
|
173
|
+
export const OUTRO_ENCODE = {
|
|
174
|
+
video: ["-c", "copy", "-f", "mp4", "-movflags", "frag_keyframe+empty_moov+default_base_moof"],
|
|
175
|
+
audio: ["-c", "copy", "-f", "mp3"],
|
|
176
|
+
};
|
|
177
|
+
/**
|
|
178
|
+
* The clips, drawn on first use and kept. `clip(kind)` answers the path,
|
|
179
|
+
* or null when there is no ffmpeg to make one, in which case a channel
|
|
180
|
+
* that ends simply closes as it used to.
|
|
181
|
+
*/
|
|
182
|
+
export class Outro {
|
|
183
|
+
options;
|
|
184
|
+
made = new Map();
|
|
185
|
+
constructor(options) {
|
|
186
|
+
this.options = options;
|
|
187
|
+
}
|
|
188
|
+
clip(kind) {
|
|
189
|
+
let pending = this.made.get(kind);
|
|
190
|
+
if (!pending) {
|
|
191
|
+
pending = Promise.resolve().then(() => this.make(kind)).catch(() => null);
|
|
192
|
+
this.made.set(kind, pending);
|
|
193
|
+
// A clip that could not be made is tried again next time, not remembered.
|
|
194
|
+
void pending.then((path) => { if (path === null)
|
|
195
|
+
this.made.delete(kind); });
|
|
196
|
+
}
|
|
197
|
+
return pending;
|
|
198
|
+
}
|
|
199
|
+
make(kind) {
|
|
200
|
+
mkdirSync(this.options.dir, { recursive: true });
|
|
201
|
+
const path = join(this.options.dir, kind === "video" ? `outro-v${OUTRO_VERSION}.mp4` : `outro-v${OUTRO_VERSION}.mp3`);
|
|
202
|
+
if (existsSync(path) && statSync(path).size > 1000)
|
|
203
|
+
return path;
|
|
204
|
+
const [command, ...prefix] = this.options.ffmpeg;
|
|
205
|
+
if (!command)
|
|
206
|
+
return null;
|
|
207
|
+
const args = ["-hide_banner", "-loglevel", "error", "-y"];
|
|
208
|
+
if (kind === "video") {
|
|
209
|
+
const picture = join(this.options.dir, `outro-v${OUTRO_VERSION}.png`);
|
|
210
|
+
writeFileSync(picture, encodePng(drawOutro()));
|
|
211
|
+
args.push("-loop", "1", "-framerate", "30", "-i", picture, "-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo", "-t", String(OUTRO_SECONDS), "-c:v", "libx264", "-preset", "veryfast", "-tune", "stillimage", "-pix_fmt", "yuv420p", "-r", "30", "-g", "30", "-keyint_min", "30", "-c:a", "aac", "-b:a", "64k", "-shortest", "-movflags", "+faststart", path);
|
|
212
|
+
}
|
|
213
|
+
else {
|
|
214
|
+
// Two soft notes, then quiet: something to hear on a channel with no picture.
|
|
215
|
+
args.push("-f", "lavfi", "-i", `aevalsrc=0.18*sin(2*PI*523.25*t)*exp(-2.5*t)+0.18*sin(2*PI*392*t)*exp(-2.5*max(t-0.7\\,0))*gte(t\\,0.7):s=44100:d=${OUTRO_SECONDS}`, "-c:a", "libmp3lame", "-b:a", "128k", path);
|
|
216
|
+
}
|
|
217
|
+
const run = spawnSync(command, [...prefix, ...args], { timeout: 60_000 });
|
|
218
|
+
if (run.error || run.status !== 0 || !existsSync(path)) {
|
|
219
|
+
this.options.onEvent?.(` the outro could not be made${run.stderr ? `: ${run.stderr.toString("utf8").trim().split("\n").pop() ?? ""}` : ""}`);
|
|
220
|
+
return null;
|
|
221
|
+
}
|
|
222
|
+
this.options.onEvent?.(` outro drawn: ${path}`);
|
|
223
|
+
return path;
|
|
224
|
+
}
|
|
225
|
+
}
|
package/dist/rtmp-in.js
CHANGED
|
@@ -70,7 +70,12 @@ export class RtmpListeners {
|
|
|
70
70
|
if (this.running.get(slot.port) !== child)
|
|
71
71
|
return;
|
|
72
72
|
this.running.delete(slot.port);
|
|
73
|
-
|
|
73
|
+
// The publisher went: the outro plays for whoever is still there, and
|
|
74
|
+
// whoever joins in the next hour, before the channel closes.
|
|
75
|
+
if (channel?.finish)
|
|
76
|
+
channel.finish();
|
|
77
|
+
else
|
|
78
|
+
channel?.close();
|
|
74
79
|
if (!this.stopped)
|
|
75
80
|
this.arm(slot);
|
|
76
81
|
}
|
package/dist/server.js
CHANGED
|
@@ -67,6 +67,7 @@ import { Rooms } from "./rooms.js";
|
|
|
67
67
|
import { Trollbox, TrollboxError, fallbackHandle, roomFor } from "./trollbox.js";
|
|
68
68
|
import { MAX_BYTES as SPEECH_BYTES, Speech, SpeechError, isWav, languageOf } from "./speech.js";
|
|
69
69
|
import { Captions } from "./captions.js";
|
|
70
|
+
import { Outro } from "./outro.js";
|
|
70
71
|
import { Profiles, Voices, spokenLine, spokenVoiceFor } from "./voices.js";
|
|
71
72
|
import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.js";
|
|
72
73
|
import { applyRemoteConfig, createPaywall, FREE_LISTENERS, paywallFromEnv, } from "./paywall.js";
|
|
@@ -2854,6 +2855,8 @@ export function createHandler(engine, options) {
|
|
|
2854
2855
|
// A picture of it, and a line about it, where there is one.
|
|
2855
2856
|
art: artOf(one),
|
|
2856
2857
|
about: one.about ?? "",
|
|
2858
|
+
// Over, with the outro playing: the page says so rather than "LIVE".
|
|
2859
|
+
...(one.ended ? { ended: one.ended } : {}),
|
|
2857
2860
|
})),
|
|
2858
2861
|
// Anything re-streamed into this server is a live stream too, and was
|
|
2859
2862
|
// sitting in the middle of the playlist among the files -- which is
|
|
@@ -4726,8 +4729,14 @@ export async function serve(argv, version = "0.1.0") {
|
|
|
4726
4729
|
});
|
|
4727
4730
|
// The pictures of channels, taken once each and kept while the channel is on.
|
|
4728
4731
|
const art = new ArtCache();
|
|
4732
|
+
// What a channel plays once its show is over: drawn here, once, by the
|
|
4733
|
+
// same ffmpeg that carries the channels. No ffmpeg, no outro, and a show
|
|
4734
|
+
// that ends closes its channel as before.
|
|
4735
|
+
const outro = new Outro({ ffmpeg: tools.ffmpeg, dir: join(stateDir(), "outro"), onEvent: (message) => console.log(message) });
|
|
4729
4736
|
const channels = new Channels({
|
|
4730
4737
|
ffmpeg: tools.ffmpeg,
|
|
4738
|
+
...(tools.carries ? { outro: (kind) => outro.clip(kind) } : {}),
|
|
4739
|
+
onOutro: (info) => console.log(` "${info.id}" has ended; the outro plays for an hour.`),
|
|
4731
4740
|
onStart: (info) => console.log(` ${info.name} is publishing to "${info.id}" (${info.format} over ${info.via}).`),
|
|
4732
4741
|
onEnd: (info) => {
|
|
4733
4742
|
console.log(` "${info.id}" stopped.`);
|