nixamp 0.28.35 → 0.28.36

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 (41) hide show
  1. package/dist/audio.d.ts +1 -1
  2. package/dist/audio.js +2 -2
  3. package/dist/channels.d.ts +10 -17
  4. package/dist/channels.js +88 -85
  5. package/dist/fragments.d.ts +5 -0
  6. package/dist/fragments.js +51 -0
  7. package/dist/playlist-input.d.ts +26 -0
  8. package/dist/playlist-input.js +95 -0
  9. package/dist/server.js +6 -7
  10. package/package.json +1 -1
  11. package/src/audio.ts +2 -2
  12. package/src/channels.ts +83 -85
  13. package/src/fragments.ts +46 -0
  14. package/src/playlist-input.ts +99 -0
  15. package/src/server.ts +6 -6
  16. package/web/dist/assets/{ar-B8r60Lgd.js → ar-CRLmcpxV.js} +1 -1
  17. package/web/dist/assets/{cs-C_jKpXHz.js → cs-C7lpT9_7.js} +1 -1
  18. package/web/dist/assets/{da-qhb4qzGL.js → da-C6icvAjb.js} +1 -1
  19. package/web/dist/assets/{de-B1U3oniY.js → de-B6Viw_1k.js} +1 -1
  20. package/web/dist/assets/{es-jT9qQhJM.js → es-h3YK7MBo.js} +1 -1
  21. package/web/dist/assets/{fi-CD8a3RyK.js → fi-tF8W-0sw.js} +1 -1
  22. package/web/dist/assets/{fr-CBYEPF15.js → fr-CPVGzn8e.js} +1 -1
  23. package/web/dist/assets/{hi-DS7Jlgq3.js → hi-DiPAdtgw.js} +1 -1
  24. package/web/dist/assets/{hls-3VKVEQE3-BVtRqG0G.js → hls-3VKVEQE3-C_fSzE0p.js} +1 -1
  25. package/web/dist/assets/{hu-BZ__rJ87.js → hu-BWP27GQV.js} +1 -1
  26. package/web/dist/assets/{id-DkM6FK8S.js → id-C9v9_aJK.js} +1 -1
  27. package/web/dist/assets/index-_aGaTsm2.js +3 -0
  28. package/web/dist/assets/index-jLCo7PIA.css +1 -0
  29. package/web/dist/assets/{it-DmhEUJ4D.js → it-DtOTP5Bt.js} +1 -1
  30. package/web/dist/assets/{mpegts-DGMTjRf5.js → mpegts-DBK5aOjj.js} +1 -1
  31. package/web/dist/assets/{mpegts-LO6RVLD6-Cct2UCqZ.js → mpegts-LO6RVLD6-0zaiW5HW.js} +1 -1
  32. package/web/dist/assets/{nl-D-gXjVr5.js → nl-CAX3A01l.js} +1 -1
  33. package/web/dist/assets/{ru-BbqAccEb.js → ru-DuLcjNsF.js} +1 -1
  34. package/web/dist/assets/{sv-BMdjgsVR.js → sv-C3ZewPxY.js} +1 -1
  35. package/web/dist/assets/{uk-DLs9Y4CC.js → uk-D7PUipxA.js} +1 -1
  36. package/web/dist/assets/{vi-_Y4nCAgc.js → vi-wUJlLppj.js} +1 -1
  37. package/web/dist/assets/{zh-DIJTp7RL.js → zh-Bd_9PHqO.js} +1 -1
  38. package/web/dist/index.html +2 -2
  39. package/web/dist/sw.js +23 -23
  40. package/web/dist/assets/index-BaDD_PRM.css +0 -1
  41. package/web/dist/assets/index-Dkxbx4QN.js +0 -3
package/dist/audio.d.ts CHANGED
@@ -137,7 +137,7 @@ export interface Codecs {
137
137
  * answering other requests, and a synchronous probe per media request is how
138
138
  * the whole library came to be tagged with the process wedged solid.
139
139
  */
140
- export declare function codecsOf(tools: Tools, path: string, input?: string[]): Promise<Codecs>;
140
+ export declare function codecsOf(tools: Tools, path: string, input?: string[], signal?: AbortSignal): Promise<Codecs>;
141
141
  /**
142
142
  * The title, artist and album a file carries in its tags, however the
143
143
  * container spells the keys (ID3 says TIT2 but ffprobe says title; MP4 and
package/dist/audio.js CHANGED
@@ -356,7 +356,7 @@ function readTags(stdout, fallback) {
356
356
  * answering other requests, and a synchronous probe per media request is how
357
357
  * the whole library came to be tagged with the process wedged solid.
358
358
  */
359
- export async function codecsOf(tools, path, input = []) {
359
+ export async function codecsOf(tools, path, input = [], signal) {
360
360
  const [cmd, ...rest] = tools.ffprobe;
361
361
  const empty = { video: "", audio: "", container: "" };
362
362
  if (!cmd)
@@ -380,7 +380,7 @@ export async function codecsOf(tools, path, input = []) {
380
380
  // Headers the source's site expects, for a link resolved by yt-dlp.
381
381
  ...input,
382
382
  path,
383
- ], { stdio: ["ignore", "pipe", "ignore"] });
383
+ ], { stdio: ["ignore", "pipe", "ignore"], signal });
384
384
  let out = "";
