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/src/channels.ts
CHANGED
|
@@ -95,6 +95,12 @@ export interface ChannelInfo {
|
|
|
95
95
|
* plain URL is read this way, and only when a policy asks for it.
|
|
96
96
|
*/
|
|
97
97
|
teed?: boolean;
|
|
98
|
+
/**
|
|
99
|
+
* When its show ended, wall clock, while the outro plays. A channel with
|
|
100
|
+
* this set is still on the air -- the picture says the stream has ended
|
|
101
|
+
* -- and closes on its own an hour later. Absent while the show is on.
|
|
102
|
+
*/
|
|
103
|
+
ended?: number;
|
|
98
104
|
/**
|
|
99
105
|
* A picture of it from somewhere else: the thumbnail the site offered
|
|
100
106
|
* for a pasted link, the logo a catalog gave a channel. A channel with
|
|
@@ -156,6 +162,13 @@ export const GIVE_UP = 5;
|
|
|
156
162
|
export const STALL = 30_000;
|
|
157
163
|
/** How long an on-demand channel stays up with nobody watching. */
|
|
158
164
|
export const IDLE = 60_000;
|
|
165
|
+
/** How long a channel plays its outro after the show, before it closes. */
|
|
166
|
+
export const OUTRO_MS = 60 * 60 * 1000;
|
|
167
|
+
/** The encode the outro is copied through: what a channel sends on the wire. */
|
|
168
|
+
const OUTRO_ENCODE: Record<"audio" | "video", string[]> = {
|
|
169
|
+
video: ["-c", "copy", "-f", "mp4", "-movflags", "frag_keyframe+empty_moov+default_base_moof"],
|
|
170
|
+
audio: ["-c", "copy", "-f", "mp3"],
|
|
171
|
+
};
|
|
159
172
|
/** How much of what ffmpeg said to keep, for the last line when it dies. */
|
|
160
173
|
const TAIL = 2000;
|
|
161
174
|
/**
|
|
@@ -241,6 +254,16 @@ export interface ChannelOptions {
|
|
|
241
254
|
ffmpeg: string[];
|
|
242
255
|
onStart?: (info: ChannelInfo) => void;
|
|
243
256
|
onEnd?: (info: ChannelInfo) => void;
|
|
257
|
+
/**
|
|
258
|
+
* The outro: the clip a channel plays once its show is over, by kind,
|
|
259
|
+
* encoded as the wire wants it (see outro.ts). Null, or absent, means a
|
|
260
|
+
* show that ends closes its channel as it always did.
|
|
261
|
+
*/
|
|
262
|
+
outro?: (kind: "audio" | "video") => Promise<string | null>;
|
|
263
|
+
/** How long the outro plays before the channel closes. An hour. */
|
|
264
|
+
outroMs?: number;
|
|
265
|
+
/** Said once, when a show ends and the outro begins. */
|
|
266
|
+
onOutro?: (info: ChannelInfo) => void;
|
|
244
267
|
/** How long an on-demand channel outlives its last viewer. Tests shorten it. */
|
|
245
268
|
idleMs?: number;
|
|
246
269
|
/** Unsent bytes a listener may hold before it is dropped. Tests shrink it. */
|
|
@@ -272,6 +295,11 @@ export class Channel {
|
|
|
272
295
|
/** Fires when a pulled source has said nothing for STALL. */
|
|
273
296
|
private watchdog: ReturnType<typeof setTimeout> | null = null;
|
|
274
297
|
private stall = STALL;
|
|
298
|
+
/** Whether the outro is what is playing now: the show is over. */
|
|
299
|
+
private outroOn = false;
|
|
300
|
+
private outroTimer: ReturnType<typeof setTimeout> | null = null;
|
|
301
|
+
/** How the show was dialled, so a restart after the outro is the show again. */
|
|
302
|
+
private dialed: { source: string; encode: string[]; paced: boolean; stall: number; input: string[]; audio: string; resume: PullResume } | null = null;
|
|
275
303
|
private stderr = "";
|
|
276
304
|
/**
|
|
277
305
|
* The last few seconds, for whoever joins next.
|
|
@@ -368,6 +396,7 @@ export class Channel {
|
|
|
368
396
|
resume: PullResume = { live: true, position: 0 },
|
|
369
397
|
): void {
|
|
370
398
|
this.stall = stall;
|
|
399
|
+
if (!this.outroOn) this.dialed = { source, encode, paced, stall, input: [...input], audio, resume };
|
|
371
400
|
if (this.info.kind === "video") this.fragments = new Fragments();
|
|
372
401
|
const [command, ...prefix] = this.options.ffmpeg as [string, ...string[]];
|
|
373
402
|
this.info.live = resume.live;
|
|
@@ -382,9 +411,12 @@ export class Channel {
|
|
|
382
411
|
this.info.playlistAt = at;
|
|
383
412
|
}
|
|
384
413
|
let current = list ? (list[at] as string) : source;
|
|
414
|
+
// The last entry is the end of the show, not a way back to the first:
|
|
415
|
+
// a list that played through is over, and says so with the outro.
|
|
385
416
|
this.advance = list && list.length > 1
|
|
386
417
|
? () => {
|
|
387
|
-
|
|
418
|
+
if (at + 1 >= list.length) return false;
|
|
419
|
+
at += 1;
|
|
388
420
|
this.info.playlistAt = at;
|
|
389
421
|
current = list[at] as string;
|
|
390
422
|
return true;
|
|
@@ -414,7 +446,8 @@ export class Channel {
|
|
|
414
446
|
// policy set after the channel started applies at its next restart.
|
|
415
447
|
this.throughAbort?.abort();
|
|
416
448
|
this.throughAbort = null;
|
|
417
|
-
|
|
449
|
+
// The outro is a file read by ffmpeg itself: a pipe cannot loop.
|
|
450
|
+
const through = this.outroOn ? null : this.options.through?.(this.info, from, input, audio) ?? null;
|
|
418
451
|
this.info.teed = through !== null;
|
|
419
452
|
const child = spawn(
|
|
420
453
|
command,
|
|
@@ -509,7 +542,43 @@ export class Channel {
|
|
|
509
542
|
|
|
510
543
|
this.redial = dial;
|
|
511
544
|
dial();
|
|
512
|
-
this.options.onStart?.(this.info);
|
|
545
|
+
if (!this.outroOn) this.options.onStart?.(this.info);
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* The show is over: the outro, then the end. A publisher's channel is
|
|
550
|
+
* told this when the publisher goes, so whoever joins in the next hour
|
|
551
|
+
* is shown that the stream has ended rather than nothing at all. Without
|
|
552
|
+
* an outro to play it is the same as close().
|
|
553
|
+
*/
|
|
554
|
+
finish(): void {
|
|
555
|
+
if (this.closing || this.outroOn) return;
|
|
556
|
+
void this.endShow();
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
private async endShow(): Promise<void> {
|
|
560
|
+
const kind = this.info.kind ?? "audio";
|
|
561
|
+
const old = this.child;
|
|
562
|
+
this.child = null;
|
|
563
|
+
if (this.watchdog) clearTimeout(this.watchdog);
|
|
564
|
+
this.watchdog = null;
|
|
565
|
+
if (this.timer) clearTimeout(this.timer);
|
|
566
|
+
this.timer = null;
|
|
567
|
+
old?.kill("SIGKILL");
|
|
568
|
+
const clip = this.options.outro ? await this.options.outro(kind).catch(() => null) : null;
|
|
569
|
+
if (this.closing) return;
|
|
570
|
+
if (!clip) {
|
|
571
|
+
this.close();
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
this.outroOn = true;
|
|
575
|
+
this.info.ended = Date.now();
|
|
576
|
+
this.info.error = undefined;
|
|
577
|
+
this.startOver();
|
|
578
|
+
this.options.onOutro?.(this.info);
|
|
579
|
+
this.pull(clip, OUTRO_ENCODE[kind], true, this.stall, ["-stream_loop", "-1"], "", { live: true, position: 0 });
|
|
580
|
+
this.outroTimer = setTimeout(() => this.close(), this.options.outroMs ?? OUTRO_MS);
|
|
581
|
+
this.outroTimer.unref?.();
|
|
513
582
|
}
|
|
514
583
|
|
|
515
584
|
/**
|
|
@@ -522,8 +591,26 @@ export class Channel {
|
|
|
522
591
|
* strikes a dead CDN ran up an hour ago.
|
|
523
592
|
*/
|
|
524
593
|
restart(): boolean {
|
|
594
|
+
if (this.closing) return false;
|
|
595
|
+
// Started over from the outro: the show itself, from its beginning.
|
|
596
|
+
if (this.outroOn && this.dialed) {
|
|
597
|
+
this.outroOn = false;
|
|
598
|
+
delete this.info.ended;
|
|
599
|
+
if (this.outroTimer) clearTimeout(this.outroTimer);
|
|
600
|
+
this.outroTimer = null;
|
|
601
|
+
const old = this.child;
|
|
602
|
+
this.child = null;
|
|
603
|
+
old?.kill("SIGKILL");
|
|
604
|
+
this.failures = 0;
|
|
605
|
+
this.info.error = undefined;
|
|
606
|
+
this.info.playlistAt = 0;
|
|
607
|
+
this.startOver();
|
|
608
|
+
const show = this.dialed;
|
|
609
|
+
this.pull(show.source, show.encode, show.paced, show.stall, show.input, show.audio, { ...show.resume, position: 0 });
|
|
610
|
+
return true;
|
|
611
|
+
}
|
|
525
612
|
const dial = this.redial;
|
|
526
|
-
if (!dial
|
|
613
|
+
if (!dial) return false;
|
|
527
614
|
if (this.timer) clearTimeout(this.timer);
|
|
528
615
|
this.timer = null;
|
|
529
616
|
if (this.watchdog) clearTimeout(this.watchdog);
|
|
@@ -613,6 +700,11 @@ export class Channel {
|
|
|
613
700
|
*/
|
|
614
701
|
private dropped(sent: boolean): void {
|
|
615
702
|
if (this.closing || !this.redial) return;
|
|
703
|
+
// An outro that stopped is over; there is nothing after it.
|
|
704
|
+
if (this.outroOn) {
|
|
705
|
+
this.close();
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
616
708
|
this.child = null;
|
|
617
709
|
if (this.watchdog) clearTimeout(this.watchdog);
|
|
618
710
|
this.watchdog = null;
|
|
@@ -628,6 +720,15 @@ export class Channel {
|
|
|
628
720
|
// gave nothing is skipped the same way, counted as the failure it was,
|
|
629
721
|
// so a list of dead links gives up rather than cycling for ever.
|
|
630
722
|
const moved = this.advance?.() ?? false;
|
|
723
|
+
// A show that ended: a film or a podcast that played to its end, a
|
|
724
|
+
// list whose last entry did. That is not a source that dropped, and it
|
|
725
|
+
// is not dialled again from the top; it is over, and the outro says
|
|
726
|
+
// so. A live feed that stopped is a feed that dropped, and is redialled.
|
|
727
|
+
const list = (this.info.playlist?.length ?? 0) > 0;
|
|
728
|
+
if (sent && !moved && (this.info.live === false || list)) {
|
|
729
|
+
void this.endShow();
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
631
732
|
const ended = moved && sent;
|
|
632
733
|
if (ended) this.info.error = undefined;
|
|
633
734
|
else this.info.redials = (this.info.redials ?? 0) + 1;
|
|
@@ -914,6 +1015,8 @@ export class Channel {
|
|
|
914
1015
|
this.watchdog = null;
|
|
915
1016
|
if (this.idle) clearTimeout(this.idle);
|
|
916
1017
|
this.idle = null;
|
|
1018
|
+
if (this.outroTimer) clearTimeout(this.outroTimer);
|
|
1019
|
+
this.outroTimer = null;
|
|
917
1020
|
const said = lastLine(this.stderr);
|
|
918
1021
|
if (said && !this.info.error) this.info.error = said;
|
|
919
1022
|
const child = this.child;
|
package/src/outro.ts
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
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
|
+
|
|
22
|
+
/** How long the outro plays after a show ends, before the channel closes. */
|
|
23
|
+
export const OUTRO_MS = 60 * 60 * 1000;
|
|
24
|
+
/** The clip's length; it loops. */
|
|
25
|
+
export const OUTRO_SECONDS = 5;
|
|
26
|
+
/** Bumped when the drawing changes, so an old clip on disk is drawn again. */
|
|
27
|
+
export const OUTRO_VERSION = 1;
|
|
28
|
+
|
|
29
|
+
export interface Rgba { r: number; g: number; b: number; a: number }
|
|
30
|
+
|
|
31
|
+
const BACKGROUND: Rgba = { r: 8, g: 12, b: 9, a: 255 };
|
|
32
|
+
const GREEN: Rgba = { r: 74, g: 246, b: 137, a: 255 };
|
|
33
|
+
const DIM: Rgba = { r: 34, g: 122, b: 74, a: 255 };
|
|
34
|
+
const MUTED: Rgba = { r: 120, g: 150, b: 130, a: 255 };
|
|
35
|
+
const BARS = [0.30, 0.55, 0.85, 0.62, 1.0, 0.72, 0.45, 0.25];
|
|
36
|
+
|
|
37
|
+
/** A canvas of straight RGBA bytes. */
|
|
38
|
+
export class Bitmap {
|
|
39
|
+
readonly pixels: Uint8Array;
|
|
40
|
+
constructor(readonly width: number, readonly height: number) {
|
|
41
|
+
this.pixels = new Uint8Array(width * height * 4);
|
|
42
|
+
}
|
|
43
|
+
fill(color: Rgba): void {
|
|
44
|
+
for (let i = 0; i < this.pixels.length; i += 4) this.set(i, color);
|
|
45
|
+
}
|
|
46
|
+
rect(x: number, y: number, w: number, h: number, color: Rgba): void {
|
|
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++) this.set((py * this.width + px) * 4, color);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
private set(offset: number, { r, g, b, a }: Rgba): void {
|
|
56
|
+
this.pixels[offset] = r;
|
|
57
|
+
this.pixels[offset + 1] = g;
|
|
58
|
+
this.pixels[offset + 2] = b;
|
|
59
|
+
this.pixels[offset + 3] = a;
|
|
60
|
+
}
|
|
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++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
|
|
68
|
+
table[n] = c >>> 0;
|
|
69
|
+
}
|
|
70
|
+
return table;
|
|
71
|
+
})();
|
|
72
|
+
|
|
73
|
+
function crc32(bytes: Uint8Array): number {
|
|
74
|
+
let c = 0xffffffff;
|
|
75
|
+
for (const byte of bytes) c = (CRC_TABLE[(c ^ byte) & 0xff] as number) ^ (c >>> 8);
|
|
76
|
+
return (c ^ 0xffffffff) >>> 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function chunk(type: string, data: Uint8Array): Buffer {
|
|
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
|
+
|
|
89
|
+
/** Encode as a non-interlaced 8-bit RGBA PNG. */
|
|
90
|
+
export function encodePng(bitmap: Bitmap): Buffer {
|
|
91
|
+
const { width, height, pixels } = bitmap;
|
|
92
|
+
const stride = width * 4;
|
|
93
|
+
const raw = Buffer.alloc((stride + 1) * height);
|
|
94
|
+
for (let y = 0; y < height; y++) {
|
|
95
|
+
raw[y * (stride + 1)] = 0;
|
|
96
|
+
Buffer.from(pixels.subarray(y * stride, (y + 1) * stride)).copy(raw, y * (stride + 1) + 1);
|
|
97
|
+
}
|
|
98
|
+
const ihdr = Buffer.alloc(13);
|
|
99
|
+
ihdr.writeUInt32BE(width, 0);
|
|
100
|
+
ihdr.writeUInt32BE(height, 4);
|
|
101
|
+
ihdr[8] = 8;
|
|
102
|
+
ihdr[9] = 6;
|
|
103
|
+
return Buffer.concat([
|
|
104
|
+
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
|
|
105
|
+
chunk("IHDR", ihdr),
|
|
106
|
+
chunk("IDAT", deflateSync(raw, { level: 9 })),
|
|
107
|
+
chunk("IEND", new Uint8Array(0)),
|
|
108
|
+
]);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** A 5x7 pixel face: the letters the outro needs and nothing else. */
|
|
112
|
+
const GLYPHS: Record<string, string[]> = {
|
|
113
|
+
A: [".XXX.", "X...X", "X...X", "XXXXX", "X...X", "X...X", "X...X"],
|
|
114
|
+
C: [".XXXX", "X....", "X....", "X....", "X....", "X....", ".XXXX"],
|
|
115
|
+
D: ["XXXX.", "X...X", "X...X", "X...X", "X...X", "X...X", "XXXX."],
|
|
116
|
+
E: ["XXXXX", "X....", "X....", "XXXX.", "X....", "X....", "XXXXX"],
|
|
117
|
+
H: ["X...X", "X...X", "X...X", "XXXXX", "X...X", "X...X", "X...X"],
|
|
118
|
+
I: ["XXXXX", "..X..", "..X..", "..X..", "..X..", "..X..", "XXXXX"],
|
|
119
|
+
L: ["X....", "X....", "X....", "X....", "X....", "X....", "XXXXX"],
|
|
120
|
+
M: ["X...X", "XX.XX", "X.X.X", "X.X.X", "X...X", "X...X", "X...X"],
|
|
121
|
+
N: ["X...X", "XX..X", "X.X.X", "X..XX", "X...X", "X...X", "X...X"],
|
|
122
|
+
O: [".XXX.", "X...X", "X...X", "X...X", "X...X", "X...X", ".XXX."],
|
|
123
|
+
P: ["XXXX.", "X...X", "X...X", "XXXX.", "X....", "X....", "X...."],
|
|
124
|
+
R: ["XXXX.", "X...X", "X...X", "XXXX.", "X.X..", "X..X.", "X...X"],
|
|
125
|
+
S: [".XXXX", "X....", "X....", ".XXX.", "....X", "....X", "XXXX."],
|
|
126
|
+
T: ["XXXXX", "..X..", "..X..", "..X..", "..X..", "..X..", "..X.."],
|
|
127
|
+
V: ["X...X", "X...X", "X...X", "X...X", "X...X", ".X.X.", "..X.."],
|
|
128
|
+
X: ["X...X", "X...X", ".X.X.", "..X..", ".X.X.", "X...X", "X...X"],
|
|
129
|
+
".": [".....", ".....", ".....", ".....", ".....", "..XX.", "..XX."],
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export function textWidth(text: string, cell: number): number {
|
|
133
|
+
return text.length * 6 * cell - cell;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function drawText(bitmap: Bitmap, text: string, x: number, y: number, cell: number, color: Rgba): void {
|
|
137
|
+
let at = x;
|
|
138
|
+
for (const letter of text) {
|
|
139
|
+
const rows = GLYPHS[letter];
|
|
140
|
+
if (rows) {
|
|
141
|
+
rows.forEach((row, r) => {
|
|
142
|
+
for (let c = 0; c < row.length; c++) if (row[c] === "X") bitmap.rect(at + c * cell, y + r * cell, cell, cell, color);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
at += 6 * cell;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function drawBars(bitmap: Bitmap, left: number, bottom: number, width: number, tallest: number, scale: number): void {
|
|
150
|
+
const slot = width / BARS.length;
|
|
151
|
+
const bar = slot * 0.62;
|
|
152
|
+
BARS.forEach((value, i) => {
|
|
153
|
+
const height = Math.max(2, tallest * value);
|
|
154
|
+
const x = left + i * slot + (slot - bar) / 2;
|
|
155
|
+
bitmap.rect(x, bottom - height, bar, height, GREEN);
|
|
156
|
+
const peak = Math.max(2, scale * 0.018);
|
|
157
|
+
bitmap.rect(x, bottom - height - peak * 2.4, bar, peak, DIM);
|
|
158
|
+
});
|
|
159
|
+
bitmap.rect(left, bottom, width, Math.max(2, scale * 0.028), DIM);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The picture: the mark above the words, on the plate. 1280x720. */
|
|
163
|
+
export function drawOutro(width = 1280, height = 720): Bitmap {
|
|
164
|
+
const bitmap = new Bitmap(width, height);
|
|
165
|
+
bitmap.fill(BACKGROUND);
|
|
166
|
+
const mark = height * 0.3;
|
|
167
|
+
drawBars(bitmap, (width - mark) / 2, height * 0.42, mark, mark - height * 0.02, mark * 1.8);
|
|
168
|
+
const line = "THIS LIVE STREAM HAS ENDED";
|
|
169
|
+
const cell = Math.floor(Math.min((width * 0.86) / (line.length * 6), height / 40));
|
|
170
|
+
drawText(bitmap, line, (width - textWidth(line, cell)) / 2, height * 0.56, cell, GREEN);
|
|
171
|
+
const small = Math.max(2, Math.floor(cell * 0.6));
|
|
172
|
+
const site = "NIXAMP.COM";
|
|
173
|
+
drawText(bitmap, site, (width - textWidth(site, small)) / 2, height * 0.56 + cell * 7 + small * 3, small, MUTED);
|
|
174
|
+
return bitmap;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** The encode a channel copies an outro through: what a channel sends on the wire. */
|
|
178
|
+
export const OUTRO_ENCODE: Record<"audio" | "video", string[]> = {
|
|
179
|
+
video: ["-c", "copy", "-f", "mp4", "-movflags", "frag_keyframe+empty_moov+default_base_moof"],
|
|
180
|
+
audio: ["-c", "copy", "-f", "mp3"],
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
export interface OutroOptions {
|
|
184
|
+
ffmpeg: string[];
|
|
185
|
+
/** Where the clips are kept. */
|
|
186
|
+
dir: string;
|
|
187
|
+
onEvent?: (message: string) => void;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The clips, drawn on first use and kept. `clip(kind)` answers the path,
|
|
192
|
+
* or null when there is no ffmpeg to make one, in which case a channel
|
|
193
|
+
* that ends simply closes as it used to.
|
|
194
|
+
*/
|
|
195
|
+
export class Outro {
|
|
196
|
+
private made = new Map<string, Promise<string | null>>();
|
|
197
|
+
|
|
198
|
+
constructor(private readonly options: OutroOptions) {}
|
|
199
|
+
|
|
200
|
+
clip(kind: "audio" | "video"): Promise<string | null> {
|
|
201
|
+
let pending = this.made.get(kind);
|
|
202
|
+
if (!pending) {
|
|
203
|
+
pending = Promise.resolve().then(() => this.make(kind)).catch(() => null);
|
|
204
|
+
this.made.set(kind, pending);
|
|
205
|
+
// A clip that could not be made is tried again next time, not remembered.
|
|
206
|
+
void pending.then((path) => { if (path === null) this.made.delete(kind); });
|
|
207
|
+
}
|
|
208
|
+
return pending;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
private make(kind: "audio" | "video"): string | null {
|
|
212
|
+
mkdirSync(this.options.dir, { recursive: true });
|
|
213
|
+
const path = join(this.options.dir, kind === "video" ? `outro-v${OUTRO_VERSION}.mp4` : `outro-v${OUTRO_VERSION}.mp3`);
|
|
214
|
+
if (existsSync(path) && statSync(path).size > 1000) return path;
|
|
215
|
+
const [command, ...prefix] = this.options.ffmpeg;
|
|
216
|
+
if (!command) return null;
|
|
217
|
+
const args: string[] = ["-hide_banner", "-loglevel", "error", "-y"];
|
|
218
|
+
if (kind === "video") {
|
|
219
|
+
const picture = join(this.options.dir, `outro-v${OUTRO_VERSION}.png`);
|
|
220
|
+
writeFileSync(picture, encodePng(drawOutro()));
|
|
221
|
+
args.push(
|
|
222
|
+
"-loop", "1", "-framerate", "30", "-i", picture,
|
|
223
|
+
"-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo",
|
|
224
|
+
"-t", String(OUTRO_SECONDS),
|
|
225
|
+
"-c:v", "libx264", "-preset", "veryfast", "-tune", "stillimage", "-pix_fmt", "yuv420p", "-r", "30", "-g", "30", "-keyint_min", "30",
|
|
226
|
+
"-c:a", "aac", "-b:a", "64k", "-shortest", "-movflags", "+faststart",
|
|
227
|
+
path,
|
|
228
|
+
);
|
|
229
|
+
} else {
|
|
230
|
+
// Two soft notes, then quiet: something to hear on a channel with no picture.
|
|
231
|
+
args.push(
|
|
232
|
+
"-f", "lavfi", "-i",
|
|
233
|
+
`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}`,
|
|
234
|
+
"-c:a", "libmp3lame", "-b:a", "128k", path,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
const run = spawnSync(command, [...prefix, ...args], { timeout: 60_000 });
|
|
238
|
+
if (run.error || run.status !== 0 || !existsSync(path)) {
|
|
239
|
+
this.options.onEvent?.(` the outro could not be made${run.stderr ? `: ${run.stderr.toString("utf8").trim().split("\n").pop() ?? ""}` : ""}`);
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
this.options.onEvent?.(` outro drawn: ${path}`);
|
|
243
|
+
return path;
|
|
244
|
+
}
|
|
245
|
+
}
|
package/src/rtmp-in.ts
CHANGED
|
@@ -79,10 +79,13 @@ export class RtmpListeners {
|
|
|
79
79
|
child.on("close", () => this.done(slot, child, channel));
|
|
80
80
|
}
|
|
81
81
|
|
|
82
|
-
private done(slot: RtmpSlot, child: ChildProcess, channel: { close(): void } | null): void {
|
|
82
|
+
private done(slot: RtmpSlot, child: ChildProcess, channel: { close(): void; finish?(): void } | null): void {
|
|
83
83
|
if (this.running.get(slot.port) !== child) return;
|
|
84
84
|
this.running.delete(slot.port);
|
|
85
|
-
|
|
85
|
+
// The publisher went: the outro plays for whoever is still there, and
|
|
86
|
+
// whoever joins in the next hour, before the channel closes.
|
|
87
|
+
if (channel?.finish) channel.finish();
|
|
88
|
+
else channel?.close();
|
|
86
89
|
if (!this.stopped) this.arm(slot);
|
|
87
90
|
}
|
|
88
91
|
|
package/src/server.ts
CHANGED
|
@@ -88,6 +88,7 @@ import { Rooms } from "./rooms.ts";
|
|
|
88
88
|
import { Trollbox, TrollboxError, fallbackHandle, roomFor } from "./trollbox.ts";
|
|
89
89
|
import { MAX_BYTES as SPEECH_BYTES, Speech, SpeechError, isWav, languageOf } from "./speech.ts";
|
|
90
90
|
import { Captions } from "./captions.ts";
|
|
91
|
+
import { Outro } from "./outro.ts";
|
|
91
92
|
import { Profiles, Voices, spokenLine, spokenVoiceFor } from "./voices.ts";
|
|
92
93
|
import { confirm, DEFAULT_DIRECTORY, Publisher } from "./publish.ts";
|
|
93
94
|
import {
|
|
@@ -3394,6 +3395,8 @@ export function createHandler(engine: Engine, options: HandlerOptions) {
|
|
|
3394
3395
|
// A picture of it, and a line about it, where there is one.
|
|
3395
3396
|
art: artOf(one),
|
|
3396
3397
|
about: one.about ?? "",
|
|
3398
|
+
// Over, with the outro playing: the page says so rather than "LIVE".
|
|
3399
|
+
...(one.ended ? { ended: one.ended } : {}),
|
|
3397
3400
|
})),
|
|
3398
3401
|
// Anything re-streamed into this server is a live stream too, and was
|
|
3399
3402
|
// sitting in the middle of the playlist among the files -- which is
|
|
@@ -5354,8 +5357,14 @@ export async function serve(argv: string[], version = "0.1.0"): Promise<void> {
|
|
|
5354
5357
|
|
|
5355
5358
|
// The pictures of channels, taken once each and kept while the channel is on.
|
|
5356
5359
|
const art = new ArtCache();
|
|
5360
|
+
// What a channel plays once its show is over: drawn here, once, by the
|
|
5361
|
+
// same ffmpeg that carries the channels. No ffmpeg, no outro, and a show
|
|
5362
|
+
// that ends closes its channel as before.
|
|
5363
|
+
const outro = new Outro({ ffmpeg: tools.ffmpeg, dir: join(stateDir(), "outro"), onEvent: (message) => console.log(message) });
|
|
5357
5364
|
const channels = new Channels({
|
|
5358
5365
|
ffmpeg: tools.ffmpeg,
|
|
5366
|
+
...(tools.carries ? { outro: (kind) => outro.clip(kind) } : {}),
|
|
5367
|
+
onOutro: (info) => console.log(` "${info.id}" has ended; the outro plays for an hour.`),
|
|
5359
5368
|
onStart: (info) =>
|
|
5360
5369
|
console.log(` ${info.name} is publishing to "${info.id}" (${info.format} over ${info.via}).`),
|
|
5361
5370
|
onEnd: (info) => {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{t as e}from"./index-
|
|
1
|
+
import{t as e}from"./index-5H3sUGHu.js";var t=3;async function n(n){let{media:r,src:i,isTv:a}=n,{default:o}=await e(async()=>{let{default:e}=await import(`./hls-n74Cnh8A.js`);return{default:e}},[]);if(!o.isSupported())return n.onError(`This browser cannot play HLS streams.`),{destroy:()=>void 0,levels:()=>[]};let s=new o({...a?{maxBufferLength:60,maxMaxBufferLength:120,backBufferLength:30,liveSyncDurationCount:4}:{backBufferLength:90},enableWorker:!0}),c=0,l=!1;s.on(o.Events.ERROR,(e,r)=>{if(!l&&r.fatal){if(c>=t){n.onError(`This stream kept failing and has been stopped.`),s.destroy();return}switch(c+=1,r.type){case o.ErrorTypes.NETWORK_ERROR:n.onNotice(`Reconnecting…`),s.startLoad();break;case o.ErrorTypes.MEDIA_ERROR:n.onNotice(`Recovering…`),s.recoverMediaError();break;default:n.onError(`This stream could not be played.`),s.destroy()}}}),s.on(o.Events.MANIFEST_PARSED,()=>{l||(n.onNotice(null),n.onReady?.({live:s.levels.length>0&&!Number.isFinite(r.duration),levels:u()}))}),s.on(o.Events.LEVEL_LOADED,(e,t)=>{l||n.onReady?.({live:t.details.live,levels:u()})}),s.on(o.Events.FRAG_BUFFERED,()=>{l||n.onNotice(null)});function u(){return s.levels.map((e,t)=>({index:t,height:e.height||null,bitrate:e.bitrate||null,label:e.height?`${String(e.height)}p`:`${String(Math.round((e.bitrate||0)/1e3))}k`}))}return s.loadSource(i),s.attachMedia(r),{destroy(){l=!0,s.destroy()},levels:u,setLevel(e){s.currentLevel=e},currentLevel:()=>s.autoLevelEnabled?-1:s.currentLevel}}export{n as createHlsEngine};
|