@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/recording.js CHANGED
@@ -15,11 +15,11 @@
15
15
  * and upgrade automatically once UT appears.
16
16
  */
17
17
  /**
18
- * Bound on the single-recording arm-and-wait: how long
19
- * {@link RecordingController.startRecording} waits for a forced feed to
20
- * reach full resolution before starting the recorder anyway. Never hangs a
21
- * start indefinitely (feed out of flight, wedged, or the sidecar/plugin
22
- * round trip taking longer than expected).
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
- * Whether `state` shows a feed actually rendering at its full target size,
292
- * as opposed to merely having caught up to whatever small size it already
293
- * had. Two ways to know the target:
294
- *
295
- * - `target` known (from `maxRenderSize`): ready once the operator size
296
- * has reached it and the rendered size has caught up to the operator
297
- * size. Resolves promptly even when the feed was already at its ceiling
298
- * before the force was sent (nothing ever "increases").
299
- * - `target` unknown: falls back to requiring the operator size to have
300
- * increased past `baseline` (the first REAL state observed at or after
301
- * watch-start; see {@link watchResolutionReady}) and the rendered size
302
- * to have caught up. `baseline` is `null` until that first real state
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
- isAtFullResolution(state, target, baseline) {
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 render at full resolution.
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
- * recording's own lifetime instead; see {@link startRecording}).
347
- * Resolves `true` once ready, `false` on timeout. Returns a `cancel` next
348
- * to the promise so a caller can abandon the wait (stop/discard) without
349
- * leaking the underlying "change" subscription or timer; cancelling never
350
- * settles the promise.
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
- /* Known-ceiling path never consults baseline; unknown-ceiling path
356
- starts with no baseline until a real state seeds one, so a null
357
- cam.state at watch-start (e.g. no discover() yet, since state and
358
- the track are independent transports with no ordering guarantee)
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
- settled = true;
369
- if (timer != null)
370
- clearTimeout(timer);
371
- unsubscribe();
446
+ release();
372
447
  resolve(reached);
373
448
  };
374
- if (this.isAtFullResolution(cam.state, target, baseline)) {
375
- finish(true);
376
- return;
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
- unsubscribe = cam.on("change", (state) => {
379
- if (!target && !baseline) {
380
- /* This is the first real state we've ever seen: it becomes the
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
- if (this.isAtFullResolution(state, target, baseline))
387
- finish(true);
388
- });
389
- if (timeoutMs != null) {
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
- settled = true;
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
- * `inGroup` is internal (set by {@link startGroupedRecording}) and governs
411
- * how `opts.forceFullResolution` behaves: a single recording (`inGroup`
412
- * false) arms and waits, bounded by {@link ARM_TIMEOUT_MS}, for the feed to
413
- * reach full resolution before the recorder actually starts, so the clip
414
- * opens on full-res frames. A grouped member starts immediately and
415
- * instead records the moment it reaches full resolution
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 && !inGroup) {
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. Returns the groupId plus the real
686
- * per-feed recordingIds (so a caller can cancel one member individually via
687
- * `stopRecording`/`discardRecording`, the same ids that end up on each
688
- * clip's `RecordingHandle`). If any feed can't start (no track / already
689
- * recording), the ones already started are discarded and the error
690
- * rethrows.
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
- /* Capture each forced member's resolution-ready UT before stopRecording
754
- tears down its ActiveRecording bookkeeping below. */
755
- const resolutionReadyUts = [];
756
- for (const id of ids) {
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 (trimWindow) {
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, trimWindow, trimmer)));
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.0",
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",