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/README.md CHANGED
@@ -136,6 +136,64 @@ DATABASE_URL=postgres://user:pass@host/nixamp NIXAMP_JWT_SECRET=… nixamp serve
136
136
  Accounts live where the directory lives and nowhere else: a nixamp on a laptop
137
137
  has nobody to be an account of.
138
138
 
139
+ ## Watch parties, and signing in with nixamp
140
+
141
+ A watch party lives on the site that has the film. bittorrented.com has them:
142
+ a six-character code, a host, and everybody at the same second. nixamp has
143
+ rooms, chat, invitations, a directory, and five clients that can already open
144
+ one. A bridged party is both.
145
+
146
+ The identity link is **OAuth 2.1**, with nixamp.com as the authorization
147
+ server. The site sends somebody here, they approve it once, and the site holds
148
+ a token that acts on their nixamp account. It is 2.1 and not 2.0, so:
149
+
150
+ - authorization code only, with PKCE (S256) required of every client, public
151
+ or confidential. No implicit grant, no password grant.
152
+ - redirect URIs match the registered string exactly; only a loopback port may
153
+ vary, because a CLI cannot know its port before it listens.
154
+ - a code is spent once; presenting it twice withdraws everything it produced.
155
+ - refresh tokens rotate, and a retired one presented again withdraws the whole
156
+ family.
157
+
158
+ The endpoints are where RFC 8414 says to look for them:
159
+
160
+ ```
161
+ GET /.well-known/oauth-authorization-server
162
+ GET /api/v1/oauth/authorize the consent page
163
+ POST /api/v1/oauth/token authorization_code, refresh_token
164
+ POST /api/v1/oauth/revoke
165
+ GET /api/v1/oauth/userinfo
166
+ ```
167
+
168
+ Scopes are `profile`, `email`, `parties` and `offline_access`. The Account
169
+ panel on nixamp.com lists what is connected and takes it away again.
170
+
171
+ bittorrented.com is registered out of the box. Another client is added with
172
+ `NIXAMP_OAUTH_CLIENTS`, a JSON list:
173
+
174
+ ```
175
+ NIXAMP_OAUTH_CLIENTS='[{"id":"example","name":"Example","redirectUris":["https://example.com/cb"]}]'
176
+ ```
177
+
178
+ Once a party is bridged it is an ordinary live event with a room, so every
179
+ surface already knows what to do with it:
180
+
181
+ ```
182
+ nixamp party list the ones you could join right now
183
+ nixamp party join ABC123 --open the room here, the film where it lives
184
+ nixamp party host ABC123 --url URL put one on the air as a nixamp room
185
+ nixamp party sync ABC123 --at 930 where playback is (hosts only)
186
+ ```
187
+
188
+ and an agent reaches the same five actions over the Model Context Protocol:
189
+
190
+ ```
191
+ nixamp mcp a stdio MCP server: list, get, host, sync, end
192
+ ```
193
+
194
+ It acts as whoever the machine is signed in as, so `nixamp login` comes first.
195
+ The film never crosses over: what nixamp carries is the room.
196
+
139
197
  ## BackToSchool.help
140
198
 
141
199
  BackToSchool.help is a branded, mobile-first client for NixAmp live events. It
@@ -172,6 +230,41 @@ on first use in the configured PostgreSQL database. `RESEND_API_KEY` and
172
230
  `NIXAMP_MAIL_FROM` are optional if invitation email should be sent rather than
173
231
  only returning a shareable link.
174
232
 
