@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/src/recording.ts CHANGED
@@ -65,21 +65,23 @@ export interface StartRecordingOptions {
65
65
  /**
66
66
  * Force this feed to the operator's full render size for the duration of
67
67
  * the recording (overrides the display-size demand only; still yields to
68
- * the adaptive framerate shed). Off by default. A single recording arms
69
- * and waits (bounded) for the bump before the clip starts, so it opens on
70
- * full-res frames; a grouped recording starts immediately and the lead-in
71
- * is cut via the existing UT-window trim instead. Released automatically
72
- * on stop, discard, or auto-stop.
68
+ * the adaptive framerate shed). Off by default. The recording arms and
69
+ * waits (bounded) until the browser is decoding full-size frames before
70
+ * the clip starts: MediaRecorder fixes the clip's frame size when it
71
+ * starts, so a later bump would never reach the file. A grouped recording
72
+ * arms every member and starts all their recorders together once every
73
+ * feed is ready (or the shared bound passes). Released automatically on
74
+ * stop, discard, or auto-stop.
73
75
  */
74
76
  forceFullResolution?: boolean;
75
77
  }
76
78
 
77
79
  /**
78
- * Bound on the single-recording arm-and-wait: how long
79
- * {@link RecordingController.startRecording} waits for a forced feed to
80
- * reach full resolution before starting the recorder anyway. Never hangs a
81
- * start indefinitely (feed out of flight, wedged, or the sidecar/plugin
82
- * round trip taking longer than expected).
80
+ * Bound on the arm-and-wait: how long a forced recording waits for its feed
81
+ * (or, for a group, every member's feed) to reach full resolution before
82
+ * starting the recorder anyway. Never hangs a start indefinitely (feed out
83
+ * of flight, wedged, or the sidecar/plugin round trip taking longer than
84
+ * expected).
83
85
  */
84
86
  export const ARM_TIMEOUT_MS = 3000;
85
87
 
@@ -148,11 +150,11 @@ export interface ActiveRecordingInfo {
148
150
  startedAt: number;
149
151
  groupId?: string;
150
152
  /**
151
- * True while a single forced recording is armed (force sent, waiting for
152
- * the feed to reach full resolution) but the recorder has not started
153
- * yet. Always false for a non-forced recording and for grouped members
154
- * (which record immediately). A UI shows a distinct "arming" state while
155
- * this is true.
153
+ * True while a forced recording is armed (force sent, waiting for the
154
+ * feed to reach full resolution) but the recorder has not started yet.
155
+ * Forced grouped members stay arming until every member is ready, then
156
+ * flip together. Always false for a non-forced recording. A UI shows a
157
+ * distinct "arming" state while this is true.
156
158
  */
157
159
  arming: boolean;
158
160
  }
