@torrent-tv/proxy 2.107.49 → 2.107.51

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.107.49",
3
+ "version": "2.107.51",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -58,6 +58,23 @@ export class WaitLedger {
58
58
  /** @type {Map<string, Map<string, number>>} */
59
59
  #counts = new Map();
60
60
 
61
+ /** @type {Map<string, object>} */
62
+ #owners = new Map();
63
+
64
+ /**
65
+ * Begin a live output-key lifetime. Reopening a key starts a clean history;
66
+ * the returned identity lets a held response prove that it still belongs to
67
+ * this lifetime when it eventually completes.
68
+ *
69
+ * @param {string} key
70
+ * @param {object} identity
71
+ */
72
+ open(key, identity) {
73
+ if (!key || !identity || this.#owners.get(key) === identity) return;
74
+ this.forget(key);
75
+ this.#owners.set(key, identity);
76
+ }
77
+
61
78
  /**
62
79
  * Somebody waited this long for something the map ranked this highly.
63
80
  *
@@ -66,11 +83,13 @@ export class WaitLedger {
66
83
  * @param {number} rank - The map's rank for the thing waited for.
67
84
  * @param {number} topRank - The highest rank the map states, so the rank can
68
85
  * be read as a position rather than as an absolute number.
86
+ * @param {object} [identity] - The output-key lifetime that started this wait.
69
87
  */
70
- note(key, waitedMs, rank, topRank) {
88
+ note(key, waitedMs, rank, topRank, identity = undefined) {
71
89
  if (!key || !Number.isFinite(waitedMs) || waitedMs < 0) {
72
90
  return;
73
91
  }
92
+ if (identity !== undefined && this.#owners.get(key) !== identity) return;
74
93
  const band = bandOf(rank, topRank);
75
94
  let byBand = this.#waits.get(key);
76
95
  if (!byBand) {
@@ -128,15 +147,26 @@ export class WaitLedger {
128
147
 
129
148
  /**
130
149
  * @param {string} key
150
+ * @param {object} [identity] - Ignore release from an older output lifetime.
131
151
  */
132
- forget(key) {
152
+ forget(key, identity = undefined) {
153
+ if (identity !== undefined && this.#owners.get(key) !== identity) return false;
133
154
  this.#waits.delete(key);
134
155
  this.#counts.delete(key);
156
+ if (identity === undefined || this.#owners.get(key) === identity) this.#owners.delete(key);
157
+ return true;
158
+ }
159
+
160
+ /** Release every record owned by this proxy before its shutdown completes. */
161
+ clear() {
162
+ this.#waits.clear();
163
+ this.#counts.clear();
164
+ this.#owners.clear();
165
+ }
166
+
167
+ /** Number of output keys with retained measurements. */
168
+ get size() {
169
+ return this.#waits.size;
135
170
  }
136
171
  }
137
172
 
138
- /**
139
- * One ledger for the process, because the question is asked in two layers and
140
- * the answer is comparable only if the scale is the same.
141
- */
142
- export const waits = new WaitLedger();
@@ -26,7 +26,11 @@ export class OutputCatalog {
26
26
 
27
27
  #byId = new Map();
28
28
  #lifetime = new Map();
29
+ /** @type {Map<string, object>} */
30
+ #waitIdentities = new Map();
29
31
  #now;
32
+ #waitIdentityStarted;
33
+ #waitIdentityEnded;
30
34
 
31
35
  /**
32
36
  * @param {object} [params]
@@ -35,11 +39,15 @@ export class OutputCatalog {
35
39
  * source file is, which the torrent reports.
36
40
  * @param {(address: string) => { index: number, size: number }} [params.largestPieceOf]
37
41
  * The biggest piece an output has made, which the disk knows.
42
+ * @param {(address: string, identity: object) => void} [params.waitIdentityStarted]
43
+ * @param {(address: string, identity: object) => void} [params.waitIdentityEnded]
38
44
  */
39
45
  constructor({
40
46
  now = Date.now,
41
47
  fileLengthOf = () => 0,
42
- largestPieceOf = () => ({ index: -1, size: 0 })
48
+ largestPieceOf = () => ({ index: -1, size: 0 }),
49
+ waitIdentityStarted = () => {},
50
+ waitIdentityEnded = () => {}
43
51
  } = {}) {
44
52
  this.#now = now;
45
53
  // Two facts this catalog needs and does not own, taken as plain functions
@@ -48,15 +56,22 @@ export class OutputCatalog {
48
56
  // it is part of the output itself (`OutputSpec`).
49
57
  this.fileLengthOf = fileLengthOf;
50
58
  this.largestPieceOf = largestPieceOf;
59
+ this.#waitIdentityStarted = waitIdentityStarted;
60
+ this.#waitIdentityEnded = waitIdentityEnded;
51
61
  }
52
62
 
53
63
  set(id, output) {
54
64
  const known = this.#byId.get(id);
65
+ const oldAddress = known?.outputKey;
55
66
  this.#byId.set(id, output);
56
67
  if (known !== output) {
57
68
  const at = this.#now();
58
69
  this.#lifetime.set(id, { startedAt: at, lastAccessedAt: at });
59
70
  }
71
+ if (known !== output && oldAddress !== output?.outputKey) {
72
+ this.#forgetWaitIdentityIfUnused(oldAddress);
73
+ }
74
+ this.#openWaitIdentity(output?.outputKey);
60
75
  return this;
61
76
  }
62
77
 
@@ -69,13 +84,42 @@ export class OutputCatalog {
69
84
  }
70
85
 
71
86
  delete(id) {
87
+ const output = this.#byId.get(id);
72
88
  this.#lifetime.delete(id);
73
- return this.#byId.delete(id);
89
+ const deleted = this.#byId.delete(id);
90
+ if (deleted) this.#forgetWaitIdentityIfUnused(output?.outputKey);
91
+ return deleted;
74
92
  }
75
93
 
76
94
  clear() {
95
+ const addresses = new Set([...this.#byId.values()].map(output => output?.outputKey).filter(Boolean));
77
96
  this.#lifetime.clear();
78
97
  this.#byId.clear();
98
+ for (const address of addresses) this.#forgetWaitIdentityIfUnused(address);
99
+ }
100
+
101
+ /** The shared active lifetime for the output addressed by a live session. */
102
+ waitIdentity(id) {
103
+ const output = this.#byId.get(id);
104
+ return output ? this.#waitIdentities.get(output.outputKey) ?? null : null;
105
+ }
106
+
107
+ #openWaitIdentity(address) {
108
+ if (typeof address !== "string" || !address || this.#waitIdentities.has(address)) return;
109
+ const identity = {};
110
+ this.#waitIdentities.set(address, identity);
111
+ this.#waitIdentityStarted(address, identity);
112
+ }
113
+
114
+ #forgetWaitIdentityIfUnused(address) {
115
+ if (typeof address !== "string" || !address) return;
116
+ for (const output of this.#byId.values()) {
117
+ if (output?.outputKey === address) return;
118
+ }
119
+ const identity = this.#waitIdentities.get(address);
120
+ if (!identity) return;
121
+ this.#waitIdentities.delete(address);
122
+ this.#waitIdentityEnded(address, identity);
79
123
  }
80
124
 
81
125
  touch(outputOrId, at = this.#now()) {
@@ -55,7 +55,7 @@ export class OutputLifecycle {
55
55
  #host;
56
56
 
57
57
  /**
58
- * @param {object} host - `retention`, `outputNeeded`, `outputWriting`, `outputReading`, `planEncodersSoon`, `now`, `invalidateWaits`, `segmentFormatOfKey`, `viewerSegmentsOn`, `budgetTimer`, `cleanupTimer`, `encodeRuns`, `keyframeTables`, `machineBudget`, `outputTimes`, `outputs`, `returns`, `segmentStore`, `sessionTtlMs`, `sourceFiles`, `timelines`, `viewers`
58
+ * @param {object} host - `retention`, `outputNeeded`, `outputWriting`, `outputReading`, `planEncodersSoon`, `now`, `invalidateWaits`, `clearWaits`, `segmentFormatOfKey`, `viewerSegmentsOn`, `budgetTimer`, `cleanupTimer`, `encodeRuns`, `keyframeTables`, `machineBudget`, `outputTimes`, `outputs`, `returns`, `segmentStore`, `sessionTtlMs`, `sourceFiles`, `timelines`, `viewers`
59
59
  */
60
60
  constructor(host) {
61
61
  this.#host = host;
@@ -267,6 +267,7 @@ export class OutputLifecycle {
267
267
  for (const sessionId of activeIds) {
268
268
  await this.disposeSession(sessionId);
269
269
  }
270
+ this.#host.clearWaits?.();
270
271
  // Everything this process owns, root included. See SegmentStore.dropAll.
271
272
  this.#host.segmentStore.dropAll("the proxy is shutting down");
272
273
  }
@@ -78,6 +78,20 @@ export class SegmentServing {
78
78
  state.waitListeners.clear();
79
79
  }
80
80
 
81
+ /** Identity of the live output-key lifetime named by this session. */
82
+ waitIdentity(sessionId) {
83
+ return this.#host.outputs.waitIdentity(sessionId);
84
+ }
85
+
86
+ /** Record a completed wait through the ledger operation wired by the server. */
87
+ noteWait(ranked, waitedMs, identity) {
88
+ this.#host.noteWait?.(ranked, waitedMs, identity);
89
+ }
90
+
91
+ waitBandOf(rank, topRank) {
92
+ return this.#host.waitBandOf?.(rank, topRank) ?? "later";
93
+ }
94
+
81
95
  /** Observe storage and lifecycle before a request checks file availability. */
82
96
  subscribeFileChange(sessionId) {
83
97
  const output = isOutputName(sessionId) ? this.#host.outputs.get(sessionId) : null;
@@ -154,7 +154,7 @@ export class ViewerRequests {
154
154
  #forecastPieces = new WeakMap();
155
155
 
156
156
  /**
157
- * @param {object} host - `opening`, `activeOutputFor`, `viewerSecondsOn`, `minimumBufferSecondsFor`, `getSourceStats`, `planEncodersSoon`, `encodeRuns`, `encodeSpeedReadingsOf`, `outputTimes`, `outputs`, `lookaheadSeconds`, `quality`, `qualityOffer`, `renditions`, `segmentDurationSec`, `segmentStore`, `sourceFiles`, `startupWaitMs`, `viewers`, `acceptsGeneration`, `generationOfRequest`, `noteGivenOutput`, `holdForResponse`, `noteServingVerdict`, `servingVerdictOf`
157
+ * @param {object} host - `opening`, `activeOutputFor`, `viewerSecondsOn`, `minimumBufferSecondsFor`, `getSourceStats`, `planEncodersSoon`, `encodeRuns`, `encodeSpeedReadingsOf`, `outputs`, `lookaheadSeconds`, `quality`, `qualityOffer`, `renditions`, `segmentDurationSec`, `segmentStore`, `sourceFiles`, `startupWaitMs`, `viewers`, `placeViewerOn`, `chooseAudioTrack`, `acceptsGeneration`, `generationOfRequest`, `noteGivenOutput`, `holdForResponse`, `noteServingVerdict`, `servingVerdictOf`
158
158
  */
159
159
  constructor(host) {
160
160
  this.#host = host;
@@ -351,6 +351,7 @@ export class ViewerRequests {
351
351
  // Whether they were already on the output this request lands on, read at
352
352
  // the moment they are put on it.
353
353
  let alreadyOn = false;
354
+ let placedHere = false;
354
355
  // THE PLACE ON THIS MACHINE, AND THE VIEWER IN IT, in one synchronous call
355
356
  // the opening makes the moment it has the output (roadmap item 97, step 14).
356
357
  // Asked and placed together, so the next viewer to open anything is asked
@@ -367,7 +368,7 @@ export class ViewerRequests {
367
368
  : this.#host.admitsWatching(candidate);
368
369
  if (answer.admitted) {
369
370
  known = this.#host.viewers.of(candidate, consumerId);
370
- this.placeViewer(candidate, known, startPositionSeconds);
371
+ placedHere = this.placeViewerOn(candidate, consumerId, startPositionSeconds) === true;
371
372
  }
372
373
  return answer;
373
374
  }
@@ -385,7 +386,7 @@ export class ViewerRequests {
385
386
  // browser may need a track re-encoded that the first viewer's could
386
387
  // decode as it stands.
387
388
  const viewer = this.#host.viewers.of(output, consumerId);
388
- viewer.audio = audio;
389
+ this.#host.chooseAudioTrack(output, consumerId, audio);
389
390
  // The picture as their page sees it, which bounds the height made for
390
391
  // them from here on (roadmap item 98).
391
392
  viewer.noteVisiblePicture(request.visiblePicture);
@@ -400,7 +401,7 @@ export class ViewerRequests {
400
401
  : null,
401
402
  bufferedAheadSec: request.bufferedAheadSec,
402
403
  bufferLimitSeconds: request.bufferLimitSeconds,
403
- positionSeconds: startPositionSeconds,
404
+ ...(placedHere ? { positionSeconds: startPositionSeconds } : {}),
404
405
  waiting: true,
405
406
  playing: false,
406
407
  visiblePicture: request.visiblePicture
@@ -438,65 +439,24 @@ export class ViewerRequests {
438
439
  }
439
440
 
440
441
  /**
441
- * Put a viewer where their own request says they are.
442
+ * Ask the viewer layer to place this viewer at their initial position.
442
443
  *
443
- * A viewer arrives by asking for a POSITION — zero, or the time an address
444
- * bar carried — so "we do not know where they are" is not a state a viewer
445
- * can be in. Before 2026-09-05 it was: position was written only by a segment
446
- * request, so a viewer counted as placeless until they had asked for a
447
- * segment, and an output whose viewers were all placeless had every encoder
448
- * on it stopped for having nobody. The soundtrack that failed that day could
449
- * not have asked: the segment it would have asked for needed an `init.mp4`
450
- * that the stopped encoder was going to make.
451
- *
452
- * Only ever places a viewer who has none. A viewer already placed is being
453
- * kept current by their own requests and seeks, and a fresh create request
454
- * carries a default of zero that must not drag them back to the beginning.
455
- *
456
- * @param {HlsSession} session
457
- * @param {import("../viewer/Viewer.js").Viewer} viewer
458
- * @param {number} positionSeconds
459
- * @returns {void}
460
- */
461
- /**
462
- * Put a viewer named by id onto this output, where they have no position yet.
463
- *
464
- * The same operation as {@link placeViewer}, addressed the way another
465
- * component may address it: by name. Encoding asks for this when a request
466
- * for any file of an output is the first sign of the person behind it, and it
467
- * must not be handed the viewer to do it — a viewer's record has one writer,
468
- * and that is the viewer layer.
444
+ * Encoding asks for this when a request for any file of an output is the
445
+ * first sign of the person behind it. The operation takes the viewer's name
446
+ * and position; the viewer layer owns the record and its idempotency rule.
469
447
  *
470
448
  * @param {object} output
471
449
  * @param {string} consumerId
472
450
  * @param {number} positionSeconds
473
- * @returns {void}
451
+ * @returns {boolean} Whether the viewer layer placed the viewer.
474
452
  */
475
453
  placeViewerOn(output, consumerId, positionSeconds) {
476
454
  if (!output || !consumerId) {
477
- return;
478
- }
479
- const viewer = this.#host.viewers.join(output, consumerId);
480
- if (viewer) this.placeViewer(output, viewer, positionSeconds);
481
- }
482
-
483
- placeViewer(session, viewer, positionSeconds) {
484
- if (viewer.position !== null) {
485
- return;
486
- }
487
- const seconds = Number.isFinite(positionSeconds) && positionSeconds > 0 ? positionSeconds : 0;
488
- let segment = 0;
489
- try {
490
- const index = this.#host.outputTimes.segmentIndexForTime(session, seconds);
491
- if (Number.isInteger(index) && index >= 0) {
492
- segment = index;
493
- }
494
- } catch {
495
- // A session whose cut table is not built yet places its viewer at the
496
- // beginning, which is where the run starts anyway.
455
+ return false;
497
456
  }
498
- viewer.position = { segment, seconds, at: Date.now(), seeked: null };
499
- this.#host.planEncodersSoon();
457
+ const placed = this.#host.placeViewerOn(output, consumerId, positionSeconds) === true;
458
+ if (placed) this.#host.planEncodersSoon();
459
+ return placed;
500
460
  }
501
461
 
502
462
  /**
@@ -1,6 +1,4 @@
1
1
  import { logger } from "../../utils/logger.js";
2
- import { bandOf, waits } from "./WaitLedger.js";
3
-
4
2
  /**
5
3
  * Which viewer is asking, or an empty string where the transport names nobody.
6
4
  *
@@ -84,6 +82,12 @@ export async function serveSessionFile(req, reply, { serving, viewerRequests, se
84
82
  // a plain HTTP transport, where no loader of ours builds the URL, and then
85
83
  // the single shared position decides as it always did.
86
84
  const consumerId = consumerOf(req);
85
+ // Hold the output-key lifetime that this request entered. Disposal followed
86
+ // by reopening the same key must not let its old completion write into the
87
+ // new output's measurements.
88
+ const waitIdentity = typeof serving.waitIdentity === "function"
89
+ ? serving.waitIdentity(sessionId)
90
+ : undefined;
87
91
  // Readiness or a terminal state ends the wait. A caller leaving cancels it;
88
92
  // elapsed time is not evidence that media preparation failed.
89
93
  const holdStartedAt = Date.now();
@@ -132,11 +136,12 @@ export async function serveSessionFile(req, reply, { serving, viewerRequests, se
132
136
  // like, and without them the table would hold only the failures.
133
137
  const ranked = result.ranked ?? null;
134
138
  if (ranked) {
135
- waits.note(ranked.address, heldMs, ranked.rank, ranked.topRank);
139
+ serving.noteWait?.(ranked, heldMs, waitIdentity);
136
140
  }
137
141
  logger.info(
138
142
  `[hold] ${fileName} ${outcome} after ${heldMs}ms` +
139
- (ranked ? ` (the map wants it ${bandOf(ranked.rank, ranked.topRank)}, rank ${ranked.rank} of ${ranked.topRank})` : "")
143
+ (ranked ? ` (the map wants it ${serving.waitBandOf?.(ranked.rank, ranked.topRank) ?? "later"}, ` +
144
+ `rank ${ranked.rank} of ${ranked.topRank})` : "")
140
145
  );
141
146
  }
142
147
 
@@ -10,7 +10,7 @@
10
10
 
11
11
  import { logger } from "../../utils/logger.js";
12
12
  import { KeyframeTables } from "../media/KeyframeTables.js";
13
- import { waits } from "./WaitLedger.js";
13
+ import { bandOf, WaitLedger } from "../encode/OutputWaitLedger.js";
14
14
  import { readEndedChildrenCpuSeconds, readMachineState, readProcessCpuSeconds, readProxyCpuSeconds, readSystemCpu, shareOfMachine } from "../encode/host-load.js";
15
15
  import { minimumBufferFrom } from "../torrent/supply-margin.js";
16
16
  import { PriorityOrchestrator } from "../viewer/PriorityOrchestrator.js";
@@ -51,6 +51,7 @@ import {
51
51
  noteStepBeingWarmed,
52
52
  noteStepOnScreen,
53
53
  outputsBeingPrepared,
54
+ placeInitially,
54
55
  placeOn,
55
56
  presentOn,
56
57
  qualityModeOf,
@@ -244,6 +245,9 @@ export function wireOutputs({
244
245
  // Told when an encoder starts or ends: the room this host has for one more
245
246
  // encode changes with it, and the server chooses proxies by that room.
246
247
  onEncodersChanged = null}) {
248
+ // Long-lived wait diagnostics belong to this wired output graph: each live
249
+ // output key owns its record, and the graph releases it at the last session.
250
+ const waits = new WaitLedger();
247
251
  const parts = {};
248
252
  const audioDescriptionOf = (output) => (getCachedAudioTracks?.({
249
253
  sourceKey: output.file.sourceKey, fileIndex: output.spec.grid?.fileIndex ?? output.file.fileIndex
@@ -552,6 +556,9 @@ export function wireOutputs({
552
556
  get lookaheadSeconds() { return parts.lookaheadSeconds; },
553
557
  get outputTimes() { return parts.outputTimes; },
554
558
  get outputs() { return parts.outputs; },
559
+ noteWait: (ranked, waitedMs, identity) =>
560
+ waits.note(ranked.address, waitedMs, ranked.rank, ranked.topRank, identity),
561
+ waitBandOf: bandOf,
555
562
  get segmentStore() { return parts.segmentStore; },
556
563
  get startupWaitMs() { return parts.startupWaitMs; },
557
564
  get viewers() { return parts.viewers; },
@@ -565,6 +572,7 @@ export function wireOutputs({
565
572
  outputReading: key => parts.segmentStore.isReading(key) || parts.viewers.responsesHold(key),
566
573
  planEncodersSoon: () => parts.encodeRuns.planEncodersSoon(),
567
574
  invalidateWaits: (output) => parts.serving.invalidateWaits(output),
575
+ clearWaits: () => waits.clear(),
568
576
  segmentFormatOfKey,
569
577
  // Where the viewers of one output stand, in its segments: what the store
570
578
  // the output retention policy reads to give disk back in the right order.
@@ -675,8 +683,11 @@ export function wireOutputs({
675
683
  return video + audio;
676
684
  },
677
685
  get opening() { return parts.opening; },
678
- get outputTimes() { return parts.outputTimes; },
679
686
  get outputs() { return parts.outputs; },
687
+ placeViewerOn: (output, consumerId, seconds, now) =>
688
+ placeInitially(parts.viewers, output, consumerId, seconds, now),
689
+ chooseAudioTrack: (output, consumerId, choice) =>
690
+ chooseAudioTrack(parts.viewers, output, consumerId, choice),
680
691
  get lookaheadSeconds() { return parts.lookaheadSeconds; },
681
692
  get quality() { return parts.quality; },
682
693
  get qualityOffer() { return parts.qualityOffer; },
@@ -960,7 +971,9 @@ export function wireOutputs({
960
971
  // belongs to, the steps, the soundtracks, the height a step is named by.
961
972
  parts.outputs = new OutputCatalog({
962
973
  fileLengthOf: (session) => parts.hostLoad.fileLengthByKey.get(session.file.key) ?? 0,
963
- largestPieceOf: (address) => parts.segmentStore.largestPiece(address)
974
+ largestPieceOf: (address) => parts.segmentStore.largestPiece(address),
975
+ waitIdentityStarted: (address, identity) => waits.open(address, identity),
976
+ waitIdentityEnded: (address, identity) => waits.forget(address, identity)
964
977
  });
965
978
  // Where produced segments live, addressed by WHAT they are rather than by
966
979
  // which session's encoder wrote them. Two sessions of one output — two
@@ -427,6 +427,22 @@ export class Viewer {
427
427
  this.bufferedSeconds = 0;
428
428
  }
429
429
 
430
+ /**
431
+ * Place this viewer at the position supplied by their first output opening.
432
+ * An opening that reuses an output must not reset a position already stated
433
+ * by this viewer. Initial placement has none of the buffer or pause effects
434
+ * of an explicit seek.
435
+ *
436
+ * @param {number} seconds
437
+ * @param {number} [now]
438
+ * @returns {boolean} Whether this call placed the viewer.
439
+ */
440
+ placeInitially(seconds, now = Date.now()) {
441
+ if (this.position !== null) return false;
442
+ this.#place(Number.isFinite(seconds) && seconds > 0 ? seconds : 0, now);
443
+ return true;
444
+ }
445
+
430
446
  /**
431
447
  * Where they are, stated — without anything a move to another place means.
432
448
  *
@@ -96,10 +96,26 @@ export function audioChoiceOf(viewers, output, consumerId) {
96
96
  * @returns {void}
97
97
  */
98
98
  export function chooseAudioTrack(viewers, output, consumerId, choice) {
99
+ if (!choice) return;
99
100
  const viewer = viewers.join(output, consumerId);
100
101
  if (viewer) viewer.audio = { trackIndex: choice.trackIndex, transcode: choice.transcode === true };
101
102
  }
102
103
 
104
+ /**
105
+ * Place a viewer at the initial position named by an output opening.
106
+ *
107
+ * @param {object} viewers
108
+ * @param {object} output
109
+ * @param {string} consumerId
110
+ * @param {number} seconds
111
+ * @param {number} [now]
112
+ * @returns {boolean} Whether this call placed the viewer.
113
+ */
114
+ export function placeInitially(viewers, output, consumerId, seconds, now = Date.now()) {
115
+ const viewer = viewers.join(output, consumerId, now);
116
+ return viewer?.placeInitially(seconds, now) ?? false;
117
+ }
118
+
103
119
  /**
104
120
  * The step on this viewer's screen, as a session id, or null for the picture
105
121
  * itself.