nixamp 0.17.1 → 0.18.0

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 (36) hide show
  1. package/dist/channels.d.ts +54 -1
  2. package/dist/channels.js +160 -23
  3. package/dist/compression/benchmark.d.ts +97 -0
  4. package/dist/compression/benchmark.js +241 -0
  5. package/dist/compression/cli.js +59 -1
  6. package/dist/compression/receiver.d.ts +25 -5
  7. package/dist/compression/receiver.js +54 -5
  8. package/dist/compression/routes.js +7 -2
  9. package/dist/compression/service.d.ts +18 -6
  10. package/dist/compression/service.js +110 -19
  11. package/dist/compression/source.d.ts +17 -0
  12. package/dist/compression/source.js +131 -0
  13. package/dist/links.js +14 -3
  14. package/dist/owner.js +10 -0
  15. package/dist/server.js +118 -10
  16. package/dist/share.js +6 -0
  17. package/package.json +1 -1
  18. package/src/channels.ts +180 -22
  19. package/src/compression/benchmark.ts +297 -0
  20. package/src/compression/cli.ts +56 -1
  21. package/src/compression/receiver.ts +68 -7
  22. package/src/compression/routes.ts +7 -2
  23. package/src/compression/service.ts +121 -24
  24. package/src/compression/source.ts +130 -0
  25. package/src/links.ts +12 -3
  26. package/src/owner.ts +8 -0
  27. package/src/server.ts +113 -11
  28. package/src/share.ts +5 -0
  29. package/web/dist/assets/{hls-3VKVEQE3-eV54kXE3.js → hls-3VKVEQE3-B2kl0-z1.js} +1 -1
  30. package/web/dist/assets/{index-CurZFzlH.css → index-D1QxpRmE.css} +1 -1
  31. package/web/dist/assets/index-DF6O2leR.js +1 -0
  32. package/web/dist/assets/{mpegts-Buc3Odv6.js → mpegts-4pBNK_Yr.js} +1 -1
  33. package/web/dist/assets/{mpegts-LO6RVLD6-DcDKPB4P.js → mpegts-LO6RVLD6-B4RLM-_e.js} +1 -1
  34. package/web/dist/index.html +15 -10
  35. package/web/dist/sw.js +6 -6
  36. package/web/dist/assets/index-DDzutJ75.js +0 -1
@@ -64,6 +64,13 @@ export interface ChannelInfo {
64
64
  * a member may have on at once.
65
65
  */
66
66
  startedBy?: string;
67
+ /**
68
+ * Set when nixamp reads the source itself and hands ffmpeg the bytes down
69
+ * a pipe, so the original bytes pass through this process and can be
70
+ * relayed exactly as they came. Only a transport stream from a file or a
71
+ * plain URL is read this way, and only when a policy asks for it.
72
+ */
73
+ teed?: boolean;
67
74
  }
68
75
  /** Where a pulled source is picked up from, and whether it can be at all. */
69
76
  export interface PullResume {
@@ -72,6 +79,22 @@ export interface PullResume {
72
79
  /** Seconds into a film to start from. */
73
80
  position: number;
74
81
  }
82
+ /**
83
+ * A source read by us rather than by ffmpeg: what to tell ffmpeg it is,
84
+ * and how to open it. Opened once per dial; the signal is pulled when that
85
+ * dial is over.
86
+ */
87
+ export interface PullThrough {
88
+ /** ffmpeg's name for the container, e.g. `mpegts`. */
89
+ format: string;
90
+ open(signal: AbortSignal): Promise<AsyncIterable<Uint8Array>>;
91
+ }
92
+ /**
93
+ * Asked at every dial whether this source should be read through us. Null
94
+ * means ffmpeg dials it as it always has. `from` is where a film is being
95
+ * picked up from; a source read through us cannot be joined mid-way.
96
+ */
97
+ export type ThroughProvider = (info: ChannelInfo, from: number, input: string[], audio: string) => PullThrough | null;
75
98
  /**
76
99
  * How far back of the saved place a film is picked up from, in seconds. The
77
100
  * place is written down every so often and a restart lands between two
@@ -144,6 +167,8 @@ export interface ChannelOptions {
144
167
  maxListenerQueueBytes?: number;
145
168
  /** How long a rate is measured over before the backlog is sized off it. Tests shorten it. */
146
169
  rateWindowMs?: number;
170
+ /** Whether a pulled source is read here and piped to ffmpeg. See `Channels.setThrough`. */
171
+ through?: ThroughProvider;
147
172
  }