385
385
  child.stdout.on("data", (chunk) => {
386
386
  out += chunk.toString("utf8");
@@ -35,9 +35,9 @@ export interface ChannelInfo {
35
35
  redials?: number;
36
36
  /**
37
37
  * For a channel that plays a list: the entries, in order, and which one is
38
- * on. When one ends the next is dialled at once, and the last is followed
39
- * by the first: a station, not a file. A restart picks up at the entry
40
- * that was on.
38
+ * on. Local files are normalized into one continuous stream. After the
39
+ * final entry the show ends. A restart picks up at the saved entry and
40
+ * position within it.
41
41
  */
42
42
  playlist?: string[];
43
43
  playlistAt?: number;
@@ -104,6 +104,7 @@ export interface PullResume {
104
104
  position: number;
105
105
  /** For a list of things rather than one: every entry, in order. */
106
106
  playlist?: string[];
107
+ playlistAt?: number;
107
108
  }
108
109
  /**
109
110
  * A source read by us rather than by ffmpeg: what to tell ffmpeg it is,
@@ -159,18 +160,9 @@ export declare const BACKLOG_AUDIO: number;
159
160
  * until the channel ended, however long that was.
160
161
  */
161
162
  export declare const LISTENER_QUEUE: number;
162
- /**
163
- * The backlog is really a number of seconds, and four megabytes was that
164
- * number for the stream we happened to have.
165
- *
166
- * Six seconds of 720p is about 4 MB. Six seconds of a 1080p transport stream
167
- * copied straight through is nearer 12, and of 4K nearer 30 -- so a fixed
168
- * 4 MB hands a 4K joiner under a second of video, which is the live edge with
169
- * no cushion, which is the play-wait-play loop the backlog exists to prevent.
170
- * So the cap follows the stream: seconds times the rate it is actually
171
- * running at, between the old floor and a ceiling that keeps a channel's
172
- * memory bounded whatever it is carrying.
173
- */
163
+ /** Media-time window for complete MP4 fragments. Byte limits are a separate
164
+ * memory ceiling; the measured byte rate is only a fallback for inputs with
165
+ * no usable fragment timestamps. It must never make low-bitrate video old. */
174
166
  export declare const BACKLOG_SECONDS = 6;
175
167
  export declare const BACKLOG_VIDEO_MAX: number;
176
168
  /**
@@ -187,6 +179,7 @@ export declare const RATE_WINDOW_MS = 5000;
187
179
  export declare function cleanId(value: unknown, fallback?: string): string;
188
180
  export interface ChannelOptions {
189
181
  ffmpeg: string[];
182
+ ffprobe?: string[];
190
183
  onStart?: (info: ChannelInfo) => void;
191
184
  onEnd?: (info: ChannelInfo) => void;
192
185
  /**
@@ -250,6 +243,8 @@ export declare class Channel {
250
243
  * can begin at; for MP3 any point will do, a frame announces itself.
251
244
  */
252
245
  private recent;
246
+ private fragmentClock;
247
+ private fragmentTimes;
253
248
  private recentBytes;
254
249
  /** The rate window: when it opened, what has arrived in it, and what the last closed one measured. */
255
250
  private rateStart;
@@ -266,8 +261,6 @@ export declare class Channel {
266
261
  private readonly sourceTaps;
267
262
  /** Pulls the plug on the current read-through, when there is one. */
268
263
  private throughAbort;
269
- /** ffmpeg concat manifest for a local multi-file live. */
270
- private concatListPath;
271
264
  constructor(info: ChannelInfo, options: ChannelOptions, onGone: (id: string) => void);
272
265
  start(format: string): void;
273
266
  /**
package/dist/channels.js CHANGED
@@ -16,10 +16,10 @@
16
16
  */
17
17
  import { spawn } from "node:child_process";
18
18
  import { randomBytes } from "node:crypto";
19
- import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
20
- import { dirname, join } from "node:path";
21
- import { tmpdir } from "node:os";
22
- import { Fragments, isOpening } from "./fragments.js";
19
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ import { PlaylistInputStream } from "./playlist-input.js";
22
+ import { FragmentClock, Fragments, isOpening } from "./fragments.js";
23
23
  /**
24
24
  * How far back of the saved place a film is picked up from, in seconds. The
25
25
  * place is written down every so often and a restart lands between two
@@ -65,18 +65,9 @@ export const BACKLOG_AUDIO = 64 * 1024;
65
65
  * until the channel ended, however long that was.
66
66
  */
67
67
  export const LISTENER_QUEUE = 16 * 1024 * 1024;
68
- /**
69
- * The backlog is really a number of seconds, and four megabytes was that
70
- * number for the stream we happened to have.
71
- *
72
- * Six seconds of 720p is about 4 MB. Six seconds of a 1080p transport stream
73
- * copied straight through is nearer 12, and of 4K nearer 30 -- so a fixed
74
- * 4 MB hands a 4K joiner under a second of video, which is the live edge with
75
- * no cushion, which is the play-wait-play loop the backlog exists to prevent.
76
- * So the cap follows the stream: seconds times the rate it is actually
77
- * running at, between the old floor and a ceiling that keeps a channel's
78
- * memory bounded whatever it is carrying.
79
- */
68
+ /** Media-time window for complete MP4 fragments. Byte limits are a separate
69
+ * memory ceiling; the measured byte rate is only a fallback for inputs with
70
+ * no usable fragment timestamps. It must never make low-bitrate video old. */
80
71
  export const BACKLOG_SECONDS = 6;
81
72
  export const BACKLOG_VIDEO_MAX = 48 * 1024 * 1024;
82
73
  /**
@@ -166,6 +157,8 @@ export class Channel {
166
157
  * can begin at; for MP3 any point will do, a frame announces itself.
167
158
  */
168
159
  recent = [];
160
+ fragmentClock = new FragmentClock();
161
+ fragmentTimes = new WeakMap();
169
162
  recentBytes = 0;
170
163
  /** The rate window: when it opened, what has arrived in it, and what the last closed one measured. */
171
164
  rateStart = 0;
@@ -182,8 +175,6 @@ export class Channel {
182
175
  sourceTaps = new Set();
183
176
  /** Pulls the plug on the current read-through, when there is one. */
184
177
  throughAbort = null;
185
- /** ffmpeg concat manifest for a local multi-file live. */
186
- concatListPath = null;
187
178
  constructor(info, options, onGone) {
188
179
  this.info = info;
189
180
  this.options = options;
@@ -240,44 +231,29 @@ export class Channel {
240
231
  this.fragments = new Fragments();
241
232
  const [command, ...prefix] = this.options.ffmpeg;
242
233
  this.info.live = resume.live;
243
- if (!resume.live)
234
+ if (!resume.live || resume.playlist?.length)
244
235
  this.info.position = Math.max(0, resume.position);
245
236
  // A list plays entry by entry: the one that is on is what gets dialled,
246
237
  // and an entry that ended is followed by the next, at once.
247
238
  const list = resume.playlist && resume.playlist.length > 0 ? resume.playlist : null;
248
- // A local directory live must be one ffmpeg input. Starting a new ffmpeg
249
- // process for every file makes browsers see a new MP4 stream at each
250
- // boundary, even when the HTTP listener stays attached. The concat
251
- // demuxer keeps timestamps and the output pipe continuous.
252
- const concatList = list && list.length > 1 && list.every((one) => !/^https?:\/\//i.test(one))
253
- ? list
254
- : null;
255
- if (this.concatListPath) {
256
- rmSync(this.concatListPath, { force: true });
257
- this.concatListPath = null;
258
- }
259
- if (concatList) {
260
- const dir = mkdtempSync(join(tmpdir(), "nixamp-playlist-"));
261
- this.concatListPath = join(dir, "playlist.txt");
262
- const quote = (one) => one.replaceAll("'", "'\\''");
263
- writeFileSync(this.concatListPath, concatList.map((one) => `file '${quote(one)}'`).join("\n") + "\n");
264
- }
239
+ const localPlaylist = list && list.length > 1 && list.every(one => !/^https?:\/\//i.test(one)) ? list : null;
265
240
  let at = 0;
266
241
  if (list) {
267
242
  this.info.playlist = list;
268
- at = Math.min(Math.max(0, this.info.playlistAt ?? 0), list.length - 1);
243
+ at = Math.min(Math.max(0, resume.playlistAt ?? this.info.playlistAt ?? 0), list.length - 1);
269
244
  this.info.playlistAt = at;
270
245
  }
271
246
  let current = list ? list[at] : source;
272
247
  // The last entry is the end of the show, not a way back to the first:
273
248
  // a list that played through is over, and says so with the outro.
274
- this.advance = list && list.length > 1 && !concatList
249
+ this.advance = list && list.length > 1 && !localPlaylist
275
250
  ? () => {
276
251
  if (at + 1 >= list.length)
277
252
  return false;
278
253
  at += 1;
279
254
  this.info.playlistAt = at;
280
255
  current = list[at];
256
+ this.info.position = 0;
281
257
  return true;
282
258
  }
283
259
  : null;
@@ -296,7 +272,7 @@ export class Channel {
296
272
  // the place was written down a moment ago and a moment of it twice is
297
273
  // better than a moment of it missing. A live source is joined as is,
298
274
  // and a film that has barely started is started.
299
- const from = resume.live ? 0 : Math.max(0, Math.floor((this.info.position ?? 0) - REWIND));
275
+ const from = resume.live && !list ? 0 : Math.max(0, Math.floor((this.info.position ?? 0) - REWIND));
300
276
  const seek = from > 0 ? ["-ss", String(from)] : [];
301
277
  // Read the source here rather than in ffmpeg, when a policy wants the
302
278
  // original bytes and the source is the kind that can be. ffmpeg then
@@ -306,26 +282,19 @@ export class Channel {
306
282
  this.throughAbort?.abort();
307
283
  this.throughAbort = null;
308
284
  // The outro is a file read by ffmpeg itself: a pipe cannot loop.
309
- const through = this.outroOn ? null : this.options.through?.(this.info, from, input, audio) ?? null;
310
- this.info.teed = through !== null;
311
- const concatInput = this.concatListPath
312
- ? [
313
- ...(paced ? ["-re"] : []),
314
- // Directory lives may contain different codecs, dimensions, or
315
- // timestamp bases. Copying the first file's streams can make
316
- // ffmpeg reject later entries and leave the audience on entry 1.
317
- // The output settings below normalize the entire concat stream.
318
- "-f", "concat", "-safe", "0", "-i", this.concatListPath,
319
- ]
320
- : null;
321
- const concatEncode = this.concatListPath && this.info.kind === "video"
322
- ? [
323
- "-c:v", "libx264", "-preset", "veryfast", "-crf", "23",
324
- "-pix_fmt", "yuv420p", "-g", "48", "-keyint_min", "48", "-sc_threshold", "0",
325
- "-c:a", "aac", "-b:a", "160k", "-ac", "2",
326
- "-f", "mp4", "-movflags", "frag_keyframe+empty_moov+default_base_moof",
327
- ]
328
- : null;
285
+ const playlistInput = localPlaylist ? new PlaylistInputStream({
286
+ ffmpeg: this.options.ffmpeg,
287
+ ffprobe: this.options.ffprobe ?? this.options.ffmpeg.map(one => one.replace(/ffmpeg(?=(?:\.exe)?$)/, "ffprobe")),
288
+ files: localPlaylist, at: this.info.playlistAt ?? 0, position: from,
289
+ video: this.info.kind === "video", width: this.info.codecs?.width, height: this.info.codecs?.height,
290
+ }) : null;
291
+ const through = this.outroOn ? null : playlistInput
292
+ ? { format: "mpegts", open: async (signal) => playlistInput.open(signal) }
293
+ : this.options.through?.(this.info, from, input, audio) ?? null;
294
+ this.info.teed = through !== null && !playlistInput;
295
+ const playlistEncode = !playlistInput ? null : this.info.kind === "video"
296
+ ? ["-c", "copy", "-bsf:a", "aac_adtstoasc", "-f", "mp4", "-movflags", "frag_keyframe+empty_moov+default_base_moof"]
297
+ : ["-af", "aresample=async=1:first_pts=0", ...encode];
329
298
  const child = spawn(command, [
330
299
  ...prefix,
331
300
  "-hide_banner",
@@ -345,7 +314,7 @@ export class Channel {
345
314
  // headers, which only a source read through us leaves out,
346
315
  // are no use to a pipe and are not here (a source that needs
347
316
  // them is not read through us in the first place).
348
- ...input,
317
+ ...(playlistInput ? ["-probesize", "32768", "-analyzeduration", "1000000"] : input),
349
318
  "-f", through.format,
350
319
  ...(paced ? ["-re"] : []),
351
320
  "-i", "pipe:0",
@@ -366,12 +335,12 @@ export class Channel {
366
335
  // What the source's site expects on the request: a user agent, a
367
336
  // referer, a cookie. A link resolved by yt-dlp comes with these,
368
337
  // and a CDN that got them from yt-dlp and not from us answers 403.
369
- ...(concatInput ?? [...input, ...seek, "-i", current]),
338
+ ...input, ...seek, "-i", current,
370
339
  // The sound, when the site keeps it apart from the picture: a
371
340
  // second input, dialled the same way, that the encode maps in.
372
341
  ...(audio ? [...remoteArgs, ...(paced ? ["-re"] : []), ...input, ...seek, "-i", audio] : []),
373
342
  ]),
374
- ...(concatEncode ?? encode),
343
+ ...(playlistEncode ?? encode),
375
344
  "pipe:1",
376
345
  ], { stdio: [through ? "pipe" : "ignore", "pipe", "pipe", "pipe"] });
377
346
  let sent = false;
@@ -386,14 +355,22 @@ export class Channel {
386
355
  let progress = "";
387
356
  child.stdio[3]?.on("data", (chunk) => {
388
357
  progress = (progress + chunk.toString("utf8")).slice(-4000);
389
- if (this.child !== child || resume.live)
358
+ if (this.child !== child || (resume.live && !list))
390
359
  return;
391
360
  const lines = progress.split("\n");
392
361
  progress = lines.pop() ?? "";
393
362
  for (const line of lines) {
394
363
  const match = /^out_time_us=(\d+)/.exec(line.trim());
395
- if (match)
396
- this.info.position = from + Number(match[1]) / 1e6;
364
+ if (match) {
365
+ const seconds = Number(match[1]) / 1e6;
366
+ if (playlistInput) {
367
+ const place = playlistInput.place(seconds);
368
+ this.info.playlistAt = place.at;
369
+ this.info.position = place.position;
370
+ }
371
+ else
372
+ this.info.position = from + seconds;
373
+ }
397
374
  }
398
375
  });
399
376
  child.stdio[3]?.on("error", () => undefined);
@@ -401,6 +378,8 @@ export class Channel {
401
378
  // An ffmpeg that was replaced can still have a chunk in the pipe.
402
379
  if (this.child !== child)
403
380
  return;
381
+ if (!sent)
382
+ this.info.error = undefined;
404
383
  sent = true;
405
384
  this.info.bytes += chunk.byteLength;
406
385
  this.rearm(child);
@@ -411,9 +390,14 @@ export class Channel {
411
390
  // Only the ffmpeg we are currently running gets to say the source
412
391
  // dropped. One that was killed to make way for a restart is not news.
413
392
  child.on("error", () => { if (this.child === child)
414
- this.dropped(sent); });
415
- child.on("close", () => { if (this.child === child)
416
- this.dropped(sent); });
393
+ this.dropped(sent, false); });
394
+ child.on("close", (code, signal) => {
395
+ if (this.child !== child)
396
+ return;
397
+ if (playlistInput?.error)
398
+ this.stderr = playlistInput.error.message;
399
+ this.dropped(sent, code === 0 && !signal && (!playlistInput || playlistInput.complete));
400
+ });
417
401
  };
418
402
  this.redial = dial;
419
403
  dial();
@@ -435,6 +419,7 @@ export class Channel {
435
419
  const kind = this.info.kind ?? "audio";
436
420
  const old = this.child;
437
421
  this.child = null;
422
+ this.throughAbort?.abort();
438
423
  if (this.watchdog)
439
424
  clearTimeout(this.watchdog);
440
425
  this.watchdog = null;
@@ -485,7 +470,7 @@ export class Channel {
485
470
  this.info.playlistAt = 0;
486
471
  this.startOver();
487
472
  const show = this.dialed;
488
- this.pull(show.source, show.encode, show.paced, show.stall, show.input, show.audio, { ...show.resume, position: 0 });
473
+ this.pull(show.source, show.encode, show.paced, show.stall, show.input, show.audio, { ...show.resume, position: 0, playlistAt: 0 });
489
474
  return true;
490
475
  }
491
476
  const dial = this.redial;
@@ -522,6 +507,8 @@ export class Channel {
522
507
  if (this.info.kind === "video")
523
508
  this.fragments = new Fragments();
524
509
  this.recent = [];
510
+ this.fragmentClock = new FragmentClock();
511
+ this.fragmentTimes = new WeakMap();
525
512
  this.recentBytes = 0;
526
513
  // A new source may be a different size of stream, and the rate measured
527
514
  // off the old one is not evidence about this one.
@@ -581,7 +568,7 @@ export class Channel {
581
568
  * worth dialling again, and a URL that has never once produced a byte is a
582
569
  * mistake somebody made, and retrying it for ever helps nobody.
583
570
  */
584
- dropped(sent) {
571
+ dropped(sent, clean = false) {
585
572
  if (this.closing || !this.redial)
586
573
  return;
587
574
  // An outro that stopped is over; there is nothing after it.
@@ -590,28 +577,28 @@ export class Channel {
590
577
  return;
591
578
  }
592
579
  this.child = null;
580
+ this.throughAbort?.abort();
593
581
  if (this.watchdog)
594
582
  clearTimeout(this.watchdog);
595
583
  this.watchdog = null;
596
584
  const said = lastLine(this.stderr);
597
585
  if (said)
598
586
  this.info.error = said;
599
- this.failures = sent ? 0 : this.failures + 1;
587
+ this.failures = sent && clean ? 0 : this.failures + 1;
600
588
  if (this.failures >= GIVE_UP) {
601
589
  this.close();
602
590
  return;
603
591
  }
604
- // A list moves on. An entry that played to its end is not a source that
605
- // dropped: the next is dialled now, and it is not a redial. One that
606
- // gave nothing is skipped the same way, counted as the failure it was,
607
- // so a list of dead links gives up rather than cycling for ever.
608
- const moved = this.advance?.() ?? false;
592
+ // Only a clean EOF advances a per-URL playlist. Decoder errors, a
593
+ // watchdog kill, or a failed normalized input recover at the saved place
594
+ // with bounded retries; an MP4 header alone is not a completed show.
595
+ const moved = clean ? this.advance?.() ?? false : false;
609
596
  // A show that ended: a film or a podcast that played to its end, a
610
597
  // list whose last entry did. That is not a source that dropped, and it
611
598
  // is not dialled again from the top; it is over, and the outro says
612
599
  // so. A live feed that stopped is a feed that dropped, and is redialled.
613
600
  const list = (this.info.playlist?.length ?? 0) > 0;
614
- if (sent && !moved && (this.info.live === false || list)) {
601
+ if (clean && sent && !moved && (this.info.live === false || list)) {
615
602
  void this.endShow();
616
603
  return;
617
604
  }
@@ -649,8 +636,23 @@ export class Channel {
649
636
  }
650
637
  const cap = this.backlogCap();
651
638
  for (const box of this.fragments.push(chunk)) {
639
+ const time = this.fragmentClock.read(box);
640
+ if (time !== null)
641
+ this.fragmentTimes.set(box, time);
652
642
  if (!isOpening(boxType(box)))
653
- this.remember(box, cap, true);
643
+ this.remember(box, time === null ? cap : BACKLOG_VIDEO_MAX, true);
644
+ if (time !== null) {
645
+ // Drop complete old fragments even when six seconds occupy only a
646
+ // few kilobytes (slides, static cameras, or a paused game screen).
647
+ while (this.recent.length > 0) {
648
+ const oldest = this.fragmentTimes.get(this.recent[0]);
649
+ if (oldest === undefined || time - oldest < BACKLOG_SECONDS)
650
+ break;
651
+ do {
652
+ this.recentBytes -= this.recent.shift().byteLength;
653
+ } while (this.recent.length && boxType(this.recent[0]) !== "moof");
654
+ }
655
+ }
654
656
  this.send(box);
655
657
  }
656
658
  }
@@ -830,8 +832,13 @@ export class Channel {
830
832
  // Wait for ffmpeg to take it, or for the pipe to go: a pipe that
831
833
  // closed never drains, and waiting on it would hold the read open.
832
834
  await new Promise((done) => {
833
- stdin.once("drain", done);
834
- stdin.once("close", done);
835
+ const finish = () => {
836
+ stdin.off("drain", finish);
837
+ stdin.off("close", finish);
838
+ done();
839
+ };
840
+ stdin.once("drain", finish);
841
+ stdin.once("close", finish);
835
842
  });
836
843
  }
837
844
  }
@@ -929,10 +936,6 @@ export class Channel {
929
936
  this.child = null;
930
937
  this.throughAbort?.abort();
931
938
  this.throughAbort = null;
932
- if (this.concatListPath) {
933
- rmSync(dirname(this.concatListPath), { force: true, recursive: true });
934
- this.concatListPath = null;
935
- }
936
939
  this.endTaps();
937
940
  try {
938
941
  child?.stdin?.end();
@@ -1240,7 +1243,7 @@ export function rememberedNow(channels) {
1240
1243
  kept.codecs = one.codecs;
1241
1244
  if (typeof one.live === "boolean")
1242
1245
  kept.live = one.live;
1243
- if (!one.live && typeof one.position === "number" && one.position > 0)
1246
+ if ((!one.live || one.playlist?.length) && typeof one.position === "number" && one.position > 0)
1244
1247
  kept.position = Math.floor(one.position);
1245
1248
  if (one.startedBy)
1246
1249
  kept.startedBy = one.startedBy;
@@ -35,6 +35,11 @@ export declare function firstBox(buffer: Buffer): {
35
35
  } | null;
36
36
  /** The boxes that describe the stream rather than carry it. */
37
37
  export declare function isOpening(type: string): boolean;
38
+ /** Decode timestamps use each track's own timescale, never its byte rate. */
39
+ export declare class FragmentClock {
40
+ private scales;
41
+ read(box: Buffer): number | null;
42
+ }
38
43
  /**
39
44
  * A fragmented MP4 arriving in pieces, handed back a box at a time.
40
45
  *
package/dist/fragments.js CHANGED
@@ -60,6 +60,57 @@ export function firstBox(buffer) {
60
60
  export function isOpening(type) {
61
61
  return type === "ftyp" || type === "moov";
62
62
  }
63
+ function children(box) {
64
+ let rest = box.subarray(box.readUInt32BE(0) === 1 ? BIG : HEADER);
65
+ const found = [];
66
+ for (;;) {
67
+ const next = firstBox(rest);
68
+ if (!next)
69
+ return found;
70
+ found.push(next.box);
71
+ rest = next.rest;
72
+ }
73
+ }
74
+ /** Decode timestamps use each track's own timescale, never its byte rate. */
75
+ export class FragmentClock {
76
+ scales = new Map();
77
+ read(box) {
78
+ const type = box.toString("latin1", 4, 8);
79
+ if (type === "moov") {
80
+ for (const track of children(box).filter(one => one.type === "trak")) {
81
+ const parts = children(track.bytes);
82
+ const tkhd = parts.find(one => one.type === "tkhd")?.bytes;
83
+ const mdia = parts.find(one => one.type === "mdia")?.bytes;
84
+ const mdhd = mdia && children(mdia).find(one => one.type === "mdhd")?.bytes;
85
+ if (!tkhd || !mdhd)
86
+ continue;
87
+ const idAt = tkhd[8] === 1 ? 28 : 20;
88
+ const scaleAt = mdhd[8] === 1 ? 28 : 20;
89
+ if (tkhd.length < idAt + 4 || mdhd.length < scaleAt + 4)
90
+ continue;
91
+ const scale = mdhd.readUInt32BE(scaleAt);
92
+ if (scale)
93
+ this.scales.set(tkhd.readUInt32BE(idAt), scale);
94
+ }
95
+ }
96
+ if (type !== "moof")
97
+ return null;
98
+ const times = [];
99
+ for (const track of children(box).filter(one => one.type === "traf")) {
100
+ const parts = children(track.bytes);
101
+ const tfhd = parts.find(one => one.type === "tfhd")?.bytes;
102
+ const tfdt = parts.find(one => one.type === "tfdt")?.bytes;
103
+ if (!tfhd || tfhd.length < 16 || !tfdt || tfdt.length < (tfdt[8] === 1 ? 20 : 16))
104
+ continue;
105
+ const scale = this.scales.get(tfhd.readUInt32BE(12));
106
+ if (!scale)
107
+ continue;
108
+ const time = tfdt[8] === 1 ? Number(tfdt.readBigUInt64BE(12)) : tfdt.readUInt32BE(12);
109
+ times.push(time / scale);
110
+ }
111
+ return times.length ? Math.min(...times) : null;
112
+ }
113
+ }
63
114
  /**
64
115
  * A fragmented MP4 arriving in pieces, handed back a box at a time.
65
116
  *
@@ -0,0 +1,26 @@
1
+ export interface PlaylistInput {
2
+ ffmpeg: string[];
3
+ ffprobe: string[];
4
+ files: string[];
5
+ at: number;
6
+ position: number;
7
+ video: boolean;
8
+ width?: number;
9
+ height?: number;
10
+ }
11
+ /** Decode each file independently; feed one persistent muxer a fixed stream
12
+ * profile and continuous timestamps. A concat demuxer cannot decode a list
13
+ * whose input codecs differ. Only one entry is encoded at a time, with pipe
14
+ * backpressure bounding read-ahead independently of the directory's size. */
15
+ export declare class PlaylistInputStream {
16
+ private readonly options;
17
+ private entries;
18
+ error: Error | null;
19
+ complete: boolean;
20
+ constructor(options: PlaylistInput);
21
+ place(seconds: number): {
22
+ at: number;
23
+ position: number;
24
+ };
25
+ open(signal: AbortSignal): AsyncGenerator<Buffer>;
26
+ }
@@ -0,0 +1,95 @@
1
+ import { spawn } from "node:child_process";
2
+ import { codecsOf } from "./audio.js";
3
+ /** Decode each file independently; feed one persistent muxer a fixed stream
4
+ * profile and continuous timestamps. A concat demuxer cannot decode a list
5
+ * whose input codecs differ. Only one entry is encoded at a time, with pipe
6
+ * backpressure bounding read-ahead independently of the directory's size. */
7
+ export class PlaylistInputStream {
8
+ options;
9
+ entries = [];
10
+ error = null;
11
+ complete = false;
12
+ constructor(options) {
13
+ this.options = options;
14
+ }
15
+ place(seconds) {
16
+ const entry = this.entries.findLast(one => seconds >= one.start) ?? this.entries[0];
17
+ return entry ? { at: entry.at, position: Math.max(0, seconds - entry.start + entry.position) }
18
+ : { at: this.options.at, position: this.options.position };
19
+ }
20
+ async *open(signal) {
21
+ const o = this.options;
22
+ const [command, ...prefix] = o.ffmpeg;
23
+ const ratio = Math.min(1, 1280 / (o.width || 1280), 720 / (o.height || 720));
24
+ const width = Math.max(2, Math.floor((o.width || 1280) * ratio / 2) * 2);
25
+ const height = Math.max(2, Math.floor((o.height || 720) * ratio / 2) * 2);
26
+ let offset = 0;
27
+ try {
28
+ for (let at = o.at; at < o.files.length && !signal.aborted; at++) {
29
+ const file = o.files[at];
30
+ const codecs = await codecsOf({ ffmpeg: o.ffmpeg, ffprobe: o.ffprobe, play: null }, file, [], AbortSignal.any([signal, AbortSignal.timeout(10_000)]));
31
+ if (signal.aborted)
32
+ return;
33
+ if (!codecs.audio && !codecs.video)
34
+ throw new Error(`Cannot decode playlist entry ${at + 1}`);
35
+ const position = at === o.at ? o.position : 0;
36
+ const duration = (codecs.duration ?? 0) - position;
37
+ if (duration <= 0)
38
+ throw new Error(`No playable duration for playlist entry ${at + 1}`);
39
+ this.entries.push({ at, start: offset, position });
40
+ const hasVideo = Boolean(codecs.video);
41
+ const extra = o.video && !hasVideo
42
+ ? ["-f", "lavfi", "-i", `color=c=black:s=${width}x${height}:r=30`]
43
+ : o.video && !codecs.audio ? ["-f", "lavfi", "-i", "anullsrc=r=48000:cl=stereo"] : [];
44
+ const args = [
45
+ ...prefix, "-hide_banner", "-loglevel", "error", "-nostdin", "-filter_threads", "2",
46
+ "-threads", "2", ...(position > 0 ? ["-ss", String(position)] : []), "-i", file,
47
+ ...extra, "-t", String(duration),
48
+ ...(o.video ? [
49
+ "-map", hasVideo ? "0:v:0" : "1:v:0", "-map", codecs.audio ? "0:a:0" : "1:a:0",
50
+ "-vf", `fps=30,scale=${width}:${height}:force_original_aspect_ratio=decrease,pad=${width}:${height}:(ow-iw)/2:(oh-ih)/2,setsar=1,setpts=PTS-STARTPTS`,
51
+ "-c:v", "libx264", "-threads", "2", "-preset", "veryfast", "-tune", "zerolatency",
52
+ "-crf", "23", "-pix_fmt", "yuv420p", "-g", "60", "-keyint_min", "60", "-sc_threshold", "0",
53
+ ] : ["-map", "0:a:0", "-vn"]),
54
+ "-af", "aresample=48000,asetpts=PTS-STARTPTS", "-c:a", "aac", "-b:a", "160k", "-ac", "2",
55
+ "-mpegts_flags", "+initial_discontinuity", "-output_ts_offset", String(offset), "-mpegts_copyts", "1", "-muxdelay", "0", "-muxpreload", "0",
56
+ "-f", "mpegts", "pipe:1",
57
+ ];
58
+ const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
59
+ let tail = "";
60
+ child.stderr.on("data", chunk => { tail = (tail + chunk.toString()).slice(-1500); });
61
+ const closed = new Promise((resolve) => {
62
+ child.once("error", error => { tail = error.message; resolve(-1); });
63
+ child.once("close", code => resolve(code));
64
+ });
65
+ const abort = () => { child.kill("SIGKILL"); };
66
+ signal.addEventListener("abort", abort, { once: true });
67
+ try {
68
+ for await (const chunk of child.stdout) {
69
+ if (signal.aborted)
70
+ return;
71
+ yield chunk;
72
+ }
73
+ const code = await closed;
74
+ if (signal.aborted)
75
+ return;
76
+ if (code !== 0)
77
+ throw new Error(`Playlist entry ${at + 1} failed: ${tail.trim()}`);
78
+ }
79
+ finally {
80
+ signal.removeEventListener("abort", abort);
81
+ abort();
82
+ await closed;
83
+ }
84
+ // Match the frame grid used above so rounding cannot accumulate a
85
+ // backwards timestamp over hundreds of short files.
86
+ offset += o.video ? Math.ceil(duration * 30) / 30 : duration;
87
+ }
88
+ this.complete = !signal.aborted;
89
+ }
90
+ catch (error) {
91
+ this.error = error instanceof Error ? error : new Error(String(error));
92
+ throw this.error;
93
+ }
94
+ }
95
+ }