@ksp-gonogo/kerbcast 1.10.0 → 1.10.2
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/dist/__generated__/types.d.ts +45 -0
- package/dist/client.d.ts +23 -1
- package/dist/client.js +1 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/noise.js +11 -0
- package/dist/recording.d.ts +113 -59
- package/dist/recording.js +297 -129
- package/package.json +1 -1
- package/src/__generated__/types.ts +46 -0
- package/src/client.test.ts +103 -0
- package/src/client.ts +29 -0
- package/src/index.ts +2 -0
- package/src/noise.test.ts +40 -0
- package/src/noise.ts +13 -0
- package/src/recording.test.ts +391 -59
- package/src/recording.ts +359 -150
package/dist/recording.js
CHANGED
|
@@ -15,11 +15,11 @@
|
|
|
15
15
|
* and upgrade automatically once UT appears.
|
|
16
16
|
*/
|
|
17
17
|
/**
|
|
18
|
-
* Bound on the
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
18
|
+
* Bound on the arm-and-wait: how long a forced recording waits for its feed
|
|
19
|
+
* (or, for a group, every member's feed) to reach full resolution before
|
|
20
|
+
* starting the recorder anyway. Never hangs a start indefinitely (feed out
|
|
21
|
+
* of flight, wedged, or the sidecar/plugin round trip taking longer than
|
|
22
|
+
* expected).
|
|
23
23
|
*/
|
|
24
24
|
export const ARM_TIMEOUT_MS = 3000;
|
|
25
25
|
/** Default {@link StopGroupedRecordingOptions.coverageTimeoutMs}. */
|
|
@@ -94,6 +94,74 @@ export function utToRecordingTimeMs(samples, ut) {
|
|
|
94
94
|
}
|
|
95
95
|
return pts[pts.length - 1].t;
|
|
96
96
|
}
|
|
97
|
+
/**
|
|
98
|
+
* Default {@link DecodedSizeProbe}: plays the stream into an off-DOM muted
|
|
99
|
+
* `<video>` and reads the decoded size from each frame
|
|
100
|
+
* (requestVideoFrameCallback where available) and from `resize` /
|
|
101
|
+
* `loadedmetadata`. `track.getSettings()` is not used because Chromium often
|
|
102
|
+
* reports stale or missing dimensions for remote WebRTC tracks. Outside a
|
|
103
|
+
* browser it never reports, so a forced recording falls back to the arm
|
|
104
|
+
* timeout.
|
|
105
|
+
*/
|
|
106
|
+
export const videoElementDecodedSizeProbe = (_flightId, stream, onSize) => {
|
|
107
|
+
if (typeof document === "undefined")
|
|
108
|
+
return () => { };
|
|
109
|
+
const video = document.createElement("video");
|
|
110
|
+
const vfc = video;
|
|
111
|
+
video.muted = true;
|
|
112
|
+
video.playsInline = true;
|
|
113
|
+
let stopped = false;
|
|
114
|
+
let frameId;
|
|
115
|
+
const report = () => {
|
|
116
|
+
if (!stopped && video.videoWidth > 0 && video.videoHeight > 0) {
|
|
117
|
+
onSize(video.videoWidth, video.videoHeight);
|
|
118
|
+
}
|
|
119
|
+
};
|
|
120
|
+
const onFrame = (_now, meta) => {
|
|
121
|
+
if (stopped)
|
|
122
|
+
return;
|
|
123
|
+
if (meta?.width && meta.height)
|
|
124
|
+
onSize(meta.width, meta.height);
|
|
125
|
+
else
|
|
126
|
+
report();
|
|
127
|
+
frameId = vfc.requestVideoFrameCallback?.(onFrame);
|
|
128
|
+
};
|
|
129
|
+
video.addEventListener("resize", report);
|
|
130
|
+
video.addEventListener("loadedmetadata", report);
|
|
131
|
+
video.srcObject = stream;
|
|
132
|
+
if (typeof vfc.requestVideoFrameCallback === "function") {
|
|
133
|
+
frameId = vfc.requestVideoFrameCallback(onFrame);
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
const played = video.play();
|
|
137
|
+
played?.catch(() => { });
|
|
138
|
+
}
|
|
139
|
+
catch {
|
|
140
|
+
/* play() unsupported or refused: resize/loadedmetadata may still report */
|
|
141
|
+
}
|
|
142
|
+
report();
|
|
143
|
+
return () => {
|
|
144
|
+
stopped = true;
|
|
145
|
+
video.removeEventListener("resize", report);
|
|
146
|
+
video.removeEventListener("loadedmetadata", report);
|
|
147
|
+
if (frameId != null)
|
|
148
|
+
vfc.cancelVideoFrameCallback?.(frameId);
|
|
149
|
+
try {
|
|
150
|
+
video.pause();
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
/* nothing to pause */
|
|
154
|
+
}
|
|
155
|
+
video.srcObject = null;
|
|
156
|
+
};
|
|
157
|
+
};
|
|
158
|
+
/**
|
|
159
|
+
* How far below the ceiling a decoded frame may be and still count as full
|
|
160
|
+
* size. Encoders round frame dimensions to even or macroblock multiples, so
|
|
161
|
+
* an exact match is not guaranteed; the tile sizes the gate rejects are far
|
|
162
|
+
* further off than this.
|
|
163
|
+
*/
|
|
164
|
+
const DECODED_SIZE_TOLERANCE_PX = 16;
|
|
97
165
|
/**
|
|
98
166
|
* Builds a {@link GroupTrimmer} from an already-resolved Mediabunny module
|
|
99
167
|
* namespace: keyframe-boundary remux via its Conversion API, no re-encode.
|
|
@@ -146,6 +214,10 @@ export function createMediabunnyTrimmerLoader(importModule) {
|
|
|
146
214
|
* stop.
|
|
147
215
|
*/
|
|
148
216
|
const loadMediabunnyTrimmer = createMediabunnyTrimmerLoader(() => import("mediabunny"));
|
|
217
|
+
/** `WxH` for a log line, or `unknown` when no decoded frame was seen. */
|
|
218
|
+
function formatSize(size) {
|
|
219
|
+
return size ? `${size.width}x${size.height}` : "unknown";
|
|
220
|
+
}
|
|
149
221
|
let idCounter = 0;
|
|
150
222
|
function mintId(prefix) {
|
|
151
223
|
idCounter += 1;
|
|
@@ -189,6 +261,8 @@ export class RecordingController {
|
|
|
189
261
|
* one; see {@link acquireForce}.
|
|
190
262
|
*/
|
|
191
263
|
this.forceSendState = new Map();
|
|
264
|
+
/** groupId -> arm state, present only while a forced group is still arming. */
|
|
265
|
+
this.groupArms = new Map();
|
|
192
266
|
this.listeners = new Set();
|
|
193
267
|
this.snapshot = { recordings: [], groups: [], active: [] };
|
|
194
268
|
/** While true, {@link rebuild} is a no-op; used to batch startGroupedRecording's
|
|
@@ -205,6 +279,7 @@ export class RecordingController {
|
|
|
205
279
|
this.getSnapshot = () => this.snapshot;
|
|
206
280
|
this.client = client;
|
|
207
281
|
this.loadTrimmer = options?.loadTrimmer ?? loadMediabunnyTrimmer;
|
|
282
|
+
this.probeDecodedSize = options?.probeDecodedSize ?? videoElementDecodedSizeProbe;
|
|
208
283
|
}
|
|
209
284
|
/**
|
|
210
285
|
* Recompute the snapshot from current state and notify subscribers. Called
|
|
@@ -288,41 +363,25 @@ export class RecordingController {
|
|
|
288
363
|
}
|
|
289
364
|
}
|
|
290
365
|
/**
|
|
291
|
-
*
|
|
292
|
-
*
|
|
293
|
-
*
|
|
294
|
-
*
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
302
|
-
*
|
|
303
|
-
* arrives, and a `null` baseline is never ready: a placeholder `{0,0}`
|
|
304
|
-
* would let any positive operator size look like an increase, so no
|
|
305
|
-
* verdict is possible until a genuine baseline exists. This is a
|
|
306
|
-
* strictly weaker guarantee than the known-target path: a feed already
|
|
307
|
-
* at its true ceiling when forced will not be recognised as ready until
|
|
308
|
-
* something else changes its operator size, or the wait simply times
|
|
309
|
-
* out, but it still refuses to treat the pre-force snapshot itself as
|
|
310
|
-
* ready.
|
|
311
|
-
*
|
|
312
|
-
* Either way, a state whose rendered size has not yet caught up to its own
|
|
313
|
-
* operator size (adaptive shed still catching up) is never ready.
|
|
366
|
+
* Fallback readiness for a feed whose ceiling is unknown to the client (no
|
|
367
|
+
* `maxRenderSize`), judged from the reported camera state alone. Ready once
|
|
368
|
+
* the operator size has increased past `baseline` (the first REAL state
|
|
369
|
+
* observed at or after watch-start; see {@link watchResolutionReady}) and
|
|
370
|
+
* the rendered size has caught up to it. `baseline` is `null` until that
|
|
371
|
+
* first real state arrives, and a `null` baseline is never ready: a
|
|
372
|
+
* placeholder `{0,0}` would let any positive operator size look like an
|
|
373
|
+
* increase. A feed already at its true ceiling when forced is not
|
|
374
|
+
* recognised until something else changes its operator size, so the wait
|
|
375
|
+
* usually times out instead, but the pre-force snapshot itself is never
|
|
376
|
+
* treated as ready. A state whose rendered size has not yet caught up to
|
|
377
|
+
* its own operator size (adaptive shed still catching up) is never ready.
|
|
314
378
|
*/
|
|
315
|
-
|
|
316
|
-
if (!state)
|
|
379
|
+
stateIncreasedPastBaseline(state, baseline) {
|
|
380
|
+
if (!state || !baseline)
|
|
317
381
|
return false;
|
|
318
382
|
if (state.renderWidth !== state.operatorWidth || state.renderHeight !== state.operatorHeight) {
|
|
319
383
|
return false;
|
|
320
384
|
}
|
|
321
|
-
if (target) {
|
|
322
|
-
return state.operatorWidth >= target.width && state.operatorHeight >= target.height;
|
|
323
|
-
}
|
|
324
|
-
if (!baseline)
|
|
325
|
-
return false;
|
|
326
385
|
return state.operatorWidth > baseline.width && state.operatorHeight > baseline.height;
|
|
327
386
|
}
|
|
328
387
|
/**
|
|
@@ -340,53 +399,88 @@ export class RecordingController {
|
|
|
340
399
|
return { width: state.operatorWidth, height: state.operatorHeight };
|
|
341
400
|
}
|
|
342
401
|
/**
|
|
343
|
-
* Waits for a forced feed to actually
|
|
402
|
+
* Waits for a forced feed to actually deliver full-resolution frames.
|
|
403
|
+
*
|
|
404
|
+
* With the ceiling known (`maxRenderSize`), the gate is the decoded frame
|
|
405
|
+
* size from {@link probeDecodedSize} reaching it (within
|
|
406
|
+
* {@link DECODED_SIZE_TOLERANCE_PX}). The camera state is deliberately not
|
|
407
|
+
* trusted here: it reports the new size before the decoder delivers
|
|
408
|
+
* full-size frames, and a recorder started in that gap locks the clip to
|
|
409
|
+
* the old size. A feed already at its ceiling resolves on the probe's
|
|
410
|
+
* first report. With the ceiling unknown, falls back to the state-only
|
|
411
|
+
* check ({@link stateIncreasedPastBaseline}).
|
|
412
|
+
*
|
|
344
413
|
* `timeoutMs` omitted means no bound: the wait only ends when the feed
|
|
345
414
|
* becomes ready or the caller cancels it (the grouped path, bounded by the
|
|
346
|
-
*
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
350
|
-
*
|
|
415
|
+
* group's one shared timer instead; see {@link armGroup}). Resolves `true`
|
|
416
|
+
* once ready, `false` on timeout. `cancel` abandons the wait (stop/discard)
|
|
417
|
+
* without settling the promise; every exit path releases the "change"
|
|
418
|
+
* subscription, the probe, and the timer. `lastDecoded` reports the most
|
|
419
|
+
* recent decoded size seen, for logging.
|
|
351
420
|
*/
|
|
352
421
|
watchResolutionReady(flightId, timeoutMs) {
|
|
353
422
|
const cam = this.client.camera(flightId);
|
|
354
423
|
const target = cam.maxRenderSize;
|
|
355
|
-
/*
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
can never masquerade as a {0,0} baseline. */
|
|
424
|
+
/* Unknown-ceiling path starts with no baseline until a real state seeds
|
|
425
|
+
one, so a null cam.state at watch-start (e.g. no discover() yet, since
|
|
426
|
+
state and the track are independent transports with no ordering
|
|
427
|
+
guarantee) can never masquerade as a {0,0} baseline. */
|
|
360
428
|
let baseline = target ? null : this.seedResolutionBaseline(cam.state);
|
|
429
|
+
let decoded = null;
|
|
361
430
|
let settled = false;
|
|
362
431
|
let unsubscribe = () => { };
|
|
432
|
+
let stopProbe = null;
|
|
363
433
|
let timer;
|
|
434
|
+
const release = () => {
|
|
435
|
+
settled = true;
|
|
436
|
+
if (timer != null)
|
|
437
|
+
clearTimeout(timer);
|
|
438
|
+
unsubscribe();
|
|
439
|
+
stopProbe?.();
|
|
440
|
+
stopProbe = null;
|
|
441
|
+
};
|
|
364
442
|
const ready = new Promise((resolve) => {
|
|
365
443
|
const finish = (reached) => {
|
|
366
444
|
if (settled)
|
|
367
445
|
return;
|
|
368
|
-
|
|
369
|
-
if (timer != null)
|
|
370
|
-
clearTimeout(timer);
|
|
371
|
-
unsubscribe();
|
|
446
|
+
release();
|
|
372
447
|
resolve(reached);
|
|
373
448
|
};
|
|
374
|
-
if (
|
|
375
|
-
|
|
376
|
-
|
|
449
|
+
if (target) {
|
|
450
|
+
const stream = cam.mediaStream;
|
|
451
|
+
if (stream) {
|
|
452
|
+
const stop = this.probeDecodedSize(flightId, stream, (width, height) => {
|
|
453
|
+
decoded = { width, height };
|
|
454
|
+
if (width >= target.width - DECODED_SIZE_TOLERANCE_PX &&
|
|
455
|
+
height >= target.height - DECODED_SIZE_TOLERANCE_PX) {
|
|
456
|
+
finish(true);
|
|
457
|
+
}
|
|
458
|
+
});
|
|
459
|
+
/* The probe may report (and finish the wait) before returning. */
|
|
460
|
+
if (settled)
|
|
461
|
+
stop();
|
|
462
|
+
else
|
|
463
|
+
stopProbe = stop;
|
|
464
|
+
}
|
|
377
465
|
}
|
|
378
|
-
|
|
379
|
-
if (
|
|
380
|
-
|
|
381
|
-
baseline, but a state can't ready itself against its own
|
|
382
|
-
just-established baseline, so wait for the NEXT state. */
|
|
383
|
-
baseline = this.seedResolutionBaseline(state);
|
|
466
|
+
else {
|
|
467
|
+
if (this.stateIncreasedPastBaseline(cam.state, baseline)) {
|
|
468
|
+
finish(true);
|
|
384
469
|
return;
|
|
385
470
|
}
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
471
|
+
unsubscribe = cam.on("change", (state) => {
|
|
472
|
+
if (!baseline) {
|
|
473
|
+
/* This is the first real state we've ever seen: it becomes the
|
|
474
|
+
baseline, but a state can't ready itself against its own
|
|
475
|
+
just-established baseline, so wait for the NEXT state. */
|
|
476
|
+
baseline = this.seedResolutionBaseline(state);
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
if (this.stateIncreasedPastBaseline(state, baseline))
|
|
480
|
+
finish(true);
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
if (!settled && timeoutMs != null) {
|
|
390
484
|
timer = setTimeout(() => finish(false), timeoutMs);
|
|
391
485
|
}
|
|
392
486
|
});
|
|
@@ -395,11 +489,9 @@ export class RecordingController {
|
|
|
395
489
|
cancel: () => {
|
|
396
490
|
if (settled)
|
|
397
491
|
return;
|
|
398
|
-
|
|
399
|
-
if (timer != null)
|
|
400
|
-
clearTimeout(timer);
|
|
401
|
-
unsubscribe();
|
|
492
|
+
release();
|
|
402
493
|
},
|
|
494
|
+
lastDecoded: () => decoded,
|
|
403
495
|
};
|
|
404
496
|
}
|
|
405
497
|
/**
|
|
@@ -407,14 +499,12 @@ export class RecordingController {
|
|
|
407
499
|
* synchronously. Throws if the feed has no live track or is already
|
|
408
500
|
* recording.
|
|
409
501
|
*
|
|
410
|
-
* `
|
|
411
|
-
*
|
|
412
|
-
*
|
|
413
|
-
*
|
|
414
|
-
*
|
|
415
|
-
*
|
|
416
|
-
* (`resolutionReadyAt`/`resolutionReadyUt`) so {@link stopGroupedRecording}
|
|
417
|
-
* can fold it into the common trim window.
|
|
502
|
+
* With `opts.forceFullResolution`, the recording arms and waits, bounded by
|
|
503
|
+
* {@link ARM_TIMEOUT_MS}, for the feed to reach full resolution before the
|
|
504
|
+
* recorder actually starts, so the clip opens on full-res frames.
|
|
505
|
+
* `inGroup` is internal (set by {@link startGroupedRecording}): a forced
|
|
506
|
+
* grouped member arms the same way but leaves the wait to the group, which
|
|
507
|
+
* starts every member's recorder together (see {@link armGroup}).
|
|
418
508
|
*/
|
|
419
509
|
startRecording(flightId, opts, inGroup = false) {
|
|
420
510
|
if (this.byFlight.has(flightId)) {
|
|
@@ -469,19 +559,25 @@ export class RecordingController {
|
|
|
469
559
|
void this.stopRecording(recordingId).catch(() => { });
|
|
470
560
|
}, opts.maxDurationMs);
|
|
471
561
|
}
|
|
472
|
-
if (forceRequested &&
|
|
562
|
+
if (forceRequested && inGroup) {
|
|
563
|
+
/* Grouped path: arm only. startGroupedRecording owns the wait and
|
|
564
|
+
starts every member's recorder in one go. */
|
|
565
|
+
rec.arming = true;
|
|
566
|
+
}
|
|
567
|
+
else if (forceRequested) {
|
|
473
568
|
/* Single path: arm-and-wait. Show "arming" immediately; the recorder
|
|
474
569
|
itself doesn't start until the feed reaches full resolution (or the
|
|
475
570
|
bounded wait gives up), so the clip never opens on low-res frames. */
|
|
476
571
|
rec.arming = true;
|
|
477
572
|
const watch = this.watchResolutionReady(flightId, ARM_TIMEOUT_MS);
|
|
478
573
|
rec.cancelResolutionWatch = watch.cancel;
|
|
479
|
-
void watch.ready.then(() => {
|
|
574
|
+
void watch.ready.then((reached) => {
|
|
480
575
|
rec.cancelResolutionWatch = undefined;
|
|
481
576
|
/* Discarded while arming: the recorder was never started and this
|
|
482
577
|
id is gone from `active`, so there is nothing left to do. */
|
|
483
578
|
if (!this.active.has(recordingId))
|
|
484
579
|
return;
|
|
580
|
+
console.info(`kerbcast: recording ${recordingId} started after arming (${reached ? "full resolution" : "arm timeout"}); decoded size ${flightId}=${formatSize(watch.lastDecoded())}`);
|
|
485
581
|
rec.arming = false;
|
|
486
582
|
/* The real recording starts now: reset the clock base so the
|
|
487
583
|
elapsed timer and utSamples track begin at actual record start,
|
|
@@ -496,31 +592,102 @@ export class RecordingController {
|
|
|
496
592
|
}
|
|
497
593
|
else {
|
|
498
594
|
recorder.start();
|
|
499
|
-
if (forceRequested) {
|
|
500
|
-
/* Grouped path: record immediately. Watch for the moment this feed
|
|
501
|
-
actually reaches full resolution so stopGroupedRecording can fold
|
|
502
|
-
it into the common trim window and cut the low-res lead-in. No
|
|
503
|
-
bound here: the wait is naturally bounded by the recording's own
|
|
504
|
-
lifetime (cancelled on stop/discard); a feed that never reaches
|
|
505
|
-
full resolution simply keeps its lead-in untrimmed. */
|
|
506
|
-
const watch = this.watchResolutionReady(flightId);
|
|
507
|
-
rec.cancelResolutionWatch = watch.cancel;
|
|
508
|
-
void watch.ready.then((reached) => {
|
|
509
|
-
rec.cancelResolutionWatch = undefined;
|
|
510
|
-
if (!reached)
|
|
511
|
-
return;
|
|
512
|
-
if (!this.active.has(recordingId))
|
|
513
|
-
return;
|
|
514
|
-
if (rec.resolutionReadyAt == null) {
|
|
515
|
-
rec.resolutionReadyAt = now() - rec.startedAt;
|
|
516
|
-
rec.resolutionReadyUt = this.client.clock.captureUt ?? undefined;
|
|
517
|
-
}
|
|
518
|
-
});
|
|
519
|
-
}
|
|
520
595
|
}
|
|
521
596
|
this.rebuild();
|
|
522
597
|
return recordingId;
|
|
523
598
|
}
|
|
599
|
+
/**
|
|
600
|
+
* Arms a forced group: watches every member's feed and starts all their
|
|
601
|
+
* recorders together once each has reached full resolution, or once the
|
|
602
|
+
* one shared {@link ARM_TIMEOUT_MS} bound passes, whichever comes first.
|
|
603
|
+
* Members stopped or discarded mid-arm drop out of the wait, so a slow
|
|
604
|
+
* feed that gets cancelled no longer holds the rest back.
|
|
605
|
+
*/
|
|
606
|
+
armGroup(groupId, ids) {
|
|
607
|
+
const arm = {
|
|
608
|
+
ready: new Set(),
|
|
609
|
+
timer: setTimeout(() => this.startArmedGroup(groupId), ARM_TIMEOUT_MS),
|
|
610
|
+
};
|
|
611
|
+
this.groupArms.set(groupId, arm);
|
|
612
|
+
for (const id of ids) {
|
|
613
|
+
const rec = this.active.get(id);
|
|
614
|
+
if (!rec)
|
|
615
|
+
continue;
|
|
616
|
+
const watch = this.watchResolutionReady(rec.flightId);
|
|
617
|
+
rec.cancelResolutionWatch = watch.cancel;
|
|
618
|
+
rec.lastDecodedSize = watch.lastDecoded;
|
|
619
|
+
void watch.ready.then(() => {
|
|
620
|
+
rec.cancelResolutionWatch = undefined;
|
|
621
|
+
arm.ready.add(id);
|
|
622
|
+
this.checkGroupArm(groupId);
|
|
623
|
+
});
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
/** Members of a group still armed and waiting to start (not stopped or discarded). */
|
|
627
|
+
armingMembers(groupId) {
|
|
628
|
+
const ids = this.groups.get(groupId) ?? [];
|
|
629
|
+
const members = [];
|
|
630
|
+
for (const id of ids) {
|
|
631
|
+
const rec = this.active.get(id);
|
|
632
|
+
if (rec && rec.arming && !rec.stopPromise)
|
|
633
|
+
members.push(rec);
|
|
634
|
+
}
|
|
635
|
+
return members;
|
|
636
|
+
}
|
|
637
|
+
/** Starts an arming group once every member still waiting is ready. */
|
|
638
|
+
checkGroupArm(groupId) {
|
|
639
|
+
const arm = this.groupArms.get(groupId);
|
|
640
|
+
if (!arm)
|
|
641
|
+
return;
|
|
642
|
+
if (this.armingMembers(groupId).every((rec) => arm.ready.has(rec.recordingId))) {
|
|
643
|
+
this.startArmedGroup(groupId);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
/**
|
|
647
|
+
* Ends a group's arm and starts every still-armed member's recorder in the
|
|
648
|
+
* same tick, on one shared clock base (`startedAt`, `utStart`), so the
|
|
649
|
+
* clips open together. Cancels any outstanding per-member watches (the
|
|
650
|
+
* timeout path).
|
|
651
|
+
*/
|
|
652
|
+
startArmedGroup(groupId) {
|
|
653
|
+
const arm = this.groupArms.get(groupId);
|
|
654
|
+
if (!arm)
|
|
655
|
+
return;
|
|
656
|
+
this.groupArms.delete(groupId);
|
|
657
|
+
clearTimeout(arm.timer);
|
|
658
|
+
const members = this.armingMembers(groupId);
|
|
659
|
+
if (members.length === 0)
|
|
660
|
+
return;
|
|
661
|
+
const startedAt = now();
|
|
662
|
+
const utStart = this.client.clock.captureUt ?? undefined;
|
|
663
|
+
for (const rec of members) {
|
|
664
|
+
rec.cancelResolutionWatch?.();
|
|
665
|
+
rec.cancelResolutionWatch = undefined;
|
|
666
|
+
rec.arming = false;
|
|
667
|
+
rec.startedAt = startedAt;
|
|
668
|
+
rec.utStart = utStart;
|
|
669
|
+
rec.utSamples = [{ t: 0, ut: utStart }];
|
|
670
|
+
rec.latestUt = utStart;
|
|
671
|
+
rec.recorder.start();
|
|
672
|
+
}
|
|
673
|
+
const reason = members.every((rec) => arm.ready.has(rec.recordingId))
|
|
674
|
+
? "all at full resolution"
|
|
675
|
+
: "arm timeout";
|
|
676
|
+
const sizes = members.map((rec) => `${rec.flightId}=${formatSize(rec.lastDecodedSize?.() ?? null)}`);
|
|
677
|
+
console.info(`kerbcast: grouped recording ${groupId} started ${members.length} member(s) after arming (${reason}); decoded sizes ${sizes.join(" ")}`);
|
|
678
|
+
this.rebuild();
|
|
679
|
+
}
|
|
680
|
+
/**
|
|
681
|
+
* Abandons a group's arm without starting anything: the caller is tearing
|
|
682
|
+
* the whole group down (stop or discard), so no member should start mid-way.
|
|
683
|
+
*/
|
|
684
|
+
cancelGroupArm(groupId) {
|
|
685
|
+
const arm = this.groupArms.get(groupId);
|
|
686
|
+
if (!arm)
|
|
687
|
+
return;
|
|
688
|
+
this.groupArms.delete(groupId);
|
|
689
|
+
clearTimeout(arm.timer);
|
|
690
|
+
}
|
|
524
691
|
/**
|
|
525
692
|
* Stop a recording and resolve its finished handle. Idempotent per id: a
|
|
526
693
|
* second call (or a race with maxDurationMs auto-stop) returns the same
|
|
@@ -567,6 +734,11 @@ export class RecordingController {
|
|
|
567
734
|
this.active.delete(recordingId);
|
|
568
735
|
this.byFlight.delete(rec.flightId);
|
|
569
736
|
this.rebuild();
|
|
737
|
+
/* An arming group member stopped on its own: the rest of its group
|
|
738
|
+
may now be ready to start without it. */
|
|
739
|
+
const groupId = this.memberOfGroup.get(recordingId);
|
|
740
|
+
if (groupId != null)
|
|
741
|
+
this.checkGroupArm(groupId);
|
|
570
742
|
return rec.stopPromise;
|
|
571
743
|
}
|
|
572
744
|
const utEnd = this.client.clock.captureUt ?? undefined;
|
|
@@ -676,18 +848,25 @@ export class RecordingController {
|
|
|
676
848
|
}
|
|
677
849
|
if (changed)
|
|
678
850
|
this.rebuild();
|
|
851
|
+
/* Discarding a slow member of an arming group must not keep the others
|
|
852
|
+
waiting on it. */
|
|
853
|
+
if (groupId != null)
|
|
854
|
+
this.checkGroupArm(groupId);
|
|
679
855
|
}
|
|
680
856
|
// -------------------------------------------------------------------------
|
|
681
857
|
// Grouped recording
|
|
682
858
|
// -------------------------------------------------------------------------
|
|
683
859
|
/**
|
|
684
860
|
* Start recording several feeds as one synchronised moment. All recorders
|
|
685
|
-
* open in a single tick to minimise skew.
|
|
686
|
-
*
|
|
687
|
-
*
|
|
688
|
-
*
|
|
689
|
-
*
|
|
690
|
-
*
|
|
861
|
+
* open in a single tick to minimise skew. With `opts.forceFullResolution`,
|
|
862
|
+
* every member arms first and that single tick comes later, once all feeds
|
|
863
|
+
* have reached full resolution or {@link ARM_TIMEOUT_MS} passes (see
|
|
864
|
+
* {@link armGroup}); the members report `arming` until then. Returns the
|
|
865
|
+
* groupId plus the real per-feed recordingIds (so a caller can cancel one
|
|
866
|
+
* member individually via `stopRecording`/`discardRecording`, the same ids
|
|
867
|
+
* that end up on each clip's `RecordingHandle`). If any feed can't start
|
|
868
|
+
* (no track / already recording), the ones already started are discarded
|
|
869
|
+
* and the error rethrows.
|
|
691
870
|
*/
|
|
692
871
|
startGroupedRecording(flightIds, opts) {
|
|
693
872
|
const started = [];
|
|
@@ -726,6 +905,8 @@ export class RecordingController {
|
|
|
726
905
|
return null;
|
|
727
906
|
}
|
|
728
907
|
})());
|
|
908
|
+
if (opts?.forceFullResolution === true)
|
|
909
|
+
this.armGroup(groupId, started);
|
|
729
910
|
return { groupId, recordingIds: [...started] };
|
|
730
911
|
}
|
|
731
912
|
finally {
|
|
@@ -750,41 +931,22 @@ export class RecordingController {
|
|
|
750
931
|
if (opts?.targetUt != null) {
|
|
751
932
|
await this.awaitCoverage(ids, opts.targetUt, opts.coverageTimeoutMs ?? DEFAULT_COVERAGE_TIMEOUT_MS);
|
|
752
933
|
}
|
|
753
|
-
/*
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
const ut = this.active.get(id)?.resolutionReadyUt;
|
|
758
|
-
if (ut != null)
|
|
759
|
-
resolutionReadyUts.push(ut);
|
|
760
|
-
}
|
|
934
|
+
/* A group stopped while still arming never starts its recorders: each
|
|
935
|
+
still-armed member settles below as an empty clip, the same as a
|
|
936
|
+
single recording stopped mid-arm. */
|
|
937
|
+
this.cancelGroupArm(groupId);
|
|
761
938
|
const recordings = await Promise.all(ids.map((id) => this.stopRecording(id)));
|
|
762
939
|
const window = commonUtWindow(recordings);
|
|
763
|
-
/* Fold the low-res lead-in cut into the same common-window trim: extend
|
|
764
|
-
the window's start to the latest point any forced member actually
|
|
765
|
-
reached full resolution, so every clip stays cut to one shared window
|
|
766
|
-
(UT-synced) rather than each clip getting its own start. A member that
|
|
767
|
-
never reached full resolution before stop simply doesn't contribute
|
|
768
|
-
(its own lead-in stays untrimmed, per the documented edge case). This
|
|
769
|
-
only widens the physical trim cut: `commonUtWindow` on the returned
|
|
770
|
-
handle keeps reporting the clips' own raw UT intersection, unchanged,
|
|
771
|
-
since that's metadata about what the clips cover independent of
|
|
772
|
-
whether (or how) they got physically cut. */
|
|
773
|
-
let trimWindow = window;
|
|
774
|
-
if (trimWindow && resolutionReadyUts.length > 0) {
|
|
775
|
-
const windowStart = Math.max(trimWindow[0], ...resolutionReadyUts);
|
|
776
|
-
trimWindow = [windowStart, trimWindow[1]];
|
|
777
|
-
}
|
|
778
940
|
/* Physically remux-trim each clip to the common window so the grouped
|
|
779
941
|
output is pre-aligned by default. The UT metadata (utSamples +
|
|
780
942
|
commonUtWindow) still rides along so a consumer can align the trimmed
|
|
781
943
|
clips to telemetry. Trimming never fails the recording: any clip that
|
|
782
944
|
can't be cut cleanly degrades to metadata-only. */
|
|
783
945
|
let finalRecordings = recordings;
|
|
784
|
-
if (
|
|
946
|
+
if (window) {
|
|
785
947
|
const trimmer = await (this.groupTrimmers.get(groupId) ?? Promise.resolve(null));
|
|
786
948
|
if (trimmer) {
|
|
787
|
-
finalRecordings = await Promise.all(recordings.map((rec) => this.maybeTrim(rec,
|
|
949
|
+
finalRecordings = await Promise.all(recordings.map((rec) => this.maybeTrim(rec, window, trimmer)));
|
|
788
950
|
}
|
|
789
951
|
}
|
|
790
952
|
const handle = {
|
|
@@ -809,6 +971,9 @@ export class RecordingController {
|
|
|
809
971
|
metadata-only for those clips (mp4 is the default container). */
|
|
810
972
|
if (!/mp4/i.test(rec.mimeType))
|
|
811
973
|
return rec;
|
|
974
|
+
/* A member stopped while still arming has no media to cut. */
|
|
975
|
+
if (rec.byteSize === 0)
|
|
976
|
+
return rec;
|
|
812
977
|
const startMs = utToRecordingTimeMs(rec.utSamples, window[0]);
|
|
813
978
|
const endMs = utToRecordingTimeMs(rec.utSamples, window[1]);
|
|
814
979
|
if (startMs == null || endMs == null || endMs <= startMs)
|
|
@@ -839,6 +1004,9 @@ export class RecordingController {
|
|
|
839
1004
|
stop the same group at once). */
|
|
840
1005
|
let changed = false;
|
|
841
1006
|
const active = this.groups.get(groupId);
|
|
1007
|
+
/* Before the member discards below, so a still-arming group can't start
|
|
1008
|
+
its remaining members partway through being torn down. */
|
|
1009
|
+
this.cancelGroupArm(groupId);
|
|
842
1010
|
if (active) {
|
|
843
1011
|
/* discardRecording now splices ids out of this very array as it goes
|
|
844
1012
|
(fix for cancelling one grouped member); iterate a copy so removals
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ksp-gonogo/kerbcast",
|
|
3
|
-
"version": "1.10.
|
|
3
|
+
"version": "1.10.2",
|
|
4
4
|
"description": "TypeScript client for the kerbcast KSP camera-streaming sidecar. KerbcastClient wraps the WebRTC peer + control channel; wire-format types are typeshare-generated from the sidecar's Rust protocol module.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Jon Pepler",
|