148
173
  /**
149
174
  * One live source, and its audience.
@@ -192,6 +217,10 @@ export declare class Channel {
192
217
  */
193
218
  ephemeral: boolean;
194
219
  private idle;
220
+ /** Whoever wants the source's own bytes, when the source is read through us. */
221
+ private readonly sourceTaps;
222
+ /** Pulls the plug on the current read-through, when there is one. */
223
+ private throughAbort;
195
224
  constructor(info: ChannelInfo, options: ChannelOptions, onGone: (id: string) => void);
196
225
  start(format: string): void;
197
226
  /**
@@ -230,6 +259,8 @@ export declare class Channel {
230
259
  * new beginning as it is written, rather than a stale one first.
231
260
  */
232
261
  private startOver;
262
+ /** Everybody tapping the source is told it ended. */
263
+ private endTaps;
233
264
  /** Expect output within STALL, or treat the source as gone and dial again. */
234
265
  private rearm;
235
266
  /** End everybody listening; the stream they were on is over. */
@@ -298,6 +329,21 @@ export declare class Channel {
298
329
  * own ffmpeg is dialled again, and a newcomer gets the new beginning.
299
330
  */
300
331
  rollover(): void;
332
+ /**
333
+ * Hear the source's own bytes, as they go down the pipe to ffmpeg. Only
334
+ * a channel read through us has any; for the rest this attaches nothing
335
+ * and returns null. Ended, like a listener, when the source starts over.
336
+ */
337
+ tapSource(tap: Listener): (() => void) | null;
338
+ private tap;
339
+ /**
340
+ * Read the source and write it to this ffmpeg's stdin, at the rate ffmpeg
341
+ * takes it. Every chunk is handed to the taps first, so a tap sees exactly
342
+ * the bytes ffmpeg does. When the source ends, stdin is ended, ffmpeg
343
+ * finishes, and its close handler dials again -- the same path a source
344
+ * that ffmpeg read itself takes when it drops.
345
+ */
346
+ private feedThrough;
301
347
  listen(listener: Listener): () => void;
302
348
  /** Stay up with nobody watching: no longer on demand. */
303
349
  keep(): void;
@@ -337,7 +383,7 @@ export declare class Channels {
337
383
  * about it is the same too, which is the point -- a re-stream stops being a
338
384
  * special case and becomes one more thing that is on.
339
385
  */
340
- pull(id: string, name: string, source: string, encode: string[], kind: "audio" | "video", paced?: boolean, stall?: number, input?: string[], audio?: string, resume?: PullResume): Channel | null;
386
+ pull(id: string, name: string, source: string, encode: string[], kind: "audio" | "video", paced?: boolean, stall?: number, input?: string[], audio?: string, resume?: PullResume, codecs?: ChannelInfo["codecs"]): Channel | null;
341
387
  /**
342
388
  * Dial a pulled channel's source again, now. False for a channel that is
343
389
  * not there or is not ours to dial: a publisher's stream restarts at the
@@ -382,6 +428,13 @@ export declare class Channels {
382
428
  relayIn(id: string, name: string, kind: "audio" | "video", source: string): Channel | null;
383
429
  /** What a new listener would be written first, for a relay's preface. */
384
430
  opening(id: string): Buffer[];
431
+ /** Hear a channel's source bytes, when it is read through us. Null otherwise. */
432
+ tapSource(id: string, tap: Listener): (() => void) | null;
433
+ /**
434
+ * Who decides whether a pulled source is read here and piped to ffmpeg.
435
+ * Set once by whoever owns the policies; asked at every dial.
436
+ */
437
+ setThrough(provider: ThroughProvider | null): void;
385
438
  /** The kind of a channel, for a relay to say what it is carrying. */
386
439
  kindOf(id: string): "audio" | "video" | undefined;
387
440
  stop(id: string): boolean;
package/dist/channels.js CHANGED
@@ -163,6 +163,10 @@ export class Channel {
163
163
  */
164
164
  ephemeral = false;
165
165
  idle = null;
166
+ /** Whoever wants the source's own bytes, when the source is read through us. */
167
+ sourceTaps = new Set();
168
+ /** Pulls the plug on the current read-through, when there is one. */
169
+ throughAbort = null;
166
170
  constructor(info, options, onGone) {
167
171
  this.info = info;
168
172
  this.options = options;
@@ -236,6 +240,15 @@ export class Channel {
236
240
  // and a film that has barely started is started.
237
241
  const from = resume.live ? 0 : Math.max(0, Math.floor((this.info.position ?? 0) - REWIND));
238
242
  const seek = from > 0 ? ["-ss", String(from)] : [];
243
+ // Read the source here rather than in ffmpeg, when a policy wants the
244
+ // original bytes and the source is the kind that can be. ffmpeg then
245
+ // reads a pipe, and every byte that goes down it is also handed to
246
+ // whoever has tapped the source. Asked again at every dial, so a
247
+ // policy set after the channel started applies at its next restart.
248
+ this.throughAbort?.abort();
249
+ this.throughAbort = null;
250
+ const through = this.options.through?.(this.info, from, input, audio) ?? null;
251
+ this.info.teed = through !== null;
239
252
  const child = spawn(command, [
240
253
  ...prefix,
241
254
  "-hide_banner",
@@ -245,33 +258,52 @@ export class Channel {
245
258
  // to. Not stderr, which is for what went wrong.
246
259
  "-progress", "pipe:3",
247
260
  "-stats_period", "1",
248
- // A dropped source is normal over hours, and a channel that dies
249
- // the first time a CDN hiccups is not a channel anybody can rely
250
- // on. ffmpeg redials on its own before we have to.
251
- // A connection that stops answering is an error after this long,
252
- // and an error is a thing the reconnect knows what to do with.
253
- // Without it a silent socket is waited on for ever. In
254
- // microseconds, as ffmpeg wants it.
255
- ...remoteArgs,
256
- // Real time, always. A file read as fast as the disk allows is an
257
- // hour of film in ninety seconds and a room that cannot be in it
258
- // together; a live source is already paced and loses nothing.
259
- ...(paced ? ["-re"] : []),
260
- // What the source's site expects on the request: a user agent, a
261
- // referer, a cookie. A link resolved by yt-dlp comes with these,
262
- // and a CDN that got them from yt-dlp and not from us answers 403.
263
- ...input,
264
- ...seek,
265
- "-i", source,
266
- // The sound, when the site keeps it apart from the picture: a
267
- // second input, dialled the same way, that the encode maps in.
268
- ...(audio ? [...remoteArgs, ...(paced ? ["-re"] : []), ...input, ...seek, "-i", audio] : []),
261
+ ...(through
262
+ ? [
263
+ // Stated, as for a publisher: ffmpeg mis-probes a pipe. Paced
264
+ // the same way, since a pipe is read as fast as it is written.
265
+ // The source's own input tuning still applies -- a transport
266
+ // stream read from a pipe needs the same probe depth and
267
+ // generated timestamps it would off a socket -- but request
268
+ // headers, which only a source read through us leaves out,
269
+ // are no use to a pipe and are not here (a source that needs
270
+ // them is not read through us in the first place).
271
+ ...input,
272
+ "-f", through.format,
273
+ ...(paced ? ["-re"] : []),
274
+ "-i", "pipe:0",
275
+ ]
276
+ : [
277
+ // A dropped source is normal over hours, and a channel that dies
278
+ // the first time a CDN hiccups is not a channel anybody can rely
279
+ // on. ffmpeg redials on its own before we have to.
280
+ // A connection that stops answering is an error after this long,
281
+ // and an error is a thing the reconnect knows what to do with.
282
+ // Without it a silent socket is waited on for ever. In
283
+ // microseconds, as ffmpeg wants it.
284
+ ...remoteArgs,
285
+ // Real time, always. A file read as fast as the disk allows is an
286
+ // hour of film in ninety seconds and a room that cannot be in it
287
+ // together; a live source is already paced and loses nothing.
288
+ ...(paced ? ["-re"] : []),
289
+ // What the source's site expects on the request: a user agent, a
290
+ // referer, a cookie. A link resolved by yt-dlp comes with these,
291
+ // and a CDN that got them from yt-dlp and not from us answers 403.
292
+ ...input,
293
+ ...seek,
294
+ "-i", source,
295
+ // The sound, when the site keeps it apart from the picture: a
296
+ // second input, dialled the same way, that the encode maps in.
297
+ ...(audio ? [...remoteArgs, ...(paced ? ["-re"] : []), ...input, ...seek, "-i", audio] : []),
298
+ ]),
269
299
  ...encode,
270
300
  "pipe:1",
271
- ], { stdio: ["ignore", "pipe", "pipe", "pipe"] });
301
+ ], { stdio: [through ? "pipe" : "ignore", "pipe", "pipe", "pipe"] });
272
302
  let sent = false;
273
303
  this.child = child;
274
304
  this.rearm(child);
305
+ if (through)
306
+ void this.feedThrough(child, through);
275
307
  // ffmpeg's progress: key=value lines, out_time_us being how much it
276
308
  // has written, from where it was told to start. Read whole lines,
277
309
  // since a chunk can end mid-number. Drained whatever it says, for
@@ -363,6 +395,22 @@ export class Channel {
363
395
  this.rateBytes = 0;
364
396
  this.rate = 0;
365
397
  this.hangUp();
398
+ // The source's own bytes start over too: a new dial is a new stream
399
+ // from its beginning, and whoever was tapping it must not be handed the
400
+ // new beginning after the old middle.
401
+ this.endTaps();
402
+ }
403
+ /** Everybody tapping the source is told it ended. */
404
+ endTaps() {
405
+ for (const tap of this.sourceTaps) {
406
+ try {
407
+ tap.end();
408
+ }
409
+ catch {
410
+ // Gone already.
411
+ }
412
+ }
413
+ this.sourceTaps.clear();
366
414
  }
367
415
  /** Expect output within STALL, or treat the source as gone and dial again. */
368
416
  rearm(child) {
@@ -572,6 +620,75 @@ export class Channel {
572
620
  this.info.redials = (this.info.redials ?? 0) + 1;
573
621
  this.startOver();
574
622
  }
623
+ /**
624
+ * Hear the source's own bytes, as they go down the pipe to ffmpeg. Only
625
+ * a channel read through us has any; for the rest this attaches nothing
626
+ * and returns null. Ended, like a listener, when the source starts over.
627
+ */
628
+ tapSource(tap) {
629
+ if (!this.info.teed || this.closing)
630
+ return null;
631
+ this.sourceTaps.add(tap);
632
+ return () => {
633
+ this.sourceTaps.delete(tap);
634
+ };
635
+ }
636
+ tap(chunk) {
637
+ for (const tap of this.sourceTaps) {
638
+ try {
639
+ tap.write(chunk);
640
+ }
641
+ catch {
642
+ this.sourceTaps.delete(tap);
643
+ }
644
+ }
645
+ }
646
+ /**
647
+ * Read the source and write it to this ffmpeg's stdin, at the rate ffmpeg
648
+ * takes it. Every chunk is handed to the taps first, so a tap sees exactly
649
+ * the bytes ffmpeg does. When the source ends, stdin is ended, ffmpeg
650
+ * finishes, and its close handler dials again -- the same path a source
651
+ * that ffmpeg read itself takes when it drops.
652
+ */
653
+ async feedThrough(child, through) {
654
+ const controller = new AbortController();
655
+ this.throughAbort = controller;
656
+ const stdin = child.stdin;
657
+ if (!stdin)
658
+ return;
659
+ stdin.on("error", () => undefined);
660
+ try {
661
+ const body = await through.open(controller.signal);
662
+ for await (const raw of body) {
663
+ if (this.child !== child || this.closing || controller.signal.aborted)
664
+ break;
665
+ const chunk = Buffer.isBuffer(raw) ? raw : Buffer.from(raw);
666
+ this.tap(chunk);
667
+ if (!stdin.write(chunk)) {
668
+ // Wait for ffmpeg to take it, or for the pipe to go: a pipe that
669
+ // closed never drains, and waiting on it would hold the read open.
670
+ await new Promise((done) => {
671
+ stdin.once("drain", done);
672
+ stdin.once("close", done);
673
+ });
674
+ }
675
+ }
676
+ }
677
+ catch (error) {
678
+ if (this.child === child && !controller.signal.aborted)
679
+ this.info.error = error.message;
680
+ }
681
+ finally {
682
+ try {
683
+ stdin.end();
684
+ }
685
+ catch {
686
+ // Already gone.
687
+ }
688
+ if (this.throughAbort === controller)
689
+ this.throughAbort = null;
690
+ }
691
+ }
575
692
  listen(listener) {
576
693
  // What the stream is, before any of what it is currently saying. Without
577
694
  // this a listener who arrives after the first second gets fragments that
@@ -645,6 +762,9 @@ export class Channel {
645
762
  this.info.error = said;
646
763
  const child = this.child;
647
764
  this.child = null;
765
+ this.throughAbort?.abort();
766
+ this.throughAbort = null;
767
+ this.endTaps();
648
768
  try {
649
769
  child?.stdin?.end();
650
770
  }
@@ -731,7 +851,7 @@ export class Channels {
731
851
  * about it is the same too, which is the point -- a re-stream stops being a
732
852
  * special case and becomes one more thing that is on.
733
853
  */
734
- pull(id, name, source, encode, kind, paced = true, stall = STALL, input = [], audio = "", resume = { live: true, position: 0 }) {
854
+ pull(id, name, source, encode, kind, paced = true, stall = STALL, input = [], audio = "", resume = { live: true, position: 0 }, codecs) {
735
855
  if (this.open.has(id))
736
856
  return null;
737
857
  const channel = new Channel({
@@ -744,6 +864,9 @@ export class Channels {
744
864
  listeners: 0,
745
865
  kind,
746
866
  source,
867
+ // Known before the first dial, so whether to read the source here
868
+ // can be decided from what it holds.
869
+ ...(codecs ? { codecs } : {}),
747
870
  }, this.options, (gone) => this.open.delete(gone));
748
871
  this.open.set(id, channel);
749
872
  channel.pull(source, encode, paced, stall, input, audio, resume);
@@ -841,6 +964,20 @@ export class Channels {
841
964
  opening(id) {
842
965
  return this.open.get(id)?.opening() ?? [];
843
966
  }
967
+ /** Hear a channel's source bytes, when it is read through us. Null otherwise. */
968
+ tapSource(id, tap) {
969
+ return this.open.get(id)?.tapSource(tap) ?? null;
970
+ }
971
+ /**
972
+ * Who decides whether a pulled source is read here and piped to ffmpeg.
973
+ * Set once by whoever owns the policies; asked at every dial.
974
+ */
975
+ setThrough(provider) {
976
+ if (provider)
977
+ this.options.through = provider;
978
+ else
979
+ delete this.options.through;
980
+ }
844
981
  /** The kind of a channel, for a relay to say what it is carrying. */
845
982
  kindOf(id) {
846
983
  return this.open.get(id)?.info.kind;
@@ -0,0 +1,97 @@
1
+ import { type BenchRow } from "./analyze.ts";
2
+ /** The report schema version, bumped when the shape below changes. */
3
+ export declare const REPORT_SCHEMA = 1;
4
+ export interface Sample {
5
+ name: string;
6
+ /** synthetic bytes we generated, or a real file the runner supplied. */
7
+ kind: "synthetic" | "real";
8
+ bytes: Buffer;
9
+ /** A word on what it is and why it is in the corpus. */
10
+ notes: string;
11
+ }
12
+ export interface SampleResult {
13
+ sample: string;
14
+ kind: Sample["kind"];
15
+ inputBytes: number;
16
+ sha256: string;
17
+ container: string;
18
+ rows: BenchRow[];
19
+ /** The mode the policy would pick for this sample, and why. */
20
+ recommendation: {
21
+ mode: string;
22
+ level: number;
23
+ reason: string;
24
+ };
25
+ }
26
+ export interface BenchmarkReport {
27
+ schema: number;
28
+ spec: "openstream";
29
+ specVersion: string;
30
+ generatedAt: string;
31
+ implementation: {
32
+ name: string;
33
+ version: string;
34
+ };
35
+ environment: {
36
+ runtime: string;
37
+ zstd: string;
38
+ zlib: string;
39
+ os: string;
40
+ arch: string;
41
+ cpu: string;
42
+ cores: number;
43
+ memoryGiB: number;
44
+ };
45
+ envelope: {
46
+ magic: string;
47
+ streamHeaderBytes: number;
48
+ frameHeaderBytes: number;
49
+ };
50
+ policy: {
51
+ minSavingsPercent: number;
52
+ minSavingsBytes: number;
53
+ maxBlockBytes: number;
54
+ zstdLevels: number[];
55
+ };
56
+ samples: SampleResult[];
57
+ /** Per mode, summed across the corpus: the honest aggregate. */
58
+ summary: {
59
+ corpusBytes: number;
60
+ byMode: {
61
+ mode: string;
62
+ level: number;
63
+ wireBytes: number;
64
+ savingsPercent: number;
65
+ roundTrip: boolean;
66
+ encodeMs: number;
67
+ decodeMs: number;
68
+ }[];
69
+ };
70
+ caveats: string[];
71
+ }
72
+ /** The default corpus: bytes only, no ffmpeg, deterministic. */
73
+ export declare function syntheticCorpus(): Sample[];
74
+ /** Real files a runner points us at, each read whole (capped) as a sample. */
75
+ export declare function fileCorpus(dir: string, capBytes?: number): Sample[];
76
+ export interface RunOptions {
77
+ implementationVersion: string;
78
+ zstdLevels?: number[];
79
+ blockBytes?: number;
80
+ signal?: AbortSignal;
81
+ }
82
+ /** Run the corpus and build the report. */
83
+ export declare function runBenchmark(corpus: Sample[], options: RunOptions): Promise<BenchmarkReport>;
84
+ /** The report as Markdown, for a human and for the reports page. */
85
+ export declare function reportMarkdown(report: BenchmarkReport): string;
86
+ /**
87
+ * Whether every applicable codec restored exactly: the gate a release must
88
+ * pass. A row with a note is a mode that did not apply to that sample, not a
89
+ * corrupted round trip, so it does not fail the gate.
90
+ */
91
+ export declare function reportPasses(report: BenchmarkReport): boolean;
92
+ /** The rows that are a real exactness failure: ran, and did not restore. */
93
+ export declare function exactnessFailures(report: BenchmarkReport): {
94
+ sample: string;
95
+ mode: string;
96
+ level: number;
97
+ }[];