233
+ ## Live shows, and tickets
234
+
235
+ A live event carries a **kind**: `talk`, `class`, or `concert`. The kind is
236
+ what a branded client reads to pick a layout, and what `/api/v1/events?kind=`
237
+ filters the directory by, so one NixAmp serves a school and a venue without
238
+ either knowing about the other. `concert` brings its own presets
239
+ (`concert-viewer`, `concert-ticketholder`, `concert-artist`) with a stage,
240
+ a setlist, a tip jar, a merch shelf and a till.
241
+
242
+ A concert also has doors and an encore. `POST /api/v1/events/:id/doors` opens
243
+ the room before the music, `.../start` begins it, `.../encore` says the band
244
+ came back, and `.../end` closes it. Opening, playing and coming back on are
245
+ allowed to everyone on the stage; cancelling and archiving stay with the host.
246
+ An **artist** is an invitation role beside moderator: they perform without
247
+ being handed the guest list.
248
+
249
+ **A ticket is a paid pass to one room**, over x402 and settled by CoinPay,
250
+ exactly like the crawler paywall but scoped to a single event:
251
+
252
+ ```
253
+ POST /api/v1/events/:id/tickets # X-PAYMENT proof in, ticket out
254
+ GET /api/v1/events/:id/tickets # what it costs and whether you hold one
255
+ POST /api/v1/events/:id/tickets/comp # the guest list, hosts only
256
+ ```
257
+
258
+ Set `ticketPriceCents` and a `payTo` address on the event and the room answers
259
+ 402 to anyone without a ticket, quoting the price; the money goes to the
260
+ event's own address, never to the platform. The ticket rides in
261
+ `x-nixamp-ticket`, or in `?ticket=` for an `<audio>` or `<video>` element that
262
+ cannot set a header. `COINPAY_X402_KEY` switches sales on; without it every
263
+ event is simply a free one. `NIXAMP_TICKET_SECRET` signs the passes (it
264
+ defaults to the CoinPay key), and each event's tickets are signed with a
265
+ secret derived from it and the event id, so a ticket to Friday is not a ticket
266
+ to Saturday.
267
+
175
268
  ## The directory
176
269
 