@@ -299,6 +301,95 @@ export interface GroupTrimmer {
299
301
  /** Loads a {@link GroupTrimmer}, or resolves null when trimming is unavailable. */
300
302
  export type TrimmerLoader = () => Promise<GroupTrimmer | null>;
301
303
 
304
+ /**
305
+ * Watches the decoded frame size of a feed's live stream, calling `onSize`
306
+ * whenever it is known or changes (repeats are fine). Returns a stop function
307
+ * that releases everything the probe holds. The arm-and-wait gates a forced
308
+ * recording on this, not on the reported camera state: the state flips to
309
+ * the full size before the decoder delivers full-size frames, and
310
+ * MediaRecorder locks the clip to whatever size it sees first.
311
+ */
312
+ export type DecodedSizeProbe = (
313
+ flightId: number,
314
+ stream: MediaStream,
315
+ onSize: (width: number, height: number) => void,
316
+ ) => () => void;
317
+
318
+ /* Structural view of requestVideoFrameCallback, which not every DOM lib
319
+ version types. */
320
+ interface FrameCallbackVideo {
321
+ requestVideoFrameCallback?(
322
+ cb: (now: number, meta: { width?: number; height?: number }) => void,
323
+ ): number;
324
+ cancelVideoFrameCallback?(id: number): void;
325
+ }
326
+
327
+ /**
328
+ * Default {@link DecodedSizeProbe}: plays the stream into an off-DOM muted
329
+ * `<video>` and reads the decoded size from each frame
330
+ * (requestVideoFrameCallback where available) and from `resize` /
331
+ * `loadedmetadata`. `track.getSettings()` is not used because Chromium often
332
+ * reports stale or missing dimensions for remote WebRTC tracks. Outside a
333
+ * browser it never reports, so a forced recording falls back to the arm
334
+ * timeout.
335
+ */
336
+ export const videoElementDecodedSizeProbe: DecodedSizeProbe = (_flightId, stream, onSize) => {
337
+ if (typeof document === "undefined") return () => {};
338
+ const video = document.createElement("video");
339
+ const vfc = video as unknown as FrameCallbackVideo;
340
+ video.muted = true;
341
+ video.playsInline = true;
342
+ let stopped = false;
343
+ let frameId: number | undefined;
344
+
345
+ const report = (): void => {
346
+ if (!stopped && video.videoWidth > 0 && video.videoHeight > 0) {
347
+ onSize(video.videoWidth, video.videoHeight);
348
+ }
349
+ };
350
+ const onFrame = (_now: number, meta: { width?: number; height?: number }): void => {
351
+ if (stopped) return;
352
+ if (meta?.width && meta.height) onSize(meta.width, meta.height);
353
+ else report();
354
+ frameId = vfc.requestVideoFrameCallback?.(onFrame);
355
+ };
356
+
357
+ video.addEventListener("resize", report);
358
+ video.addEventListener("loadedmetadata", report);
359
+ video.srcObject = stream;
360
+ if (typeof vfc.requestVideoFrameCallback === "function") {
361
+ frameId = vfc.requestVideoFrameCallback(onFrame);
362
+ }
363
+ try {
364
+ const played = video.play() as Promise<void> | undefined;
365
+ played?.catch(() => {});
366
+ } catch {
367
+ /* play() unsupported or refused: resize/loadedmetadata may still report */
368
+ }
369
+ report();
370
+
371
+ return () => {
372
+ stopped = true;
373
+ video.removeEventListener("resize", report);
374
+ video.removeEventListener("loadedmetadata", report);
375
+ if (frameId != null) vfc.cancelVideoFrameCallback?.(frameId);
376
+ try {
377
+ video.pause();
378
+ } catch {
379
+ /* nothing to pause */
380
+ }
381
+ video.srcObject = null;
382
+ };
383
+ };
384
+
385
+ /**
386
+ * How far below the ceiling a decoded frame may be and still count as full
387
+ * size. Encoders round frame dimensions to even or macroblock multiples, so
388
+ * an exact match is not guaranteed; the tile sizes the gate rejects are far
389
+ * further off than this.
390
+ */
391
+ const DECODED_SIZE_TOLERANCE_PX = 16;
392
+
302
393
  /** Options for {@link RecordingController}. */
303
394
  export interface RecordingControllerOptions {
304
395
  /**
@@ -307,6 +398,12 @@ export interface RecordingControllerOptions {
307
398
  * `import("mediabunny")` keyframe remux.
308
399
  */
309
400
  loadTrimmer?: TrimmerLoader;
401
+ /**
402
+ * Override how a forced recording observes its feed's decoded frame size
403
+ * (tests inject a fake; a harness without real decoding can follow the
404
+ * camera state instead). Defaults to {@link videoElementDecodedSizeProbe}.
405
+ */
406
+ probeDecodedSize?: DecodedSizeProbe;
310
407
  }
311
408
 
312
409
  /**
@@ -414,28 +511,35 @@ interface ActiveRecording {
414
511
  */
415
512
  discarded?: boolean;
416
513
  /**
417
- * True while a single forced recording is armed (force sent, recorder not
418
- * started yet). Never set for grouped members, which record immediately.
514
+ * True while a forced recording is armed (force sent, recorder not started
515
+ * yet). A grouped member stays armed until its whole group starts.
419
516
  */
420
517
  arming?: boolean;
421
518
  /** True once this recording has acquired the per-feed force ref-count. */
422
519
  forced?: boolean;
423
520
  /**
424
- * Grouped path only: recording-time (ms since `startedAt`) at which this
425
- * feed was observed at full resolution. Undefined until reached (or
426
- * always, if it never is before stop).
427
- */
428
- resolutionReadyAt?: number;
429
- /** Grouped path only: `client.clock.captureUt` at the same moment. */
430
- resolutionReadyUt?: number;
431
- /**
432
- * Cancels a pending arm-wait (single) or ready-watch (grouped)
433
- * subscription. Set while a {@link RecordingController.watchResolutionReady}
521
+ * Cancels a pending arm-wait subscription (single, or one member of a
522
+ * group). Set while a {@link RecordingController.watchResolutionReady}
434
523
  * wait is outstanding; cleared once it settles or is cancelled. Always
435
524
  * cancelled on stop/discard so a torn-down recording never leaks a
436
- * "change" listener or starts its recorder late.
525
+ * "change" listener or decoded-size probe, or starts its recorder late.
437
526
  */
438
527
  cancelResolutionWatch?: () => void;
528
+ /** The last decoded frame size the arm-wait saw, for the start log line. */
529
+ lastDecodedSize?: () => { width: number; height: number } | null;
530
+ }
531
+
532
+ /** Arm state for a forced grouped recording whose recorders have not started yet. */
533
+ interface GroupArm {
534
+ /** Members whose feed has been observed at full resolution. */
535
+ ready: Set<string>;
536
+ /** The shared {@link ARM_TIMEOUT_MS} bound; starts every member when it fires. */
537
+ timer: ReturnType<typeof setTimeout>;
538
+ }
539
+
540
+ /** `WxH` for a log line, or `unknown` when no decoded frame was seen. */
541
+ function formatSize(size: { width: number; height: number } | null): string {
542
+ return size ? `${size.width}x${size.height}` : "unknown";
439
543
  }
