nixamp 0.16.0 → 0.17.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.
- 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 +152 -19
- package/dist/live-events.d.ts +57 -2
- package/dist/live-events.js +243 -11
- 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 +172 -17
- package/src/live-events.ts +269 -11
- 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/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:
|
|
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
|
-
|
|
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",
|
package/dist/channels.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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;
|