177
270
  [nixamp.com/directory](https://nixamp.com/directory) lists nixamps that agreed
@@ -223,6 +316,39 @@ is the whole point.
223
316
  Publishing is administering the server, so it needs the control link or the
224
317
  owner's account. Listening only needs the share link, like any other audio.
225
318
 
319
+ ### Relaying a channel to another nixamp, compressed
320
+
321
+ A channel can be carried from one nixamp to another with fewer bytes on the
322
+ wire and every byte restored at the far end. It is off until you turn it on,
323
+ per channel, and nothing about ordinary playback changes when you do.
324
+
325
+ ```
326
+ nixamp compression analyze --channel cnn what a codec would make of it
327
+ nixamp compression set --channel cnn --mode auto compress when it pays, store when it does not
328
+ nixamp compression status --channel cnn what it is doing, in bytes
329
+ nixamp compression off the whole server, at once
330
+ ```
331
+
332
+ On the receiving nixamp:
333
+
334
+ ```
335
+ nixamp compression pull --channel cnn --from https://host:4321/api/channels/cnn/relay --from-key KEY
336
+ ```
337
+
338
+ and `cnn` is a channel there, heard at `/api/channels/cnn` like any other.
339
+ The relay is `GET /api/channels/<id>/relay` as `application/vnd.nixamp.stream`,
340
+ a framed stream of Zstandard blocks each carrying the length and SHA-256 of
341
+ what it stands for, ending in a marker; a block that would not shrink is
342
+ sent as it is, and the metrics say so rather than claiming a saving. A
343
+ library file gets the same treatment at `/api/media/<n>/relay`, built once
344
+ and kept. `nixamp compression analyze FILE` measures a file here with no
345
+ server at all. The wire format, the policy, the limits and the switch are
346
+ in [docs/stream-compression.md](docs/stream-compression.md).
347
+
348
+ HLS can be packaged as fragmented MP4 instead of MPEG-TS
349
+ (`--hls fmp4` on `compression set`, or server-wide): the same boxes the
350
+ channel already carries, copied into files, never re-encoded.
351
+
226
352
  ## Streaming into it
227
353
 
228
354
  A nixamp can be the thing you broadcast *to*, not just from.
@@ -512,6 +638,19 @@ A bare `ffmpeg` on `PATH` is used when there is one; `mise` shims are detected a
512
638
 
513
639
  Whatever your ffmpeg was built with: mp3, flac, ogg, opus, m4a, aac, wav, wma, aiff, alac, and the audio track of mp4 and webm.
514
640
 
641
+ Video too, including raw transport streams — a `.ts`, `.m2ts` or `.mts` off a
642
+ capture card, a receiver or an IPTV recorder, at 1080p or 4K. H.264 is copied
643
+ into the fragmented MP4 a browser is sent, at whatever size it already is, so
644
+ a 4K recording costs no encoding to watch or to put on the air. H.265 is
645
+ copied too when the browser asking for it says it can decode one, and
646
+ otherwise re-encoded down to 1080p, because a 4K encode does not keep up with
647
+ playing it. A channel, which has one encode and a whole audience, re-encodes
648
+ H.265 by default; `NIXAMP_HEVC_CHANNELS=1` copies it through instead, for an
649
+ audience of phones and televisions.
650
+
651
+ A `.ts` is opened rather than taken on its name: it is as often a TypeScript
652
+ file as a transport stream, and a checkout is not a playlist.
653
+
515
654
  ## Status
516
655
 
517
656
  Early. It plays a directory, shows tags and timings, and draws what it hears —
package/dist/audio.d.ts CHANGED
@@ -98,6 +98,19 @@ export interface Codecs {
98
98
  * restart; a live channel is wherever it is now.
99
99
  */
100
100
  duration?: number;
101
+ /**
102
+ * The size of the picture, when there is one.
103
+ *
104
+ * It decides the one thing that costs real money: whether a re-encode is
105
+ * asked to do 4K. Measured on this machine, 3840x2160 through libx264
106
+ * -preset veryfast runs at about half of real time, so a 4K film re-encoded
107
+ * at its own size arrives slower than it plays -- a stream that falls
108
+ * further behind every second. The same source scaled to 1080p runs at
109
+ * about 1.6x real time and keeps up. Copying, of course, costs nothing at
110
+ * any size, which is why what is inside matters more than how big it is.
111
+ */
112
+ width?: number;
113
+ height?: number;
101
114
  }
102
115
  /**
103
116
  * Ask ffprobe what the streams are, without holding the event loop.
@@ -107,6 +120,57 @@ export interface Codecs {
107
120
  * the whole library came to be tagged with the process wedged solid.
108
121
  */
109
122
  export declare function codecsOf(tools: Tools, path: string, input?: string[]): Promise<Codecs>;
123
+ /** How much of a transport stream is read before deciding what is in it. */
124
+ export declare const TRANSPORT_PROBE_BYTES: number;
125
+ export declare const TRANSPORT_ANALYSE_US = 10000000;
126
+ /**
127
+ * What to tell ffmpeg or ffprobe before it opens a transport stream.
128
+ *
129
+ * A `.ts` has no index and no header: it is packets, and the tracks are
130
+ * whatever turns up in them. The defaults are tuned for a file that describes
131
+ * itself, so a 4K recording -- padded with null packets, seconds between
132
+ * keyframes, sometimes several programmes -- gets read as having no picture,
133
+ * or no sound, or neither. Reading twenty megabytes before deciding costs a
134
+ * fraction of a second on a local disk and is the difference between a
135
+ * television recording and "nothing to play here".
136
+ *
137
+ * `+genpts` is for the other half of it: a recording that starts mid-stream
138
+ * has no timestamp on its first frames, and a fragmented MP4 built out of
139
+ * those has a duration of nothing and a seek bar that does not move.
140
+ * `+discardcorrupt` drops the half-packet at a cut rather than passing
141
+ * rubbish to the decoder.
142
+ */
143
+ export declare function transportProbeArgs(path: string, container?: string): string[];
144
+ /** The same, for a decode rather than a probe: the timestamps matter too. */
145
+ export declare function transportInputArgs(path: string, container?: string): string[];
146
+ /** How the two sides of `-c:v copy` are told apart in a name a person reads. */
147
+ export interface VideoOptions {
148
+ /**
149
+ * Whether the thing at the other end can decode H.265.
150
+ *
151
+ * Safari and most televisions can; Chrome on a desktop cannot, and hands
152
+ * back nothing at all rather than an error anybody sees. So HEVC is only
153
+ * ever copied when the client said it could take it -- which is worth
154
+ * asking, because the alternative for a 4K HEVC film is an encode that does
155
+ * not keep up with playback.
156
+ */
157
+ allowHevc?: boolean;
158
+ /**
159
+ * The tallest picture a re-encode may produce. A copy is never resized: a
160
+ * 4K stream a browser can already decode is handed over as it is.
161
+ */
162
+ maxHeight?: number;
163
+ }
164
+ /**
165
+ * The tallest re-encode that keeps up with playback.
166
+ *
167
+ * Measured on this box (8 cores, libx264 -preset veryfast, a 4K HEVC source):
168
+ * 4K out ran at 0.52x real time, 1080p out at 1.65x. An encode slower than
169
+ * real time is a live channel that falls behind for ever and a film that
170
+ * stalls every few seconds, so a re-encode of anything taller comes down to
171
+ * this. Copying is exempt, and copying is the ordinary case.
172
+ */
173
+ export declare const MAX_TRANSCODE_HEIGHT = 1080;
110
174
  /**
111
175
  * How to get this file into a browser, given what is inside it.
112
176
  *
@@ -115,8 +179,13 @@ export declare function codecsOf(tools: Tools, path: string, input?: string[]):
115
179
  * is wrong. Rewrapping that costs nothing and looks identical; re-encoding it
116
180
  * would cost a core per viewer and look worse. So the streams decide, one part
117
181
  * at a time -- a film can have its video copied and only its DTS re-encoded.
182
+ *
183
+ * Resolution is deliberately not one of the deciders for a copy. 1080p and 4K
184
+ * H.264 out of a transport stream are copied exactly as 720p is, because the
185
+ * work of copying does not grow with the picture and a browser that can decode
186
+ * 4K should be given 4K.
118
187
  */
119
- export declare function videoArgs(codecs: Codecs, capKbps?: number): string[];
188
+ export declare function videoArgs(codecs: Codecs, capKbps?: number, options?: VideoOptions): string[];
120
189
  /**
121
190
  * The width that suits a bitrate.
122
191
  *
package/dist/audio.js CHANGED
@@ -10,6 +10,7 @@ import { spawn, spawnSync } from "node:child_process";
10
10
  import { readdirSync } from "node:fs";
11
11
  import { homedir } from "node:os";
12
12
  import { join } from "node:path";
13
+ import { isTransportStream } from "./sources.js";
13
14
  export const RATE = 44100;
14
15
  export const CHANNELS = 2;
15
16
  function works(argv, flag = "-version") {
@@ -364,7 +365,14 @@ export async function codecsOf(tools, path, input = []) {
364
365
  ...rest,
365
366
  "-v", "quiet",
366
367
  "-print_format", "json",
367
- "-show_entries", "format=format_name,duration:stream=codec_type,codec_name",
368
+ "-show_entries", "format=format_name,duration:stream=codec_type,codec_name,width,height",
369
+ // A transport stream needs looking further into than a file with an
370
+ // index does: there is no header listing the tracks, only packets, and
371
+ // a 4K recording can carry a second of null padding and a long gap to
372
+ // its first keyframe. ffprobe's default gives up before the picture on
373
+ // exactly the recordings this is for, and "no video stream" is how a
374
+ // film comes back as its own soundtrack.
375
+ ...transportProbeArgs(path),
368
376
  // Headers the source's site expects, for a link resolved by yt-dlp.
369
377
  ...input,
370
378
  path,
@@ -381,11 +389,14 @@ export async function codecsOf(tools, path, input = []) {
381
389
  // ffprobe prints seconds as a string, and "N/A" for a stream with no
382
390
  // end; both of those read as 0.
383
391
  const seconds = Number(parsed.format?.duration ?? 0);
392
+ const picture = streams.find((s) => s.codec_type === "video");
384
393
  return done({
385
- video: streams.find((s) => s.codec_type === "video")?.codec_name ?? "",
394
+ video: picture?.codec_name ?? "",
386
395
  audio: streams.find((s) => s.codec_type === "audio")?.codec_name ?? "",
387
396
  container: parsed.format?.format_name ?? "",
388
397
  duration: Number.isFinite(seconds) && seconds > 0 ? seconds : 0,
398
+ ...(typeof picture?.width === "number" && picture.width > 0 ? { width: picture.width } : {}),
399
+ ...(typeof picture?.height === "number" && picture.height > 0 ? { height: picture.height } : {}),
389
400
  });
390
401
  }
391
402
  catch {
@@ -394,6 +405,57 @@ export async function codecsOf(tools, path, input = []) {
394
405
  });
395
406
  });
396
407
  }
408
+ /** How much of a transport stream is read before deciding what is in it. */
409
+ export const TRANSPORT_PROBE_BYTES = 20 * 1024 * 1024;
410
+ export const TRANSPORT_ANALYSE_US = 10_000_000;
411
+ /**
412
+ * What to tell ffmpeg or ffprobe before it opens a transport stream.
413
+ *
414
+ * A `.ts` has no index and no header: it is packets, and the tracks are
415
+ * whatever turns up in them. The defaults are tuned for a file that describes
416
+ * itself, so a 4K recording -- padded with null packets, seconds between
417
+ * keyframes, sometimes several programmes -- gets read as having no picture,
418
+ * or no sound, or neither. Reading twenty megabytes before deciding costs a
419
+ * fraction of a second on a local disk and is the difference between a
420
+ * television recording and "nothing to play here".
421
+ *
422
+ * `+genpts` is for the other half of it: a recording that starts mid-stream
423
+ * has no timestamp on its first frames, and a fragmented MP4 built out of
424
+ * those has a duration of nothing and a seek bar that does not move.
425
+ * `+discardcorrupt` drops the half-packet at a cut rather than passing
426
+ * rubbish to the decoder.
427
+ */
428
+ export function transportProbeArgs(path, container = "") {
429
+ if (!isTransportSource(path, container))
430
+ return [];
431
+ return ["-probesize", String(TRANSPORT_PROBE_BYTES), "-analyzeduration", String(TRANSPORT_ANALYSE_US)];
432
+ }
433
+ /** The same, for a decode rather than a probe: the timestamps matter too. */
434
+ export function transportInputArgs(path, container = "") {
435
+ if (!isTransportSource(path, container))
436
+ return [];
437
+ return [...transportProbeArgs(path, container), "-fflags", "+genpts+discardcorrupt"];
438
+ }
439
+ /**
440
+ * Whether this source is a transport stream, by its name or by what a probe
441
+ * already found in it. The container is the better answer where there is one:
442
+ * an IPTV URL ending in `/301` is an mpegts and says so nowhere in its name.
443
+ */
444
+ function isTransportSource(path, container = "") {
445
+ if (container.includes("mpegts"))
446
+ return true;
447
+ return isTransportStream(path);
448
+ }
449
+ /**
450
+ * The tallest re-encode that keeps up with playback.
451
+ *
452
+ * Measured on this box (8 cores, libx264 -preset veryfast, a 4K HEVC source):
453
+ * 4K out ran at 0.52x real time, 1080p out at 1.65x. An encode slower than
454
+ * real time is a live channel that falls behind for ever and a film that
455
+ * stalls every few seconds, so a re-encode of anything taller comes down to
456
+ * this. Copying is exempt, and copying is the ordinary case.
457
+ */
458
+ export const MAX_TRANSCODE_HEIGHT = 1080;
397
459
  /**
398
460
  * How to get this file into a browser, given what is inside it.
399
461
  *
@@ -402,14 +464,21 @@ export async function codecsOf(tools, path, input = []) {
402
464
  * is wrong. Rewrapping that costs nothing and looks identical; re-encoding it
403
465
  * would cost a core per viewer and look worse. So the streams decide, one part
404
466
  * at a time -- a film can have its video copied and only its DTS re-encoded.
467
+ *
468
+ * Resolution is deliberately not one of the deciders for a copy. 1080p and 4K
469
+ * H.264 out of a transport stream are copied exactly as 720p is, because the
470
+ * work of copying does not grow with the picture and a browser that can decode
471
+ * 4K should be given 4K.
405
472
  */
406
- export function videoArgs(codecs, capKbps = 0) {
473
+ export function videoArgs(codecs, capKbps = 0, options = {}) {
407
474
  // A ceiling means re-encoding whatever is there, because you cannot cap the
408
475
  // bitrate of a stream you are copying: copying is what "unchanged" means.
409
476
  if (capKbps > 0)
410
477
  return cappedArgs(capKbps);
411
- // What a browser can play inside MP4 without help.
412
- const keepVideo = codecs.video === "h264";
478
+ // What a browser can play inside MP4 without help -- and H.265, when the
479
+ // other end has said it can decode it, which saves re-encoding 4K.
480
+ const keepHevc = codecs.video === "hevc" && options.allowHevc === true;
481
+ const keepVideo = codecs.video === "h264" || keepHevc;
413
482
  // A transport stream's audio is never copied. Its AAC is ADTS-framed, which
414
483
  // MP4 refuses without a bitstream filter -- ffmpeg writes nothing at all and
415
484
  // says "Malformed AAC bitstream detected" -- and the track ffmpeg picks off
@@ -417,13 +486,23 @@ export function videoArgs(codecs, capKbps = 0) {
417
486
  // no browser plays. Re-encoding audio is cheap; this failing is total.
418
487
  const transportStream = codecs.container.includes("mpegts");
419
488
  const keepAudio = !transportStream && (codecs.audio === "aac" || codecs.audio === "mp3");
489
+ // A re-encode of something taller than this comes down to it, because an
490
+ // encode slower than real time is not a stream. A copy keeps its size.
491
+ const ceiling = options.maxHeight ?? MAX_TRANSCODE_HEIGHT;
492
+ const tooTall = !keepVideo && (codecs.height ?? 0) > ceiling;
420
493
  return [
421
494
  "-c:v", keepVideo ? "copy" : "libx264",
495
+ // H.265 in MP4 is `hvc1` to Safari and to every television; ffmpeg writes
496
+ // `hev1` by default, which Safari opens and then plays as a black panel.
497
+ ...(keepHevc ? ["-tag:v", "hvc1"] : []),
422
498
  // A keyframe every two seconds when encoding. A fragment starts on a
423
499
  // keyframe, so this is how soon a joiner sees a picture -- and an HLS
424
500
  // segment, which is cut on keyframes too, was ten seconds long on
425
501
  // x264's default and made a phone wait thirty before it played.
426
502
  ...(keepVideo ? [] : ["-preset", "veryfast", "-crf", "23", "-pix_fmt", "yuv420p", "-g", "48", "-keyint_min", "48", "-sc_threshold", "0"]),
503
+ // -2 keeps the aspect ratio and an even height, which H.264 requires; the
504
+ // min() never enlarges, so a 720p source asked for 1080p stays 720p.
505
+ ...(tooTall ? ["-vf", `scale=-2:'min(${ceiling},ih)'`] : []),
427
506
  "-c:a", keepAudio ? "copy" : "aac",
428
507
  ...(keepAudio ? [] : ["-b:a", "160k", "-ac", "2"]),
429
508
  "-f", "mp4",
@@ -3,6 +3,12 @@ import type { Readable } from "node:stream";
3
3
  export interface Listener {
4
4
  write(chunk: Buffer): boolean;
5
5
  end(): void;
6
+ /**
7
+ * How many bytes it has accepted and not yet sent, when it can say. A
8
+ * response's writableLength. Without it a listener that stopped reading
9
+ * is a buffer that grows until the channel ends.
10
+ */
11
+ pending?(): number;
6
12
  }
7
13
  export interface ChannelInfo {
8
14
  id: string;
@@ -10,8 +16,8 @@ export interface ChannelInfo {
10
16
  name: string;
11
17
  /** The container it is sending, e.g. webm from a browser, flv over RTMP. */
12
18
  format: string;
13
- /** How it arrived. */
14
- via: "http" | "rtmp" | "pull";
19
+ /** How it arrived. `relay` is another nixamp's channel, decoded from its envelope. */
20
+ via: "http" | "rtmp" | "pull" | "relay";
15
21
  startedAt: number;
16
22
  bytes: number;
17
23
  listeners: number;
@@ -40,7 +46,18 @@ export interface ChannelInfo {
40
46
  audio: string;
41
47
  container: string;
42
48
  duration?: number;
49
+ width?: number;
50
+ height?: number;
43
51
  };
52
+ /**
53
+ * What the channel itself is producing, which is not always what its source
54
+ * holds: an H.265 source is usually re-encoded to H.264 on the way out,
55
+ * because a channel has one encode and an audience that does not all
56
+ * decode the same things. Anything downstream -- the HLS packager above
57
+ * all, which has to choose between transport and fMP4 segments -- has to
58
+ * ask this rather than the source's codecs.
59
+ */
60
+ emits?: string;
44
61
  /**
45
62
  * The nixamp.com account that put it on the air, when a member did rather
46
63
  * than the owner. Theirs to take off again, and counted against how many
@@ -83,6 +100,38 @@ export declare const IDLE = 60000;
83
100
  */
84
101
  export declare const BACKLOG_VIDEO: number;
85
102
  export declare const BACKLOG_AUDIO: number;
103
+ /**
104
+ * How far behind a listener may fall before it is let go. Sixteen
105
+ * megabytes is half a minute of 720p television that a socket has accepted
106
+ * and not delivered: nobody is watching that, and every byte of it was
107
+ * sitting in this process. Before this a stalled listener's buffer grew
108
+ * until the channel ended, however long that was.
109
+ */
110
+ export declare const LISTENER_QUEUE: number;
111
+ /**
112
+ * The backlog is really a number of seconds, and four megabytes was that
113
+ * number for the stream we happened to have.
114
+ *
115
+ * Six seconds of 720p is about 4 MB. Six seconds of a 1080p transport stream
116
+ * copied straight through is nearer 12, and of 4K nearer 30 -- so a fixed
117
+ * 4 MB hands a 4K joiner under a second of video, which is the live edge with
118
+ * no cushion, which is the play-wait-play loop the backlog exists to prevent.
119
+ * So the cap follows the stream: seconds times the rate it is actually
120
+ * running at, between the old floor and a ceiling that keeps a channel's
121
+ * memory bounded whatever it is carrying.
122
+ */
123
+ export declare const BACKLOG_SECONDS = 6;
124
+ export declare const BACKLOG_VIDEO_MAX: number;
125
+ /**
126
+ * How long a rate is measured over before it is believed.
127
+ *
128
+ * The first seconds of a pull are not a bitrate: ffmpeg opens the source,
129
+ * reads ahead, and empties what it has as fast as the pipe takes it. Sizing a
130
+ * buffer off that burst would reserve tens of megabytes for a stream that
131
+ * turns out to be a podcast. A window is measured, and until one has closed
132
+ * the floor stands.
133
+ */
134
+ export declare const RATE_WINDOW_MS = 5000;
86
135
  /** A name that can sit in a URL and be read back in a list. */
87
136
  export declare function cleanId(value: unknown, fallback?: string): string;
88
137
  export interface ChannelOptions {
@@ -91,6 +140,10 @@ export interface ChannelOptions {
91
140
  onEnd?: (info: ChannelInfo) => void;
92
141
  /** How long an on-demand channel outlives its last viewer. Tests shorten it. */
93
142
  idleMs?: number;
143
+ /** Unsent bytes a listener may hold before it is dropped. Tests shrink it. */
144
+ maxListenerQueueBytes?: number;
145
+ /** How long a rate is measured over before the backlog is sized off it. Tests shorten it. */
146
+ rateWindowMs?: number;
94
147
  }
95
148
  /**
96
149
  * One live source, and its audience.
@@ -128,6 +181,10 @@ export declare class Channel {
128
181
  */
129
182
  private recent;
130
183
  private recentBytes;
184
+ /** The rate window: when it opened, what has arrived in it, and what the last closed one measured. */
185
+ private rateStart;
186
+ private rateBytes;
187
+ private rate;
131
188
  /**
132
189
  * Started for whoever asked and stopped when nobody is left. A catalog
133
190
  * channel is one of thousands; keeping every one that was ever clicked
@@ -193,6 +250,15 @@ export declare class Channel {
193
250
  * make sense.
194
251
  */
195
252
  private emit;
253
+ /** Watch how fast this channel is actually running, a window at a time. */
254
+ private measure;
255
+ /**
256
+ * Six seconds of whatever this channel turned out to be, within bounds.
257
+ *
258
+ * Unmeasured -- the first window of a pull, or a channel that has only just
259
+ * started -- means the floor, which is what every channel had before.
260
+ */
261
+ private backlogCap;
196
262
  /** Keep this for the next arrival, and let the oldest go once it is too much. */
197
263
  private remember;
198
264
  /** Feed the source. */
@@ -204,8 +270,34 @@ export declare class Channel {
204
270
  * simply a different process that did it.
205
271
  */
206
272
  feed(chunk: Buffer): void;
207
- /** Write to everyone, and drop anybody whose socket has gone. */
273
+ /**
274
+ * Write to everyone, and drop anybody whose socket has gone -- or has
275
+ * stopped taking anything. A write that returns false is ordinary: the
276
+ * socket is a little behind and will catch up. One that returns false
277
+ * with a queue past the limit is a listener that is not reading, and
278
+ * ending it is the only thing that stops its queue growing.
279
+ */
208
280
  private send;
281
+ /**
282
+ * What a new listener is written before the live bytes: the opening
283
+ * boxes when there are any, then the recent backlog. The same rule as
284
+ * `listen`, handed out so a relay can compress it for one receiver.
285
+ */
286
+ opening(): Buffer[];
287
+ /**
288
+ * Bytes decoded from another nixamp's relay: the channel's own output as
289
+ * it was there, so they go out here exactly as ffmpeg's would, whole
290
+ * boxes at a time with the backlog kept.
291
+ */
292
+ receive(chunk: Buffer): void;
293
+ /** Ready a channel that will be fed by `receive`: pictures need their boxes tracked. */
294
+ prepare(): void;
295
+ /**
296
+ * The feed behind `receive` started over: a new generation upstream, with
297
+ * new opening boxes. Everybody listening is ended, as they are when our
298
+ * own ffmpeg is dialled again, and a newcomer gets the new beginning.
299
+ */
300
+ rollover(): void;
209
301
  listen(listener: Listener): () => void;
210
302
  /** Stay up with nobody watching: no longer on demand. */
211
303
  keep(): void;
@@ -282,6 +374,16 @@ export declare class Channels {
282
374
  * decoded would double the work to arrive at the same bytes.
283
375
  */
284
376
  attach(id: string, name: string, format: string, via: ChannelInfo["via"]): Channel | null;
377
+ /**
378
+ * A channel carried in from another nixamp's relay. Like `attach`, no
379
+ * ffmpeg of our own; unlike it, the kind is known up front, so a picture
380
+ * gets its fragment tracking and a newcomer gets the opening boxes.
381
+ */
382
+ relayIn(id: string, name: string, kind: "audio" | "video", source: string): Channel | null;
383
+ /** What a new listener would be written first, for a relay's preface. */
384
+ opening(id: string): Buffer[];
385
+ /** The kind of a channel, for a relay to say what it is carrying. */
386
+ kindOf(id: string): "audio" | "video" | undefined;
285
387
  stop(id: string): boolean;
286
388
  stopAll(): void;
287
389
  }
@@ -314,6 +416,8 @@ export interface RememberedChannel {
314
416
  audio: string;
315
417
  container: string;
316
418
  duration?: number;
419
+ width?: number;
420
+ height?: number;
317
421
  };
318
422
  /** Where a film had got to, in seconds, so it picks up there. */
319
423
  position?: number;