440
544
 
441
545
  let idCounter = 0;
@@ -464,6 +568,7 @@ export class RecordingController {
464
568
  /** Per-group trimmer load kicked off at group-start, awaited at stop. */
465
569
  private readonly groupTrimmers = new Map<string, Promise<GroupTrimmer | null>>();
466
570
  private readonly loadTrimmer: TrimmerLoader;
571
+ private readonly probeDecodedSize: DecodedSizeProbe;
467
572
  /** recordingIds in a grouped set, so they stay out of the standalone `recordings` list. */
468
573
  private readonly groupMemberIds = new Set<string>();
469
574
  /** recordingId -> groupId, so discarding one member can pull it out of its group's array. */
@@ -483,6 +588,8 @@ export class RecordingController {
483
588
  * one; see {@link acquireForce}.
484
589
  */
485
590
  private readonly forceSendState = new Map<number, "pending" | "sent">();
591
+ /** groupId -> arm state, present only while a forced group is still arming. */
592
+ private readonly groupArms = new Map<string, GroupArm>();
486
593
  private readonly listeners = new Set<() => void>();
487
594
  private snapshot: RecordingsSnapshot = { recordings: [], groups: [], active: [] };
488
595
  /** While true, {@link rebuild} is a no-op; used to batch startGroupedRecording's
@@ -492,6 +599,7 @@ export class RecordingController {
492
599
  constructor(client: RecordingClient, options?: RecordingControllerOptions) {
493
600
  this.client = client;
494
601
  this.loadTrimmer = options?.loadTrimmer ?? loadMediabunnyTrimmer;
602
+ this.probeDecodedSize = options?.probeDecodedSize ?? videoElementDecodedSizeProbe;
495
603
  }
496
604
 
497
605
  /** Subscribe to snapshot changes. Returns an unsubscribe function. */
@@ -591,43 +699,27 @@ export class RecordingController {
591
699
  }
592
700
 
593
701
  /**
594
- * Whether `state` shows a feed actually rendering at its full target size,
595
- * as opposed to merely having caught up to whatever small size it already
596
- * had. Two ways to know the target:
597
- *
598
- * - `target` known (from `maxRenderSize`): ready once the operator size
599
- * has reached it and the rendered size has caught up to the operator
600
- * size. Resolves promptly even when the feed was already at its ceiling
601
- * before the force was sent (nothing ever "increases").
602
- * - `target` unknown: falls back to requiring the operator size to have
603
- * increased past `baseline` (the first REAL state observed at or after
604
- * watch-start; see {@link watchResolutionReady}) and the rendered size
605
- * to have caught up. `baseline` is `null` until that first real state
606
- * arrives, and a `null` baseline is never ready: a placeholder `{0,0}`
607
- * would let any positive operator size look like an increase, so no
608
- * verdict is possible until a genuine baseline exists. This is a
609
- * strictly weaker guarantee than the known-target path: a feed already
610
- * at its true ceiling when forced will not be recognised as ready until
611
- * something else changes its operator size, or the wait simply times
612
- * out, but it still refuses to treat the pre-force snapshot itself as
613
- * ready.
614
- *
615
- * Either way, a state whose rendered size has not yet caught up to its own
616
- * operator size (adaptive shed still catching up) is never ready.
702
+ * Fallback readiness for a feed whose ceiling is unknown to the client (no
703
+ * `maxRenderSize`), judged from the reported camera state alone. Ready once
704
+ * the operator size has increased past `baseline` (the first REAL state
705
+ * observed at or after watch-start; see {@link watchResolutionReady}) and
706
+ * the rendered size has caught up to it. `baseline` is `null` until that
707
+ * first real state arrives, and a `null` baseline is never ready: a
708
+ * placeholder `{0,0}` would let any positive operator size look like an
709
+ * increase. A feed already at its true ceiling when forced is not
710
+ * recognised until something else changes its operator size, so the wait
711
+ * usually times out instead, but the pre-force snapshot itself is never
712
+ * treated as ready. A state whose rendered size has not yet caught up to
713
+ * its own operator size (adaptive shed still catching up) is never ready.
617
714
  */
618
- private isAtFullResolution(
715
+ private stateIncreasedPastBaseline(
619
716
  state: CameraState | null,
620
- target: { width: number; height: number } | null,
621
717
  baseline: { width: number; height: number } | null,
622
718
  ): boolean {
623
- if (!state) return false;
719
+ if (!state || !baseline) return false;
624
720
  if (state.renderWidth !== state.operatorWidth || state.renderHeight !== state.operatorHeight) {
625
721
  return false;
626
722
  }
627
- if (target) {
628
- return state.operatorWidth >= target.width && state.operatorHeight >= target.height;
629
- }
630
- if (!baseline) return false;
631
723
  return state.operatorWidth > baseline.width && state.operatorHeight > baseline.height;
632
724
  }
633
725
 
@@ -647,57 +739,95 @@ export class RecordingController {
647
739
  }
648
740
 
649
741
  /**
650
- * Waits for a forced feed to actually render at full resolution.
742
+ * Waits for a forced feed to actually deliver full-resolution frames.
743
+ *
744
+ * With the ceiling known (`maxRenderSize`), the gate is the decoded frame
745
+ * size from {@link probeDecodedSize} reaching it (within
746
+ * {@link DECODED_SIZE_TOLERANCE_PX}). The camera state is deliberately not
747
+ * trusted here: it reports the new size before the decoder delivers
748
+ * full-size frames, and a recorder started in that gap locks the clip to
749
+ * the old size. A feed already at its ceiling resolves on the probe's
750
+ * first report. With the ceiling unknown, falls back to the state-only
751
+ * check ({@link stateIncreasedPastBaseline}).
752
+ *
651
753
  * `timeoutMs` omitted means no bound: the wait only ends when the feed
652
754
  * becomes ready or the caller cancels it (the grouped path, bounded by the
653
- * recording's own lifetime instead; see {@link startRecording}).
654
- * Resolves `true` once ready, `false` on timeout. Returns a `cancel` next
655
- * to the promise so a caller can abandon the wait (stop/discard) without
656
- * leaking the underlying "change" subscription or timer; cancelling never
657
- * settles the promise.
755
+ * group's one shared timer instead; see {@link armGroup}). Resolves `true`
756
+ * once ready, `false` on timeout. `cancel` abandons the wait (stop/discard)
757
+ * without settling the promise; every exit path releases the "change"
758
+ * subscription, the probe, and the timer. `lastDecoded` reports the most
759
+ * recent decoded size seen, for logging.
658
760
  */
659
761
  private watchResolutionReady(
660
762
  flightId: number,
661
763
  timeoutMs?: number,
662
- ): { ready: Promise<boolean>; cancel: () => void } {
764
+ ): {
765
+ ready: Promise<boolean>;
766
+ cancel: () => void;
767
+ lastDecoded: () => { width: number; height: number } | null;
768
+ } {
663
769
  const cam = this.client.camera(flightId);
664
770
  const target = cam.maxRenderSize;
665
- /* Known-ceiling path never consults baseline; unknown-ceiling path
666
- starts with no baseline until a real state seeds one, so a null
667
- cam.state at watch-start (e.g. no discover() yet, since state and
668
- the track are independent transports with no ordering guarantee)
669
- can never masquerade as a {0,0} baseline. */
771
+ /* Unknown-ceiling path starts with no baseline until a real state seeds
772
+ one, so a null cam.state at watch-start (e.g. no discover() yet, since
773
+ state and the track are independent transports with no ordering
774
+ guarantee) can never masquerade as a {0,0} baseline. */
670
775
  let baseline = target ? null : this.seedResolutionBaseline(cam.state);
776
+ let decoded: { width: number; height: number } | null = null;
671
777
 
672
778
  let settled = false;
673
779
  let unsubscribe: () => void = () => {};
780
+ let stopProbe: (() => void) | null = null;
674
781
  let timer: ReturnType<typeof setTimeout> | undefined;
675
782
 
783
+ const release = (): void => {
784
+ settled = true;
785
+ if (timer != null) clearTimeout(timer);
786
+ unsubscribe();
787
+ stopProbe?.();
788
+ stopProbe = null;
789
+ };
790
+
676
791
  const ready = new Promise<boolean>((resolve) => {
677
792
  const finish = (reached: boolean): void => {
678
793
  if (settled) return;
679
- settled = true;
680
- if (timer != null) clearTimeout(timer);
681
- unsubscribe();
794
+ release();
682
795
  resolve(reached);
683
796
  };
684
797
 
685
- if (this.isAtFullResolution(cam.state, target, baseline)) {
686
- finish(true);
687
- return;
688
- }
689
-
690
- unsubscribe = cam.on("change", (state) => {
691
- if (!target && !baseline) {
692
- /* This is the first real state we've ever seen: it becomes the
693
- baseline, but a state can't ready itself against its own
694
- just-established baseline, so wait for the NEXT state. */
695
- baseline = this.seedResolutionBaseline(state);
798
+ if (target) {
799
+ const stream = cam.mediaStream;
800
+ if (stream) {
801
+ const stop = this.probeDecodedSize(flightId, stream, (width, height) => {
802
+ decoded = { width, height };
803
+ if (
804
+ width >= target.width - DECODED_SIZE_TOLERANCE_PX &&
805
+ height >= target.height - DECODED_SIZE_TOLERANCE_PX
806
+ ) {
807
+ finish(true);
808
+ }
809
+ });
810
+ /* The probe may report (and finish the wait) before returning. */
811
+ if (settled) stop();
812
+ else stopProbe = stop;
813
+ }
814
+ } else {
815
+ if (this.stateIncreasedPastBaseline(cam.state, baseline)) {
816
+ finish(true);
696
817
  return;
697
818
  }
698
- if (this.isAtFullResolution(state, target, baseline)) finish(true);
699
- });
700
- if (timeoutMs != null) {
819
+ unsubscribe = cam.on("change", (state) => {
820
+ if (!baseline) {
821
+ /* This is the first real state we've ever seen: it becomes the
822
+ baseline, but a state can't ready itself against its own
823
+ just-established baseline, so wait for the NEXT state. */
824
+ baseline = this.seedResolutionBaseline(state);
825
+ return;
826
+ }
827
+ if (this.stateIncreasedPastBaseline(state, baseline)) finish(true);
828
+ });
829
+ }
830
+ if (!settled && timeoutMs != null) {
701
831
  timer = setTimeout(() => finish(false), timeoutMs);
702
832
  }
703
833
  });
@@ -706,10 +836,9 @@ export class RecordingController {
706
836
  ready,
707
837
  cancel: (): void => {
708
838
  if (settled) return;
709
- settled = true;
710
- if (timer != null) clearTimeout(timer);
711
- unsubscribe();
839
+ release();
712
840
  },
841
+ lastDecoded: () => decoded,
713
842
  };
714
843
  }
715
844
 
@@ -718,14 +847,12 @@ export class RecordingController {
718
847
  * synchronously. Throws if the feed has no live track or is already
719
848
  * recording.
720
849
  *
721
- * `inGroup` is internal (set by {@link startGroupedRecording}) and governs
722
- * how `opts.forceFullResolution` behaves: a single recording (`inGroup`
723
- * false) arms and waits, bounded by {@link ARM_TIMEOUT_MS}, for the feed to
724
- * reach full resolution before the recorder actually starts, so the clip
725
- * opens on full-res frames. A grouped member starts immediately and
726
- * instead records the moment it reaches full resolution
727
- * (`resolutionReadyAt`/`resolutionReadyUt`) so {@link stopGroupedRecording}
728
- * can fold it into the common trim window.
850
+ * With `opts.forceFullResolution`, the recording arms and waits, bounded by
851
+ * {@link ARM_TIMEOUT_MS}, for the feed to reach full resolution before the
852
+ * recorder actually starts, so the clip opens on full-res frames.
853
+ * `inGroup` is internal (set by {@link startGroupedRecording}): a forced
854
+ * grouped member arms the same way but leaves the wait to the group, which
855
+ * starts every member's recorder together (see {@link armGroup}).
729
856
  */
730
857
  startRecording(flightId: number, opts?: StartRecordingOptions, inGroup = false): string {
731
858
  if (this.byFlight.has(flightId)) {
@@ -786,18 +913,27 @@ export class RecordingController {
786
913
  }, opts.maxDurationMs);
787
914
  }
788
915
 
789
- if (forceRequested && !inGroup) {
916
+ if (forceRequested && inGroup) {
917
+ /* Grouped path: arm only. startGroupedRecording owns the wait and
918
+ starts every member's recorder in one go. */
919
+ rec.arming = true;
920
+ } else if (forceRequested) {
790
921
  /* Single path: arm-and-wait. Show "arming" immediately; the recorder
791
922
  itself doesn't start until the feed reaches full resolution (or the
792
923
  bounded wait gives up), so the clip never opens on low-res frames. */
793
924
  rec.arming = true;
794
925
  const watch = this.watchResolutionReady(flightId, ARM_TIMEOUT_MS);
795
926
  rec.cancelResolutionWatch = watch.cancel;
796
- void watch.ready.then(() => {
927
+ void watch.ready.then((reached) => {
797
928
  rec.cancelResolutionWatch = undefined;
798
929
  /* Discarded while arming: the recorder was never started and this
799
930
  id is gone from `active`, so there is nothing left to do. */
800
931
  if (!this.active.has(recordingId)) return;
932
+ console.info(
933
+ `kerbcast: recording ${recordingId} started after arming (${
934
+ reached ? "full resolution" : "arm timeout"
935
+ }); decoded size ${flightId}=${formatSize(watch.lastDecoded())}`,
936
+ );
801
937
  rec.arming = false;
802
938
  /* The real recording starts now: reset the clock base so the
803
939
  elapsed timer and utSamples track begin at actual record start,
@@ -811,31 +947,107 @@ export class RecordingController {
811
947
  });
812
948
  } else {
813
949
  recorder.start();
814
- if (forceRequested) {
815
- /* Grouped path: record immediately. Watch for the moment this feed
816
- actually reaches full resolution so stopGroupedRecording can fold
817
- it into the common trim window and cut the low-res lead-in. No
818
- bound here: the wait is naturally bounded by the recording's own
819
- lifetime (cancelled on stop/discard); a feed that never reaches
820
- full resolution simply keeps its lead-in untrimmed. */
821
- const watch = this.watchResolutionReady(flightId);
822
- rec.cancelResolutionWatch = watch.cancel;
823
- void watch.ready.then((reached) => {
824
- rec.cancelResolutionWatch = undefined;
825
- if (!reached) return;
826
- if (!this.active.has(recordingId)) return;
827
- if (rec.resolutionReadyAt == null) {
828
- rec.resolutionReadyAt = now() - rec.startedAt;
829
- rec.resolutionReadyUt = this.client.clock.captureUt ?? undefined;
830
- }
831
- });
832
- }
833
950
  }
834
951
 
835
952
  this.rebuild();
836
953
  return recordingId;
837
954
  }
838
955
 
956
+ /**
957
+ * Arms a forced group: watches every member's feed and starts all their
958
+ * recorders together once each has reached full resolution, or once the
959
+ * one shared {@link ARM_TIMEOUT_MS} bound passes, whichever comes first.
960
+ * Members stopped or discarded mid-arm drop out of the wait, so a slow
961
+ * feed that gets cancelled no longer holds the rest back.
962
+ */
963
+ private armGroup(groupId: string, ids: string[]): void {
964
+ const arm: GroupArm = {
965
+ ready: new Set(),
966
+ timer: setTimeout(() => this.startArmedGroup(groupId), ARM_TIMEOUT_MS),
967
+ };
968
+ this.groupArms.set(groupId, arm);
969
+ for (const id of ids) {
970
+ const rec = this.active.get(id);
971
+ if (!rec) continue;
972
+ const watch = this.watchResolutionReady(rec.flightId);
973
+ rec.cancelResolutionWatch = watch.cancel;
974
+ rec.lastDecodedSize = watch.lastDecoded;
975
+ void watch.ready.then(() => {
976
+ rec.cancelResolutionWatch = undefined;
977
+ arm.ready.add(id);
978
+ this.checkGroupArm(groupId);
979
+ });
980
+ }
981
+ }
982
+
983
+ /** Members of a group still armed and waiting to start (not stopped or discarded). */
984
+ private armingMembers(groupId: string): ActiveRecording[] {
985
+ const ids = this.groups.get(groupId) ?? [];
986
+ const members: ActiveRecording[] = [];
987
+ for (const id of ids) {
988
+ const rec = this.active.get(id);
989
+ if (rec && rec.arming && !rec.stopPromise) members.push(rec);
990
+ }
991
+ return members;
992
+ }
993
+
994
+ /** Starts an arming group once every member still waiting is ready. */
995
+ private checkGroupArm(groupId: string): void {
996
+ const arm = this.groupArms.get(groupId);
997
+ if (!arm) return;
998
+ if (this.armingMembers(groupId).every((rec) => arm.ready.has(rec.recordingId))) {
999
+ this.startArmedGroup(groupId);
1000
+ }
1001
+ }
1002
+
1003
+ /**
1004
+ * Ends a group's arm and starts every still-armed member's recorder in the
1005
+ * same tick, on one shared clock base (`startedAt`, `utStart`), so the
1006
+ * clips open together. Cancels any outstanding per-member watches (the
1007
+ * timeout path).
1008
+ */
1009
+ private startArmedGroup(groupId: string): void {
1010
+ const arm = this.groupArms.get(groupId);
1011
+ if (!arm) return;
1012
+ this.groupArms.delete(groupId);
1013
+ clearTimeout(arm.timer);
1014
+ const members = this.armingMembers(groupId);
1015
+ if (members.length === 0) return;
1016
+ const startedAt = now();
1017
+ const utStart = this.client.clock.captureUt ?? undefined;
1018
+ for (const rec of members) {
1019
+ rec.cancelResolutionWatch?.();
1020
+ rec.cancelResolutionWatch = undefined;
1021
+ rec.arming = false;
1022
+ rec.startedAt = startedAt;
1023
+ rec.utStart = utStart;
1024
+ rec.utSamples = [{ t: 0, ut: utStart }];
1025
+ rec.latestUt = utStart;
1026
+ rec.recorder.start();
1027
+ }
1028
+ const reason = members.every((rec) => arm.ready.has(rec.recordingId))
1029
+ ? "all at full resolution"
1030
+ : "arm timeout";
1031
+ const sizes = members.map(
1032
+ (rec) => `${rec.flightId}=${formatSize(rec.lastDecodedSize?.() ?? null)}`,
1033
+ );
1034
+ console.info(
1035
+ `kerbcast: grouped recording ${groupId} started ${members.length} member(s) after arming (${reason}); decoded sizes ${sizes.join(" ")}`,
1036
+ );
1037
+ this.rebuild();
1038
+ }
1039
+
1040
+ /**
1041
+ * Abandons a group's arm without starting anything: the caller is tearing
1042
+ * the whole group down (stop or discard), so no member should start mid-way.
1043
+ */
1044
+ private cancelGroupArm(groupId: string): void {
1045
+ const arm = this.groupArms.get(groupId);
1046
+ if (!arm) return;
1047
+ this.groupArms.delete(groupId);
1048
+ clearTimeout(arm.timer);
1049
+ }
1050
+
839
1051
  /**
840
1052
  * Stop a recording and resolve its finished handle. Idempotent per id: a
841
1053
  * second call (or a race with maxDurationMs auto-stop) returns the same
@@ -880,6 +1092,10 @@ export class RecordingController {
880
1092
  this.active.delete(recordingId);
881
1093
  this.byFlight.delete(rec.flightId);
882
1094
  this.rebuild();
1095
+ /* An arming group member stopped on its own: the rest of its group
1096
+ may now be ready to start without it. */
1097
+ const groupId = this.memberOfGroup.get(recordingId);
1098
+ if (groupId != null) this.checkGroupArm(groupId);
883
1099
  return rec.stopPromise;
884
1100
  }
885
1101
 
@@ -989,6 +1205,9 @@ export class RecordingController {
989
1205
  }
990
1206
 
991
1207
  if (changed) this.rebuild();
1208
+ /* Discarding a slow member of an arming group must not keep the others
1209
+ waiting on it. */
1210
+ if (groupId != null) this.checkGroupArm(groupId);
992
1211
  }
993
1212
 
994
1213
  // -------------------------------------------------------------------------
@@ -997,12 +1216,15 @@ export class RecordingController {
997
1216
 
998
1217
  /**
999
1218
  * Start recording several feeds as one synchronised moment. All recorders
1000
- * open in a single tick to minimise skew. Returns the groupId plus the real
1001
- * per-feed recordingIds (so a caller can cancel one member individually via
1002
- * `stopRecording`/`discardRecording`, the same ids that end up on each
1003
- * clip's `RecordingHandle`). If any feed can't start (no track / already
1004
- * recording), the ones already started are discarded and the error
1005
- * rethrows.
1219
+ * open in a single tick to minimise skew. With `opts.forceFullResolution`,
1220
+ * every member arms first and that single tick comes later, once all feeds
1221
+ * have reached full resolution or {@link ARM_TIMEOUT_MS} passes (see
1222
+ * {@link armGroup}); the members report `arming` until then. Returns the
1223
+ * groupId plus the real per-feed recordingIds (so a caller can cancel one
1224
+ * member individually via `stopRecording`/`discardRecording`, the same ids
1225
+ * that end up on each clip's `RecordingHandle`). If any feed can't start
1226
+ * (no track / already recording), the ones already started are discarded
1227
+ * and the error rethrows.
1006
1228
  */
1007
1229
  startGroupedRecording(
1008
1230
  flightIds: number[],
@@ -1044,6 +1266,7 @@ export class RecordingController {
1044
1266
  }
1045
1267
  })(),
1046
1268
  );
1269
+ if (opts?.forceFullResolution === true) this.armGroup(groupId, started);
1047
1270
  return { groupId, recordingIds: [...started] };
1048
1271
  } finally {
1049
1272
  this.suppressRebuild = false;
@@ -1075,44 +1298,25 @@ export class RecordingController {
1075
1298
  );
1076
1299
  }
1077
1300
 
1078
- /* Capture each forced member's resolution-ready UT before stopRecording
1079
- tears down its ActiveRecording bookkeeping below. */
1080
- const resolutionReadyUts: number[] = [];
1081
- for (const id of ids) {
1082
- const ut = this.active.get(id)?.resolutionReadyUt;
1083
- if (ut != null) resolutionReadyUts.push(ut);
1084
- }
1301
+ /* A group stopped while still arming never starts its recorders: each
1302
+ still-armed member settles below as an empty clip, the same as a
1303
+ single recording stopped mid-arm. */
1304
+ this.cancelGroupArm(groupId);
1085
1305
 
1086
1306
  const recordings = await Promise.all(ids.map((id) => this.stopRecording(id)));
1087
1307
  const window = commonUtWindow(recordings);
1088
1308
 
1089
- /* Fold the low-res lead-in cut into the same common-window trim: extend
1090
- the window's start to the latest point any forced member actually
1091
- reached full resolution, so every clip stays cut to one shared window
1092
- (UT-synced) rather than each clip getting its own start. A member that
1093
- never reached full resolution before stop simply doesn't contribute
1094
- (its own lead-in stays untrimmed, per the documented edge case). This
1095
- only widens the physical trim cut: `commonUtWindow` on the returned
1096
- handle keeps reporting the clips' own raw UT intersection, unchanged,
1097
- since that's metadata about what the clips cover independent of
1098
- whether (or how) they got physically cut. */
1099
- let trimWindow = window;
1100
- if (trimWindow && resolutionReadyUts.length > 0) {
1101
- const windowStart = Math.max(trimWindow[0], ...resolutionReadyUts);
1102
- trimWindow = [windowStart, trimWindow[1]];
1103
- }
1104
-
1105
1309
  /* Physically remux-trim each clip to the common window so the grouped
1106
1310
  output is pre-aligned by default. The UT metadata (utSamples +
1107
1311
  commonUtWindow) still rides along so a consumer can align the trimmed
1108
1312
  clips to telemetry. Trimming never fails the recording: any clip that
1109
1313
  can't be cut cleanly degrades to metadata-only. */
1110
1314
  let finalRecordings = recordings;
1111
- if (trimWindow) {
1315
+ if (window) {
1112
1316
  const trimmer = await (this.groupTrimmers.get(groupId) ?? Promise.resolve(null));
1113
1317
  if (trimmer) {
1114
1318
  finalRecordings = await Promise.all(
1115
- recordings.map((rec) => this.maybeTrim(rec, trimWindow, trimmer)),
1319
+ recordings.map((rec) => this.maybeTrim(rec, window, trimmer)),
1116
1320
  );
1117
1321
  }
1118
1322
  }
@@ -1143,6 +1347,8 @@ export class RecordingController {
1143
1347
  /* MediaRecorder webm output isn't remuxed to mp4 here; degrade to
1144
1348
  metadata-only for those clips (mp4 is the default container). */
1145
1349
  if (!/mp4/i.test(rec.mimeType)) return rec;
1350
+ /* A member stopped while still arming has no media to cut. */
1351
+ if (rec.byteSize === 0) return rec;
1146
1352
  const startMs = utToRecordingTimeMs(rec.utSamples, window[0]);
1147
1353
  const endMs = utToRecordingTimeMs(rec.utSamples, window[1]);
1148
1354
  if (startMs == null || endMs == null || endMs <= startMs) return rec;
@@ -1173,6 +1379,9 @@ export class RecordingController {
1173
1379
  stop the same group at once). */
1174
1380
  let changed = false;
1175
1381
  const active = this.groups.get(groupId);
1382
+ /* Before the member discards below, so a still-arming group can't start
1383
+ its remaining members partway through being torn down. */
1384
+ this.cancelGroupArm(groupId);
1176
1385
  if (active) {
1177
1386
  /* discardRecording now splices ids out of this very array as it goes
1178
1387
  (fix for cancelling one grouped member); iterate a copy so removals