nixamp 0.16.0 → 0.17.1
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 +139 -0
- package/dist/audio.d.ts +70 -1
- package/dist/audio.js +84 -5
- package/dist/channels.d.ts +107 -3
- package/dist/channels.js +151 -3
- package/dist/compression/analyze.d.ts +105 -0
- package/dist/compression/analyze.js +213 -0
- package/dist/compression/blocks.d.ts +35 -0
- package/dist/compression/blocks.js +73 -0
- package/dist/compression/cli.d.ts +1 -0
- package/dist/compression/cli.js +347 -0
- package/dist/compression/codec.d.ts +66 -0
- package/dist/compression/codec.js +178 -0
- package/dist/compression/envelope.d.ts +77 -0
- package/dist/compression/envelope.js +191 -0
- package/dist/compression/jobs.d.ts +59 -0
- package/dist/compression/jobs.js +120 -0
- package/dist/compression/metrics.d.ts +62 -0
- package/dist/compression/metrics.js +64 -0
- package/dist/compression/policy.d.ts +76 -0
- package/dist/compression/policy.js +148 -0
- package/dist/compression/receiver.d.ts +51 -0
- package/dist/compression/receiver.js +92 -0
- package/dist/compression/relay.d.ts +134 -0
- package/dist/compression/relay.js +430 -0
- package/dist/compression/routes.d.ts +25 -0
- package/dist/compression/routes.js +266 -0
- package/dist/compression/service.d.ts +162 -0
- package/dist/compression/service.js +488 -0
- package/dist/compression/static.d.ts +65 -0
- package/dist/compression/static.js +248 -0
- package/dist/compression/store.d.ts +36 -0
- package/dist/compression/store.js +119 -0
- package/dist/compression/ts-transform.d.ts +44 -0
- package/dist/compression/ts-transform.js +148 -0
- package/dist/hls.d.ts +36 -3
- package/dist/hls.js +85 -11
- package/dist/layouts.d.ts +10 -0
- package/dist/layouts.js +71 -1
- package/dist/live-api.d.ts +16 -2
- package/dist/live-api.js +153 -19
- package/dist/live-events.d.ts +57 -2
- package/dist/live-events.js +252 -12
- package/dist/main.js +47 -0
- package/dist/mcp.d.ts +40 -0
- package/dist/mcp.js +255 -0
- package/dist/oauth-api.d.ts +50 -0
- package/dist/oauth-api.js +381 -0
- package/dist/oauth-server.d.ts +174 -0
- package/dist/oauth-server.js +559 -0
- package/dist/party.d.ts +32 -0
- package/dist/party.js +196 -0
- package/dist/playlist.d.ts +11 -0
- package/dist/playlist.js +27 -6
- package/dist/server.d.ts +37 -0
- package/dist/server.js +277 -45
- package/dist/sources.d.ts +16 -8
- package/dist/sources.js +102 -0
- package/dist/tickets.d.ts +67 -0
- package/dist/tickets.js +156 -0
- package/dist/tokens.d.ts +27 -2
- package/dist/tokens.js +42 -1
- package/dist/watch-party.d.ts +127 -0
- package/dist/watch-party.js +301 -0
- package/package.json +1 -1
- package/src/audio.ts +119 -6
- package/src/channels.ts +177 -7
- package/src/compression/analyze.ts +270 -0
- package/src/compression/blocks.ts +93 -0
- package/src/compression/cli.ts +340 -0
- package/src/compression/codec.ts +202 -0
- package/src/compression/envelope.ts +237 -0
- package/src/compression/jobs.ts +157 -0
- package/src/compression/metrics.ts +109 -0
- package/src/compression/policy.ts +171 -0
- package/src/compression/receiver.ts +107 -0
- package/src/compression/relay.ts +462 -0
- package/src/compression/routes.ts +294 -0
- package/src/compression/service.ts +525 -0
- package/src/compression/static.ts +264 -0
- package/src/compression/store.ts +126 -0
- package/src/compression/ts-transform.ts +148 -0
- package/src/hls.ts +100 -10
- package/src/layouts.ts +78 -1
- package/src/live-api.ts +173 -17
- package/src/live-events.ts +281 -12
- package/src/main.ts +47 -0
- package/src/mcp.ts +282 -0
- package/src/oauth-api.ts +481 -0
- package/src/oauth-server.ts +653 -0
- package/src/party.ts +216 -0
- package/src/playlist.ts +28 -5
- package/src/server.ts +296 -44
- package/src/sources.ts +100 -0
- package/src/tickets.ts +195 -0
- package/src/tokens.ts +50 -3
- package/src/watch-party.ts +390 -0
- package/web/dist/assets/{hls-3VKVEQE3-CrILISPJ.js → hls-3VKVEQE3-eV54kXE3.js} +1 -1
- package/web/dist/assets/index-CurZFzlH.css +1 -0
- package/web/dist/assets/index-DDzutJ75.js +1 -0
- package/web/dist/assets/{mpegts-CWeN63eG.js → mpegts-Buc3Odv6.js} +1 -1
- package/web/dist/assets/{mpegts-LO6RVLD6-DaMgRvlO.js → mpegts-LO6RVLD6-DcDKPB4P.js} +1 -1
- package/web/dist/index.html +59 -6
- package/web/dist/sw.js +6 -6
- package/web/dist/assets/index-CQ_m5HqS.css +0 -1
- package/web/dist/assets/index-CTxPM5KS.js +0 -1
package/dist/channels.js
CHANGED
|
@@ -49,6 +49,38 @@ const TAIL = 2000;
|
|
|
49
49
|
*/
|
|
50
50
|
export const BACKLOG_VIDEO = 4 * 1024 * 1024;
|
|
51
51
|
export const BACKLOG_AUDIO = 64 * 1024;
|
|
52
|
+
/**
|
|
53
|
+
* How far behind a listener may fall before it is let go. Sixteen
|
|
54
|
+
* megabytes is half a minute of 720p television that a socket has accepted
|
|
55
|
+
* and not delivered: nobody is watching that, and every byte of it was
|
|
56
|
+
* sitting in this process. Before this a stalled listener's buffer grew
|
|
57
|
+
* until the channel ended, however long that was.
|
|
58
|
+
*/
|
|
59
|
+
export const LISTENER_QUEUE = 16 * 1024 * 1024;
|
|
60
|
+
/**
|
|
61
|
+
* The backlog is really a number of seconds, and four megabytes was that
|
|
62
|
+
* number for the stream we happened to have.
|
|
63
|
+
*
|
|
64
|
+
* Six seconds of 720p is about 4 MB. Six seconds of a 1080p transport stream
|
|
65
|
+
* copied straight through is nearer 12, and of 4K nearer 30 -- so a fixed
|
|
66
|
+
* 4 MB hands a 4K joiner under a second of video, which is the live edge with
|
|
67
|
+
* no cushion, which is the play-wait-play loop the backlog exists to prevent.
|
|
68
|
+
* So the cap follows the stream: seconds times the rate it is actually
|
|
69
|
+
* running at, between the old floor and a ceiling that keeps a channel's
|
|
70
|
+
* memory bounded whatever it is carrying.
|
|
71
|
+
*/
|
|
72
|
+
export const BACKLOG_SECONDS = 6;
|
|
73
|
+
export const BACKLOG_VIDEO_MAX = 48 * 1024 * 1024;
|
|
74
|
+
/**
|
|
75
|
+
* How long a rate is measured over before it is believed.
|
|
76
|
+
*
|
|
77
|
+
* The first seconds of a pull are not a bitrate: ffmpeg opens the source,
|
|
78
|
+
* reads ahead, and empties what it has as fast as the pipe takes it. Sizing a
|
|
79
|
+
* buffer off that burst would reserve tens of megabytes for a stream that
|
|
80
|
+
* turns out to be a podcast. A window is measured, and until one has closed
|
|
81
|
+
* the floor stands.
|
|
82
|
+
*/
|
|
83
|
+
export const RATE_WINDOW_MS = 5000;
|
|
52
84
|
/** The four-letter name in a box header, or "" for something too short. */
|
|
53
85
|
function boxType(box) {
|
|
54
86
|
return box.length >= 8 ? box.toString("latin1", 4, 8) : "";
|
|
@@ -120,6 +152,10 @@ export class Channel {
|
|
|
120
152
|
*/
|
|
121
153
|
recent = [];
|
|
122
154
|
recentBytes = 0;
|
|
155
|
+
/** The rate window: when it opened, what has arrived in it, and what the last closed one measured. */
|
|
156
|
+
rateStart = 0;
|
|
157
|
+
rateBytes = 0;
|
|
158
|
+
rate = 0;
|
|
123
159
|
/**
|
|
124
160
|
* Started for whoever asked and stopped when nobody is left. A catalog
|
|
125
161
|
* channel is one of thousands; keeping every one that was ever clicked
|
|
@@ -321,6 +357,11 @@ export class Channel {
|
|
|
321
357
|
this.fragments = new Fragments();
|
|
322
358
|
this.recent = [];
|
|
323
359
|
this.recentBytes = 0;
|
|
360
|
+
// A new source may be a different size of stream, and the rate measured
|
|
361
|
+
// off the old one is not evidence about this one.
|
|
362
|
+
this.rateStart = 0;
|
|
363
|
+
this.rateBytes = 0;
|
|
364
|
+
this.rate = 0;
|
|
324
365
|
this.hangUp();
|
|
325
366
|
}
|
|
326
367
|
/** Expect output within STALL, or treat the source as gone and dial again. */
|
|
@@ -390,17 +431,44 @@ export class Channel {
|
|
|
390
431
|
* make sense.
|
|
391
432
|
*/
|
|
392
433
|
emit(chunk) {
|
|
434
|
+
this.measure(chunk.byteLength);
|
|
393
435
|
if (!this.fragments) {
|
|
394
436
|
this.remember(chunk, BACKLOG_AUDIO, false);
|
|
395
437
|
this.send(chunk);
|
|
396
438
|
return;
|
|
397
439
|
}
|
|
440
|
+
const cap = this.backlogCap();
|
|
398
441
|
for (const box of this.fragments.push(chunk)) {
|
|
399
442
|
if (!isOpening(boxType(box)))
|
|
400
|
-
this.remember(box,
|
|
443
|
+
this.remember(box, cap, true);
|
|
401
444
|
this.send(box);
|
|
402
445
|
}
|
|
403
446
|
}
|
|
447
|
+
/** Watch how fast this channel is actually running, a window at a time. */
|
|
448
|
+
measure(bytes) {
|
|
449
|
+
const now = Date.now();
|
|
450
|
+
if (this.rateStart === 0)
|
|
451
|
+
this.rateStart = now;
|
|
452
|
+
this.rateBytes += bytes;
|
|
453
|
+
const elapsed = now - this.rateStart;
|
|
454
|
+
if (elapsed < (this.options.rateWindowMs ?? RATE_WINDOW_MS))
|
|
455
|
+
return;
|
|
456
|
+
this.rate = (this.rateBytes * 1000) / elapsed;
|
|
457
|
+
this.rateStart = now;
|
|
458
|
+
this.rateBytes = 0;
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Six seconds of whatever this channel turned out to be, within bounds.
|
|
462
|
+
*
|
|
463
|
+
* Unmeasured -- the first window of a pull, or a channel that has only just
|
|
464
|
+
* started -- means the floor, which is what every channel had before.
|
|
465
|
+
*/
|
|
466
|
+
backlogCap() {
|
|
467
|
+
if (this.rate <= 0)
|
|
468
|
+
return BACKLOG_VIDEO;
|
|
469
|
+
const wanted = this.rate * BACKLOG_SECONDS;
|
|
470
|
+
return Math.min(BACKLOG_VIDEO_MAX, Math.max(BACKLOG_VIDEO, Math.round(wanted)));
|
|
471
|
+
}
|
|
404
472
|
/** Keep this for the next arrival, and let the oldest go once it is too much. */
|
|
405
473
|
remember(piece, cap, aligned) {
|
|
406
474
|
this.recent.push(piece);
|
|
@@ -440,11 +508,22 @@ export class Channel {
|
|
|
440
508
|
this.info.bytes += chunk.byteLength;
|
|
441
509
|
this.send(chunk);
|
|
442
510
|
}
|
|
443
|
-
/**
|
|
511
|
+
/**
|
|
512
|
+
* Write to everyone, and drop anybody whose socket has gone -- or has
|
|
513
|
+
* stopped taking anything. A write that returns false is ordinary: the
|
|
514
|
+
* socket is a little behind and will catch up. One that returns false
|
|
515
|
+
* with a queue past the limit is a listener that is not reading, and
|
|
516
|
+
* ending it is the only thing that stops its queue growing.
|
|
517
|
+
*/
|
|
444
518
|
send(chunk) {
|
|
519
|
+
const cap = this.options.maxListenerQueueBytes ?? LISTENER_QUEUE;
|
|
445
520
|
for (const listener of this.listeners) {
|
|
446
521
|
try {
|
|
447
|
-
listener.write(chunk);
|
|
522
|
+
const drained = listener.write(chunk);
|
|
523
|
+
if (!drained && (listener.pending?.() ?? 0) > cap) {
|
|
524
|
+
this.listeners.delete(listener);
|
|
525
|
+
listener.end();
|
|
526
|
+
}
|
|
448
527
|
}
|
|
449
528
|
catch {
|
|
450
529
|
// One listener's broken socket is not the channel's problem.
|
|
@@ -453,6 +532,46 @@ export class Channel {
|
|
|
453
532
|
}
|
|
454
533
|
this.info.listeners = this.listeners.size;
|
|
455
534
|
}
|
|
535
|
+
/**
|
|
536
|
+
* What a new listener is written before the live bytes: the opening
|
|
537
|
+
* boxes when there are any, then the recent backlog. The same rule as
|
|
538
|
+
* `listen`, handed out so a relay can compress it for one receiver.
|
|
539
|
+
*/
|
|
540
|
+
opening() {
|
|
541
|
+
const out = [];
|
|
542
|
+
if (this.fragments?.ready)
|
|
543
|
+
out.push(this.fragments.header);
|
|
544
|
+
if (!this.fragments || this.fragments.ready)
|
|
545
|
+
out.push(...this.recent);
|
|
546
|
+
return out;
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* Bytes decoded from another nixamp's relay: the channel's own output as
|
|
550
|
+
* it was there, so they go out here exactly as ffmpeg's would, whole
|
|
551
|
+
* boxes at a time with the backlog kept.
|
|
552
|
+
*/
|
|
553
|
+
receive(chunk) {
|
|
554
|
+
if (this.closing)
|
|
555
|
+
return;
|
|
556
|
+
this.info.bytes += chunk.byteLength;
|
|
557
|
+
this.emit(chunk);
|
|
558
|
+
}
|
|
559
|
+
/** Ready a channel that will be fed by `receive`: pictures need their boxes tracked. */
|
|
560
|
+
prepare() {
|
|
561
|
+
if (this.info.kind === "video")
|
|
562
|
+
this.fragments = new Fragments();
|
|
563
|
+
}
|
|
564
|
+
/**
|
|
565
|
+
* The feed behind `receive` started over: a new generation upstream, with
|
|
566
|
+
* new opening boxes. Everybody listening is ended, as they are when our
|
|
567
|
+
* own ffmpeg is dialled again, and a newcomer gets the new beginning.
|
|
568
|
+
*/
|
|
569
|
+
rollover() {
|
|
570
|
+
if (this.closing)
|
|
571
|
+
return;
|
|
572
|
+
this.info.redials = (this.info.redials ?? 0) + 1;
|
|
573
|
+
this.startOver();
|
|
574
|
+
}
|
|
456
575
|
listen(listener) {
|
|
457
576
|
// What the stream is, before any of what it is currently saying. Without
|
|
458
577
|
// this a listener who arrives after the first second gets fragments that
|
|
@@ -704,6 +823,28 @@ export class Channels {
|
|
|
704
823
|
this.open.set(id, channel);
|
|
705
824
|
return channel;
|
|
706
825
|
}
|
|
826
|
+
/**
|
|
827
|
+
* A channel carried in from another nixamp's relay. Like `attach`, no
|
|
828
|
+
* ffmpeg of our own; unlike it, the kind is known up front, so a picture
|
|
829
|
+
* gets its fragment tracking and a newcomer gets the opening boxes.
|
|
830
|
+
*/
|
|
831
|
+
relayIn(id, name, kind, source) {
|
|
832
|
+
if (this.open.has(id))
|
|
833
|
+
return null;
|
|
834
|
+
const channel = new Channel({ id, name: name || id, format: kind === "video" ? "mp4" : "mp3", via: "relay", startedAt: Date.now(), bytes: 0, listeners: 0, kind, source, live: true }, this.options, (gone) => this.open.delete(gone));
|
|
835
|
+
channel.prepare();
|
|
836
|
+
this.open.set(id, channel);
|
|
837
|
+
this.options.onStart?.(channel.info);
|
|
838
|
+
return channel;
|
|
839
|
+
}
|
|
840
|
+
/** What a new listener would be written first, for a relay's preface. */
|
|
841
|
+
opening(id) {
|
|
842
|
+
return this.open.get(id)?.opening() ?? [];
|
|
843
|
+
}
|
|
844
|
+
/** The kind of a channel, for a relay to say what it is carrying. */
|
|
845
|
+
kindOf(id) {
|
|
846
|
+
return this.open.get(id)?.info.kind;
|
|
847
|
+
}
|
|
707
848
|
stop(id) {
|
|
708
849
|
const channel = this.open.get(id);
|
|
709
850
|
if (!channel)
|
|
@@ -739,6 +880,13 @@ export function rememberedChannels(dir, port) {
|
|
|
739
880
|
kept.codecs = { video: c["video"], audio: c["audio"], container: c["container"] };
|
|
740
881
|
if (typeof c["duration"] === "number" && Number.isFinite(c["duration"]))
|
|
741
882
|
kept.codecs.duration = c["duration"];
|
|
883
|
+
// The size of the picture decides whether a re-encode has to come
|
|
884
|
+
// down to 1080p; forgetting it across a restart is how a 4K
|
|
885
|
+
// channel comes back at a size that cannot keep up.
|
|
886
|
+
if (typeof c["width"] === "number" && Number.isFinite(c["width"]))
|
|
887
|
+
kept.codecs.width = c["width"];
|
|
888
|
+
if (typeof c["height"] === "number" && Number.isFinite(c["height"]))
|
|
889
|
+
kept.codecs.height = c["height"];
|
|
742
890
|
}
|
|
743
891
|
}
|
|
744
892
|
if (typeof one["position"] === "number" && Number.isFinite(one["position"]) && one["position"] > 0)
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { toolVersions } from "./codec.ts";
|
|
2
|
+
import { type Boundary, type Mode } from "./envelope.ts";
|
|
3
|
+
import { type LosslessPolicy } from "./policy.ts";
|
|
4
|
+
/** Sample limits: bytes, and seconds of a live channel. */
|
|
5
|
+
export declare const SAMPLE_MAX_BYTES: number;
|
|
6
|
+
export declare const SAMPLE_MAX_SECONDS = 30;
|
|
7
|
+
export type Container = "mpegts" | "fmp4" | "mp4" | "mp3" | "webm" | "unknown";
|
|
8
|
+
/** Which container the first bytes say they are. */
|
|
9
|
+
export declare function sniffContainer(bytes: Uint8Array): Container;
|
|
10
|
+
export interface TsReport {
|
|
11
|
+
packetSize: 188 | 192 | 204;
|
|
12
|
+
offset: number;
|
|
13
|
+
packets: number;
|
|
14
|
+
nullPackets: number;
|
|
15
|
+
/** Of all packets. Padding a compressor removes for free, and that a copy must keep. */
|
|
16
|
+
nullShare: number;
|
|
17
|
+
scrambled: number;
|
|
18
|
+
transportErrors: number;
|
|
19
|
+
adaptationOnly: number;
|
|
20
|
+
/** Bytes not inside an aligned packet: a ragged head or tail. Kept, always. */
|
|
21
|
+
unsynced: number;
|
|
22
|
+
pids: {
|
|
23
|
+
pid: number;
|
|
24
|
+
packets: number;
|
|
25
|
+
}[];
|
|
26
|
+
/** What the PCR clock says the whole stream runs at, when there is a PCR to read. */
|
|
27
|
+
pcrBitrateKbps?: number;
|
|
28
|
+
}
|
|
29
|
+
/** The transport-stream report, or null for something that is not one. */
|
|
30
|
+
export declare function analyzeTs(bytes: Uint8Array): TsReport | null;
|
|
31
|
+
export interface BenchRow {
|
|
32
|
+
mode: Mode;
|
|
33
|
+
level: number;
|
|
34
|
+
blocks: number;
|
|
35
|
+
inputBytes: number;
|
|
36
|
+
/** Payload bytes after the eligibility rule: stored where compression did not pay. */
|
|
37
|
+
payloadBytes: number;
|
|
38
|
+
/** The complete representation: stream header, every frame header, every payload. */
|
|
39
|
+
wireBytes: number;
|
|
40
|
+
storedBlocks: number;
|
|
41
|
+
compressedBlocks: number;
|
|
42
|
+
/** Against the unwrapped original, headers included. Negative means it grew. */
|
|
43
|
+
savingsPercent: number;
|
|
44
|
+
encodeMs: number;
|
|
45
|
+
decodeMs: number;
|
|
46
|
+
roundTrip: boolean;
|
|
47
|
+
/** Set when a mode could not be applied at all, e.g. ts-zstd on an MP4. */
|
|
48
|
+
note?: string;
|
|
49
|
+
}
|
|
50
|
+
export interface BenchOptions {
|
|
51
|
+
blockBytes?: number;
|
|
52
|
+
zstdLevels?: number[];
|
|
53
|
+
policy?: Pick<LosslessPolicy, "minSavingsPercent" | "minSavingsBytes">;
|
|
54
|
+
/** Try the transport-stream transform too. */
|
|
55
|
+
tsAware?: boolean;
|
|
56
|
+
signal?: AbortSignal;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Every codec over the same blocks. The identity row is the honest floor:
|
|
60
|
+
* what the envelope costs when nothing is saved.
|
|
61
|
+
*/
|
|
62
|
+
export declare function benchmark(bytes: Buffer, options?: BenchOptions): Promise<BenchRow[]>;
|
|
63
|
+
export interface Analysis {
|
|
64
|
+
/** Which bytes these are: the source as it arrived, or the channel's output. */
|
|
65
|
+
boundary: Boundary;
|
|
66
|
+
source: {
|
|
67
|
+
kind: "file" | "channel" | "bytes";
|
|
68
|
+
name: string;
|
|
69
|
+
};
|
|
70
|
+
sampleBytes: number;
|
|
71
|
+
sampleMs?: number;
|
|
72
|
+
truncated: boolean;
|
|
73
|
+
sha256: string;
|
|
74
|
+
container: Container;
|
|
75
|
+
ts: TsReport | null;
|
|
76
|
+
/** From ffprobe, when the caller had one to ask. */
|
|
77
|
+
codecs?: {
|
|
78
|
+
video: string;
|
|
79
|
+
audio: string;
|
|
80
|
+
container: string;
|
|
81
|
+
duration?: number;
|
|
82
|
+
};
|
|
83
|
+
/** What the sample's own length and duration imply, when a duration is known. */
|
|
84
|
+
observedKbps?: number;
|
|
85
|
+
bench: BenchRow[];
|
|
86
|
+
/** The rows' verdict under the policy thresholds: which mode `auto` would take. */
|
|
87
|
+
recommendation: {
|
|
88
|
+
mode: Mode;
|
|
89
|
+
level: number;
|
|
90
|
+
reason: string;
|
|
91
|
+
};
|
|
92
|
+
tools: ReturnType<typeof toolVersions> & {
|
|
93
|
+
ffprobe?: string;
|
|
94
|
+
};
|
|
95
|
+
at: string;
|
|
96
|
+
}
|
|
97
|
+
export interface AnalyzeOptions extends BenchOptions {
|
|
98
|
+
boundary: Boundary;
|
|
99
|
+
source: Analysis["source"];
|
|
100
|
+
sampleMs?: number;
|
|
101
|
+
truncated?: boolean;
|
|
102
|
+
codecs?: Analysis["codecs"];
|
|
103
|
+
ffprobeVersion?: string;
|
|
104
|
+
}
|
|
105
|
+
export declare function analyzeSample(bytes: Buffer, options: AnalyzeOptions): Promise<Analysis>;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What is in a sample, and what each codec makes of it.
|
|
3
|
+
*
|
|
4
|
+
* Bytes are inspected, not filenames: a .ts that is really an MP4 is told
|
|
5
|
+
* apart by its sync bytes, or their absence. The transport-stream report
|
|
6
|
+
* counts what can be counted without decoding anything -- packets, null
|
|
7
|
+
* packets, PIDs, the bitrate the PCR clock implies. The benchmark then runs
|
|
8
|
+
* every codec over the same blocks and reports the complete wire size,
|
|
9
|
+
* headers included, with a round trip checked on every block. Nothing here
|
|
10
|
+
* is a promise about production sources; it is a measurement of this
|
|
11
|
+
* sample on this machine, and says so.
|
|
12
|
+
*/
|
|
13
|
+
import { createHash } from "node:crypto";
|
|
14
|
+
import { decode, encode, toolVersions } from "./codec.js";
|
|
15
|
+
import { FRAME_HEADER_BYTES, STREAM_HEADER_BYTES } from "./envelope.js";
|
|
16
|
+
import { DEFAULT_LOSSLESS, eligible } from "./policy.js";
|
|
17
|
+
import { SYNC, TS_PACKET, tsLayout } from "./ts-transform.js";
|
|
18
|
+
/** Sample limits: bytes, and seconds of a live channel. */
|
|
19
|
+
export const SAMPLE_MAX_BYTES = 25 * 1024 * 1024;
|
|
20
|
+
export const SAMPLE_MAX_SECONDS = 30;
|
|
21
|
+
/** Which container the first bytes say they are. */
|
|
22
|
+
export function sniffContainer(bytes) {
|
|
23
|
+
if (bytes.length >= 12) {
|
|
24
|
+
const box = Buffer.from(bytes.subarray(4, 8)).toString("latin1");
|
|
25
|
+
if (box === "ftyp" || box === "styp") {
|
|
26
|
+
const brand = Buffer.from(bytes.subarray(8, 12)).toString("latin1");
|
|
27
|
+
return brand === "iso5" || brand === "iso6" || brand === "dash" || brand === "cmfc" ? "fmp4" : "mp4";
|
|
28
|
+
}
|
|
29
|
+
if (box === "moof" || box === "moov" || box === "sidx")
|
|
30
|
+
return "fmp4";
|
|
31
|
+
}
|
|
32
|
+
if (tsLayout(bytes) !== null)
|
|
33
|
+
return "mpegts";
|
|
34
|
+
if (bytes.length >= 4 && bytes[0] === 0x1a && bytes[1] === 0x45 && bytes[2] === 0xdf && bytes[3] === 0xa3)
|
|
35
|
+
return "webm";
|
|
36
|
+
if (bytes.length >= 3 && bytes[0] === 0x49 && bytes[1] === 0x44 && bytes[2] === 0x33)
|
|
37
|
+
return "mp3";
|
|
38
|
+
if (bytes.length >= 2 && bytes[0] === 0xff && (bytes[1] & 0xe0) === 0xe0)
|
|
39
|
+
return "mp3";
|
|
40
|
+
return "unknown";
|
|
41
|
+
}
|
|
42
|
+
/** PCR base and extension, as a count of 27 MHz ticks, from an adaptation field that has one. */
|
|
43
|
+
function pcrOf(packet, at) {
|
|
44
|
+
const afc = (packet[at + 3] >> 4) & 0x3;
|
|
45
|
+
if ((afc & 0b10) === 0)
|
|
46
|
+
return null;
|
|
47
|
+
const length = packet[at + 4];
|
|
48
|
+
if (length < 7)
|
|
49
|
+
return null;
|
|
50
|
+
const flags = packet[at + 5];
|
|
51
|
+
if ((flags & 0x10) === 0)
|
|
52
|
+
return null;
|
|
53
|
+
const b = packet.subarray(at + 6, at + 12);
|
|
54
|
+
const base = (b[0] * 2 ** 25) + (b[1] << 17) + (b[2] << 9) + (b[3] << 1) + (b[4] >> 7);
|
|
55
|
+
const ext = ((b[4] & 0x1) << 8) | b[5];
|
|
56
|
+
return base * 300 + ext;
|
|
57
|
+
}
|
|
58
|
+
/** The transport-stream report, or null for something that is not one. */
|
|
59
|
+
export function analyzeTs(bytes) {
|
|
60
|
+
const layout = tsLayout(bytes);
|
|
61
|
+
if (layout === null)
|
|
62
|
+
return null;
|
|
63
|
+
const { packetSize, offset } = layout;
|
|
64
|
+
const skip = packetSize === 192 ? 4 : 0;
|
|
65
|
+
const pids = new Map();
|
|
66
|
+
let packets = 0;
|
|
67
|
+
let nullPackets = 0;
|
|
68
|
+
let scrambled = 0;
|
|
69
|
+
let transportErrors = 0;
|
|
70
|
+
let adaptationOnly = 0;
|
|
71
|
+
let firstPcr = null;
|
|
72
|
+
let lastPcr = null;
|
|
73
|
+
let cursor = offset;
|
|
74
|
+
while (cursor + packetSize <= bytes.length && bytes[cursor + skip] === SYNC) {
|
|
75
|
+
const at = cursor + skip;
|
|
76
|
+
const b1 = bytes[at + 1];
|
|
77
|
+
const b2 = bytes[at + 2];
|
|
78
|
+
const b3 = bytes[at + 3];
|
|
79
|
+
const pid = ((b1 & 0x1f) << 8) | b2;
|
|
80
|
+
packets += 1;
|
|
81
|
+
pids.set(pid, (pids.get(pid) ?? 0) + 1);
|
|
82
|
+
if (pid === 0x1fff)
|
|
83
|
+
nullPackets += 1;
|
|
84
|
+
if (b1 & 0x80)
|
|
85
|
+
transportErrors += 1;
|
|
86
|
+
if (b3 & 0xc0)
|
|
87
|
+
scrambled += 1;
|
|
88
|
+
if (((b3 >> 4) & 0x3) === 0b10)
|
|
89
|
+
adaptationOnly += 1;
|
|
90
|
+
const pcr = pcrOf(bytes, at);
|
|
91
|
+
if (pcr !== null) {
|
|
92
|
+
if (firstPcr === null)
|
|
93
|
+
firstPcr = { pid, value: pcr, at: cursor };
|
|
94
|
+
else if (firstPcr.pid === pid && pcr > firstPcr.value)
|
|
95
|
+
lastPcr = { value: pcr, at: cursor };
|
|
96
|
+
}
|
|
97
|
+
cursor += packetSize;
|
|
98
|
+
}
|
|
99
|
+
const report = {
|
|
100
|
+
packetSize,
|
|
101
|
+
offset,
|
|
102
|
+
packets,
|
|
103
|
+
nullPackets,
|
|
104
|
+
nullShare: packets === 0 ? 0 : nullPackets / packets,
|
|
105
|
+
scrambled,
|
|
106
|
+
transportErrors,
|
|
107
|
+
adaptationOnly,
|
|
108
|
+
unsynced: offset + (bytes.length - cursor),
|
|
109
|
+
pids: [...pids.entries()]
|
|
110
|
+
.sort((a, b) => b[1] - a[1])
|
|
111
|
+
.slice(0, 12)
|
|
112
|
+
.map(([pid, count]) => ({ pid, packets: count })),
|
|
113
|
+
};
|
|
114
|
+
if (firstPcr && lastPcr) {
|
|
115
|
+
const seconds = (lastPcr.value - firstPcr.value) / 27_000_000;
|
|
116
|
+
if (seconds > 0.5)
|
|
117
|
+
report.pcrBitrateKbps = Math.round(((lastPcr.at - firstPcr.at) * 8) / seconds / 1000);
|
|
118
|
+
}
|
|
119
|
+
return report;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Every codec over the same blocks. The identity row is the honest floor:
|
|
123
|
+
* what the envelope costs when nothing is saved.
|
|
124
|
+
*/
|
|
125
|
+
export async function benchmark(bytes, options = {}) {
|
|
126
|
+
const blockBytes = options.blockBytes ?? DEFAULT_LOSSLESS.maxBlockBytes;
|
|
127
|
+
const policy = options.policy ?? DEFAULT_LOSSLESS;
|
|
128
|
+
const plan = [{ mode: "stored", level: 0 }, { mode: "gzip", level: 6 }];
|
|
129
|
+
for (const level of options.zstdLevels ?? [1, 3])
|
|
130
|
+
plan.push({ mode: "zstd", level });
|
|
131
|
+
if (options.tsAware)
|
|
132
|
+
plan.push({ mode: "ts-zstd", level: 1 });
|
|
133
|
+
const rows = [];
|
|
134
|
+
for (const { mode, level } of plan) {
|
|
135
|
+
if (options.signal?.aborted)
|
|
136
|
+
break;
|
|
137
|
+
const row = {
|
|
138
|
+
mode, level, blocks: 0, inputBytes: bytes.length, payloadBytes: 0, wireBytes: STREAM_HEADER_BYTES + FRAME_HEADER_BYTES,
|
|
139
|
+
storedBlocks: 0, compressedBlocks: 0, savingsPercent: 0, encodeMs: 0, decodeMs: 0, roundTrip: true,
|
|
140
|
+
};
|
|
141
|
+
try {
|
|
142
|
+
for (let at = 0; at < bytes.length; at += blockBytes) {
|
|
143
|
+
if (options.signal?.aborted)
|
|
144
|
+
throw new Error("cancelled");
|
|
145
|
+
const block = bytes.subarray(at, Math.min(bytes.length, at + blockBytes));
|
|
146
|
+
row.blocks += 1;
|
|
147
|
+
const t0 = performance.now();
|
|
148
|
+
const encoded = mode === "stored" ? block : await encode(mode, block, level);
|
|
149
|
+
row.encodeMs += performance.now() - t0;
|
|
150
|
+
const keep = mode !== "stored" && eligible(block.length, encoded.length, policy);
|
|
151
|
+
const payload = keep ? encoded : block;
|
|
152
|
+
if (keep)
|
|
153
|
+
row.compressedBlocks += 1;
|
|
154
|
+
else
|
|
155
|
+
row.storedBlocks += 1;
|
|
156
|
+
row.payloadBytes += payload.length;
|
|
157
|
+
row.wireBytes += FRAME_HEADER_BYTES + payload.length;
|
|
158
|
+
const t1 = performance.now();
|
|
159
|
+
const back = keep ? await decode(mode, payload, block.length) : payload;
|
|
160
|
+
row.decodeMs += performance.now() - t1;
|
|
161
|
+
if (!back.equals(block))
|
|
162
|
+
row.roundTrip = false;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
catch (error) {
|
|
166
|
+
row.note = error.message;
|
|
167
|
+
row.roundTrip = false;
|
|
168
|
+
}
|
|
169
|
+
row.savingsPercent = bytes.length === 0 ? 0 : ((bytes.length - row.wireBytes) * 100) / bytes.length;
|
|
170
|
+
row.encodeMs = Math.round(row.encodeMs * 100) / 100;
|
|
171
|
+
row.decodeMs = Math.round(row.decodeMs * 100) / 100;
|
|
172
|
+
row.savingsPercent = Math.round(row.savingsPercent * 100) / 100;
|
|
173
|
+
rows.push(row);
|
|
174
|
+
}
|
|
175
|
+
return rows;
|
|
176
|
+
}
|
|
177
|
+
export async function analyzeSample(bytes, options) {
|
|
178
|
+
const ts = analyzeTs(bytes);
|
|
179
|
+
const bench = await benchmark(bytes, { ...options, tsAware: options.tsAware ?? ts?.packetSize === TS_PACKET });
|
|
180
|
+
const stored = bench.find((row) => row.mode === "stored");
|
|
181
|
+
const best = bench
|
|
182
|
+
.filter((row) => row.roundTrip && row.mode !== "stored")
|
|
183
|
+
.sort((a, b) => a.wireBytes - b.wireBytes)[0];
|
|
184
|
+
const policy = options.policy ?? DEFAULT_LOSSLESS;
|
|
185
|
+
let recommendation;
|
|
186
|
+
if (!best || !stored || !eligible(stored.wireBytes, best.wireBytes, policy)) {
|
|
187
|
+
recommendation = { mode: "stored", level: 0, reason: "already efficiently compressed: no codec beat stored by the configured margin" };
|
|
188
|
+
}
|
|
189
|
+
else {
|
|
190
|
+
recommendation = { mode: best.mode, level: best.level, reason: `${best.mode} level ${best.level} saves ${best.savingsPercent}% of the complete wire size on this sample` };
|
|
191
|
+
}
|
|
192
|
+
const analysis = {
|
|
193
|
+
boundary: options.boundary,
|
|
194
|
+
source: options.source,
|
|
195
|
+
sampleBytes: bytes.length,
|
|
196
|
+
truncated: options.truncated ?? false,
|
|
197
|
+
sha256: createHash("sha256").update(bytes).digest("hex"),
|
|
198
|
+
container: sniffContainer(bytes),
|
|
199
|
+
ts,
|
|
200
|
+
bench,
|
|
201
|
+
recommendation,
|
|
202
|
+
tools: { ...toolVersions(), ...(options.ffprobeVersion ? { ffprobe: options.ffprobeVersion } : {}) },
|
|
203
|
+
at: new Date().toISOString(),
|
|
204
|
+
};
|
|
205
|
+
if (options.sampleMs !== undefined)
|
|
206
|
+
analysis.sampleMs = options.sampleMs;
|
|
207
|
+
if (options.codecs)
|
|
208
|
+
analysis.codecs = options.codecs;
|
|
209
|
+
const seconds = options.sampleMs !== undefined ? options.sampleMs / 1000 : options.codecs?.duration;
|
|
210
|
+
if (seconds && seconds > 0 && !options.truncated)
|
|
211
|
+
analysis.observedKbps = Math.round((bytes.length * 8) / seconds / 1000);
|
|
212
|
+
return analysis;
|
|
213
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bytes into blocks, without ever waiting for the stream's convenience.
|
|
3
|
+
*
|
|
4
|
+
* A block is flushed when it is full or when its first byte has been held
|
|
5
|
+
* for `maxHoldMs`, whichever is sooner. It never waits for a packet
|
|
6
|
+
* boundary that has not come, and a chunk bigger than a block is sliced
|
|
7
|
+
* rather than refused. Alignment, when asked for, only decides where a full
|
|
8
|
+
* block is cut; a timed flush sends whatever is there, and the transform
|
|
9
|
+
* that wanted the alignment copes with a ragged edge.
|
|
10
|
+
*/
|
|
11
|
+
export interface BlockerOptions {
|
|
12
|
+
maxBlockBytes: number;
|
|
13
|
+
maxHoldMs: number;
|
|
14
|
+
/** Cut full blocks at a multiple of this many bytes. 0 for wherever. */
|
|
15
|
+
align?: number;
|
|
16
|
+
onBlock: (block: Buffer) => void;
|
|
17
|
+
/** Injected by tests. */
|
|
18
|
+
setTimer?: (fn: () => void, ms: number) => {
|
|
19
|
+
clear(): void;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
export declare class Blocker {
|
|
23
|
+
private readonly options;
|
|
24
|
+
private pieces;
|
|
25
|
+
private held;
|
|
26
|
+
private timer;
|
|
27
|
+
private ended;
|
|
28
|
+
constructor(options: BlockerOptions);
|
|
29
|
+
get pendingBytes(): number;
|
|
30
|
+
push(chunk: Buffer): void;
|
|
31
|
+
/** Send whatever is held, now. */
|
|
32
|
+
flush(): void;
|
|
33
|
+
end(): void;
|
|
34
|
+
private flushBytes;
|
|
35
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
const realTimer = (fn, ms) => {
|
|
2
|
+
const handle = setTimeout(fn, ms);
|
|
3
|
+
handle.unref?.();
|
|
4
|
+
return { clear: () => clearTimeout(handle) };
|
|
5
|
+
};
|
|
6
|
+
export class Blocker {
|
|
7
|
+
options;
|
|
8
|
+
pieces = [];
|
|
9
|
+
held = 0;
|
|
10
|
+
timer = null;
|
|
11
|
+
ended = false;
|
|
12
|
+
constructor(options) {
|
|
13
|
+
this.options = options;
|
|
14
|
+
}
|
|
15
|
+
get pendingBytes() {
|
|
16
|
+
return this.held;
|
|
17
|
+
}
|
|
18
|
+
push(chunk) {
|
|
19
|
+
if (this.ended || chunk.length === 0)
|
|
20
|
+
return;
|
|
21
|
+
const { maxBlockBytes, align = 0 } = this.options;
|
|
22
|
+
let offset = 0;
|
|
23
|
+
while (offset < chunk.length) {
|
|
24
|
+
const room = maxBlockBytes - this.held;
|
|
25
|
+
const take = Math.min(room, chunk.length - offset);
|
|
26
|
+
this.pieces.push(chunk.subarray(offset, offset + take));
|
|
27
|
+
this.held += take;
|
|
28
|
+
offset += take;
|
|
29
|
+
if (this.held >= maxBlockBytes) {
|
|
30
|
+
// Cut at the alignment, carrying the remainder into the next block.
|
|
31
|
+
// Only a full block is cut this way: a whole block with no boundary
|
|
32
|
+
// in it is sent as it is, or nothing would ever leave.
|
|
33
|
+
const cut = align > 1 && this.held - (this.held % align) > 0 ? this.held - (this.held % align) : this.held;
|
|
34
|
+
this.flushBytes(cut);
|
|
35
|
+
}
|
|
36
|
+
else if (this.timer === null) {
|
|
37
|
+
// The clock starts when the first byte enters an empty block.
|
|
38
|
+
this.timer = (this.options.setTimer ?? realTimer)(() => {
|
|
39
|
+
this.timer = null;
|
|
40
|
+
this.flush();
|
|
41
|
+
}, this.options.maxHoldMs);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Send whatever is held, now. */
|
|
46
|
+
flush() {
|
|
47
|
+
this.flushBytes(this.held);
|
|
48
|
+
}
|
|
49
|
+
end() {
|
|
50
|
+
this.flush();
|
|
51
|
+
this.ended = true;
|
|
52
|
+
}
|
|
53
|
+
flushBytes(count) {
|
|
54
|
+
if (this.timer)
|
|
55
|
+
this.timer.clear();
|
|
56
|
+
this.timer = null;
|
|
57
|
+
if (count <= 0 || this.held === 0)
|
|
58
|
+
return;
|
|
59
|
+
const whole = this.pieces.length === 1 ? this.pieces[0] : Buffer.concat(this.pieces, this.held);
|
|
60
|
+
const block = whole.subarray(0, count);
|
|
61
|
+
const rest = whole.subarray(count);
|
|
62
|
+
this.pieces = rest.length > 0 ? [rest] : [];
|
|
63
|
+
this.held = rest.length;
|
|
64
|
+
this.options.onBlock(block);
|
|
65
|
+
// A remainder is a new block whose first byte arrived just now.
|
|
66
|
+
if (this.held > 0 && this.timer === null && !this.ended) {
|
|
67
|
+
this.timer = (this.options.setTimer ?? realTimer)(() => {
|
|
68
|
+
this.timer = null;
|
|
69
|
+
this.flush();
|
|
70
|
+
}, this.options.maxHoldMs);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function compression(argv: string[]): Promise<number>;
|