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.
Files changed (106) hide show
  1. package/README.md +139 -0
  2. package/dist/audio.d.ts +70 -1
  3. package/dist/audio.js +84 -5
  4. package/dist/channels.d.ts +107 -3
  5. package/dist/channels.js +151 -3
  6. package/dist/compression/analyze.d.ts +105 -0
  7. package/dist/compression/analyze.js +213 -0
  8. package/dist/compression/blocks.d.ts +35 -0
  9. package/dist/compression/blocks.js +73 -0
  10. package/dist/compression/cli.d.ts +1 -0
  11. package/dist/compression/cli.js +347 -0
  12. package/dist/compression/codec.d.ts +66 -0
  13. package/dist/compression/codec.js +178 -0
  14. package/dist/compression/envelope.d.ts +77 -0
  15. package/dist/compression/envelope.js +191 -0
  16. package/dist/compression/jobs.d.ts +59 -0
  17. package/dist/compression/jobs.js +120 -0
  18. package/dist/compression/metrics.d.ts +62 -0
  19. package/dist/compression/metrics.js +64 -0
  20. package/dist/compression/policy.d.ts +76 -0
  21. package/dist/compression/policy.js +148 -0
  22. package/dist/compression/receiver.d.ts +51 -0
  23. package/dist/compression/receiver.js +92 -0
  24. package/dist/compression/relay.d.ts +134 -0
  25. package/dist/compression/relay.js +430 -0
  26. package/dist/compression/routes.d.ts +25 -0
  27. package/dist/compression/routes.js +266 -0
  28. package/dist/compression/service.d.ts +162 -0
  29. package/dist/compression/service.js +488 -0
  30. package/dist/compression/static.d.ts +65 -0
  31. package/dist/compression/static.js +248 -0
  32. package/dist/compression/store.d.ts +36 -0
  33. package/dist/compression/store.js +119 -0
  34. package/dist/compression/ts-transform.d.ts +44 -0
  35. package/dist/compression/ts-transform.js +148 -0
  36. package/dist/hls.d.ts +36 -3
  37. package/dist/hls.js +85 -11
  38. package/dist/layouts.d.ts +10 -0
  39. package/dist/layouts.js +71 -1
  40. package/dist/live-api.d.ts +16 -2
  41. package/dist/live-api.js +153 -19
  42. package/dist/live-events.d.ts +57 -2
  43. package/dist/live-events.js +252 -12
  44. package/dist/main.js +47 -0
  45. package/dist/mcp.d.ts +40 -0
  46. package/dist/mcp.js +255 -0
  47. package/dist/oauth-api.d.ts +50 -0
  48. package/dist/oauth-api.js +381 -0
  49. package/dist/oauth-server.d.ts +174 -0
  50. package/dist/oauth-server.js +559 -0
  51. package/dist/party.d.ts +32 -0
  52. package/dist/party.js +196 -0
  53. package/dist/playlist.d.ts +11 -0
  54. package/dist/playlist.js +27 -6
  55. package/dist/server.d.ts +37 -0
  56. package/dist/server.js +277 -45
  57. package/dist/sources.d.ts +16 -8
  58. package/dist/sources.js +102 -0
  59. package/dist/tickets.d.ts +67 -0
  60. package/dist/tickets.js +156 -0
  61. package/dist/tokens.d.ts +27 -2
  62. package/dist/tokens.js +42 -1
  63. package/dist/watch-party.d.ts +127 -0
  64. package/dist/watch-party.js +301 -0
  65. package/package.json +1 -1
  66. package/src/audio.ts +119 -6
  67. package/src/channels.ts +177 -7
  68. package/src/compression/analyze.ts +270 -0
  69. package/src/compression/blocks.ts +93 -0
  70. package/src/compression/cli.ts +340 -0
  71. package/src/compression/codec.ts +202 -0
  72. package/src/compression/envelope.ts +237 -0
  73. package/src/compression/jobs.ts +157 -0
  74. package/src/compression/metrics.ts +109 -0
  75. package/src/compression/policy.ts +171 -0
  76. package/src/compression/receiver.ts +107 -0
  77. package/src/compression/relay.ts +462 -0
  78. package/src/compression/routes.ts +294 -0
  79. package/src/compression/service.ts +525 -0
  80. package/src/compression/static.ts +264 -0
  81. package/src/compression/store.ts +126 -0
  82. package/src/compression/ts-transform.ts +148 -0
  83. package/src/hls.ts +100 -10
  84. package/src/layouts.ts +78 -1
  85. package/src/live-api.ts +173 -17
  86. package/src/live-events.ts +281 -12
  87. package/src/main.ts +47 -0
  88. package/src/mcp.ts +282 -0
  89. package/src/oauth-api.ts +481 -0
  90. package/src/oauth-server.ts +653 -0
  91. package/src/party.ts +216 -0
  92. package/src/playlist.ts +28 -5
  93. package/src/server.ts +296 -44
  94. package/src/sources.ts +100 -0
  95. package/src/tickets.ts +195 -0
  96. package/src/tokens.ts +50 -3
  97. package/src/watch-party.ts +390 -0
  98. package/web/dist/assets/{hls-3VKVEQE3-CrILISPJ.js → hls-3VKVEQE3-eV54kXE3.js} +1 -1
  99. package/web/dist/assets/index-CurZFzlH.css +1 -0
  100. package/web/dist/assets/index-DDzutJ75.js +1 -0
  101. package/web/dist/assets/{mpegts-CWeN63eG.js → mpegts-Buc3Odv6.js} +1 -1
  102. package/web/dist/assets/{mpegts-LO6RVLD6-DaMgRvlO.js → mpegts-LO6RVLD6-DcDKPB4P.js} +1 -1
  103. package/web/dist/index.html +59 -6
  104. package/web/dist/sw.js +6 -6
  105. package/web/dist/assets/index-CQ_m5HqS.css +0 -1
  106. 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, BACKLOG_VIDEO, true);
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
- /** Write to everyone, and drop anybody whose socket has gone. */
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>;