@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.
@@ -169,6 +169,35 @@ class FakeCamera implements RecordingClientCamera {
169
169
  emitState(state: CameraState): void {
170
170
  this.state = state;
171
171
  for (const l of [...this.listeners]) l(state);
172
+ if (this.decodedFollowsState) this.setDecoded(state.renderWidth, state.renderHeight);
173
+ }
174
+
175
+ /**
176
+ * When true (the default), every emitted state also lands as the decoded
177
+ * frame size at once, as if the decoder kept pace with the sidecar. A test
178
+ * modelling the real lag between the two sets this false and drives
179
+ * {@link setDecoded} itself.
180
+ */
181
+ decodedFollowsState = true;
182
+ decoded: { width: number; height: number } | null = null;
183
+ private readonly decodedListeners = new Set<(width: number, height: number) => void>();
184
+
185
+ /** Test hook: the decoder delivers a frame of this size. */
186
+ setDecoded(width: number, height: number): void {
187
+ this.decoded = { width, height };
188
+ for (const l of [...this.decodedListeners]) l(width, height);
189
+ }
190
+
191
+ /** Fake {@link DecodedSizeProbe} body: reports the current size at once, then each change. */
192
+ probe(onSize: (width: number, height: number) => void): () => void {
193
+ this.decodedListeners.add(onSize);
194
+ if (this.decoded) onSize(this.decoded.width, this.decoded.height);
195
+ return () => this.decodedListeners.delete(onSize);
196
+ }
197
+
198
+ /** Live decoded-size probe count, to assert a wait releases its probe. */
199
+ get probeCount(): number {
200
+ return this.decodedListeners.size;
172
201
  }
173
202
 
174
203
  /** Live "change" subscription count, to assert a wait releases its listener. */
@@ -444,7 +473,10 @@ describe("RecordingController force-full-resolution", () => {
444
473
  return new Blob([new Uint8Array(16)], { type: mimeType });
445
474
  },
446
475
  };
447
- ctrl = new RecordingController(client, { loadTrimmer: () => Promise.resolve(fakeTrimmer) });
476
+ ctrl = new RecordingController(client, {
477
+ loadTrimmer: () => Promise.resolve(fakeTrimmer),
478
+ probeDecodedSize: (flightId, _stream, onSize) => client.camera(flightId).probe(onSize),
479
+ });
448
480
  });
449
481
 
450
482
  describe("single (arm-and-wait)", () => {
@@ -651,97 +683,397 @@ describe("RecordingController force-full-resolution", () => {
651
683
  });
652
684
  });
653
685
 
654
- describe("grouped (record immediately)", () => {
655
- it("starts every member's recorder immediately, without arming", () => {
656
- const cam1 = client.camera(1);
657
- const cam2 = client.camera(2);
686
+ describe("grouped (arm every member, start together)", () => {
687
+ const fullState = (flightId: number): CameraState =>
688
+ makeCameraState({
689
+ flightId,
690
+ renderWidth: 1920,
691
+ renderHeight: 1080,
692
+ operatorWidth: 1920,
693
+ operatorHeight: 1080,
694
+ });
695
+ const tileState = (flightId: number): CameraState =>
696
+ makeCameraState({
697
+ flightId,
698
+ renderWidth: 720,
699
+ renderHeight: 400,
700
+ operatorWidth: 720,
701
+ operatorHeight: 400,
702
+ });
703
+ const armingOf = (id: string): boolean | undefined =>
704
+ ctrl.getSnapshot().active.find((a) => a.recordingId === id)?.arming;
705
+
706
+ let startSpy: ReturnType<typeof vi.spyOn>;
707
+ let cam1: FakeCamera;
708
+ let cam2: FakeCamera;
658
709
 
710
+ beforeEach(() => {
711
+ startSpy = vi.spyOn(StubMediaRecorder.prototype, "start");
712
+ /* maxRenderSize must be set BEFORE starting the group: watchResolutionReady
713
+ reads it synchronously at watch-start, so setting it afterwards would
714
+ silently exercise the (weaker) unknown-ceiling fallback instead. */
715
+ cam1 = client.camera(1);
716
+ cam2 = client.camera(2);
717
+ cam1.maxRenderSize = { width: 1920, height: 1080 };
718
+ cam2.maxRenderSize = { width: 1920, height: 1080 };
719
+ cam1.emitState(tileState(1));
720
+ cam2.emitState(tileState(2));
721
+ });
722
+
723
+ afterEach(() => {
724
+ startSpy.mockRestore();
725
+ vi.useRealTimers();
726
+ });
727
+
728
+ it("sends force for every member and arms them all without starting any recorder", () => {
659
729
  const { recordingIds } = ctrl.startGroupedRecording([1, 2], {
660
730
  forceFullResolution: true,
661
731
  });
662
732
 
663
733
  expect(cam1.forceCalls).toEqual([true]);
664
734
  expect(cam2.forceCalls).toEqual([true]);
665
- const active = ctrl.getSnapshot().active;
666
- expect(active).toHaveLength(2);
667
- expect(active.every((a) => a.arming === false)).toBe(true);
668
- expect(recordingIds).toHaveLength(2);
735
+ expect(recordingIds.map(armingOf)).toEqual([true, true]);
736
+ expect(startSpy).not.toHaveBeenCalled();
669
737
  });
670
738
 
671
- it("captures each member's resolution-ready UT and folds it into the common trim window", async () => {
739
+ it("starts every recorder together only once the last member reaches full resolution", async () => {
672
740
  client.captureUt = 500;
673
- /* maxRenderSize must be set BEFORE starting the group: watchResolutionReady
674
- reads it synchronously at watch-start, so setting it afterwards would
675
- silently exercise the (weaker) unknown-ceiling fallback instead of the
676
- known-ceiling path this test is meant to cover. */
677
- const cam1 = client.camera(1);
678
- const cam2 = client.camera(2);
679
- cam1.maxRenderSize = { width: 1920, height: 1080 };
680
- cam2.maxRenderSize = { width: 1920, height: 1080 };
681
- const { groupId, recordingIds } = ctrl.startGroupedRecording([1, 2], {
741
+ const { recordingIds } = ctrl.startGroupedRecording([1, 2], {
682
742
  forceFullResolution: true,
683
743
  });
684
- const [id1, id2] = recordingIds;
685
744
 
686
- /* Feed 1 reaches full resolution quickly. */
687
745
  client.tick(502);
688
- cam1.emitState(
689
- makeCameraState({
690
- flightId: 1,
691
- renderWidth: 1920,
692
- renderHeight: 1080,
693
- operatorWidth: 1920,
694
- operatorHeight: 1080,
695
- }),
696
- );
746
+ cam1.emitState(fullState(1));
697
747
  await flush();
748
+ /* One member ready is not enough: nobody starts yet. */
749
+ expect(startSpy).not.toHaveBeenCalled();
750
+ expect(recordingIds.map(armingOf)).toEqual([true, true]);
698
751
 
699
- /* Feed 2 reaches full resolution later; this later UT should win the fold. */
700
752
  client.tick(506);
701
- cam2.emitState(
702
- makeCameraState({
703
- flightId: 2,
704
- renderWidth: 1920,
705
- renderHeight: 1080,
706
- operatorWidth: 1920,
707
- operatorHeight: 1080,
708
- }),
753
+ cam2.emitState(fullState(2));
754
+ await flush();
755
+
756
+ expect(startSpy).toHaveBeenCalledTimes(2);
757
+ expect(recordingIds.map(armingOf)).toEqual([false, false]);
758
+ /* One shared clock base: both clips open at the same instant. */
759
+ const [a, b] = recordingIds.map(
760
+ (id) => ctrl.getSnapshot().active.find((x) => x.recordingId === id)!,
709
761
  );
762
+ expect(a.startedAt).toBe(b.startedAt);
763
+ /* The per-member watches were released once the group started. */
764
+ expect(cam1.changeListenerCount).toBe(0);
765
+ expect(cam2.changeListenerCount).toBe(0);
766
+ });
767
+
768
+ it("opens every clip at the start UT, so the common window starts there with no lead-in to cut", async () => {
769
+ client.captureUt = 500;
770
+ const { groupId, recordingIds } = ctrl.startGroupedRecording([1, 2], {
771
+ forceFullResolution: true,
772
+ });
773
+ client.tick(503);
774
+ cam1.emitState(fullState(1));
775
+ cam2.emitState(fullState(2));
710
776
  await flush();
711
777
 
712
778
  client.captureUt = 510;
713
779
  const handle = await ctrl.stopGroupedRecording(groupId);
714
780
 
715
- expect(handle.commonUtWindow).toEqual([500, 510]);
781
+ for (const rec of handle.recordings) {
782
+ expect(rec.utStart).toBe(503);
783
+ expect(rec.byteSize).toBeGreaterThan(0);
784
+ }
785
+ expect(handle.commonUtWindow).toEqual([503, 510]);
716
786
  expect(trimCalls).toHaveLength(2);
787
+ const rec1 = handle.recordings.find((r) => r.recordingId === recordingIds[0])!;
788
+ expect(trimCalls[0].startMs).toBe(utToRecordingTimeMs(rec1.utSamples, 503));
789
+ expect(trimCalls[0].startMs).toBe(0);
790
+ });
791
+
792
+ it("starts every member anyway once the shared ARM_TIMEOUT_MS passes", async () => {
793
+ vi.useFakeTimers();
794
+ const { recordingIds } = ctrl.startGroupedRecording([1, 2], {
795
+ forceFullResolution: true,
796
+ });
797
+ cam1.emitState(fullState(1));
798
+ await vi.advanceTimersByTimeAsync(ARM_TIMEOUT_MS - 1);
799
+ expect(startSpy).not.toHaveBeenCalled();
800
+ expect(recordingIds.map(armingOf)).toEqual([true, true]);
801
+
802
+ await vi.advanceTimersByTimeAsync(1);
803
+
804
+ expect(startSpy).toHaveBeenCalledTimes(2);
805
+ expect(recordingIds.map(armingOf)).toEqual([false, false]);
806
+ /* The straggler's watch was cancelled, not leaked. */
807
+ expect(cam2.changeListenerCount).toBe(0);
808
+ });
809
+
810
+ it("starts promptly when every member is already at its ceiling", async () => {
811
+ cam1.emitState(fullState(1));
812
+ cam2.emitState(fullState(2));
813
+ const { recordingIds } = ctrl.startGroupedRecording([1, 2], {
814
+ forceFullResolution: true,
815
+ });
816
+ await flush();
717
817
 
718
- /* recordings/trimCalls preserve the [id1, id2] order Promise.all was
719
- given, regardless of which member's trim settles first. */
720
- const rec1 = handle.recordings.find((r) => r.recordingId === id1)!;
721
- const rec2 = handle.recordings.find((r) => r.recordingId === id2)!;
722
- const [call1, call2] = trimCalls;
723
- /* The window start used is the LATEST resolution-ready UT (506), not
724
- the raw common window start (500), for both clips alike (one common
725
- window, UT-synced). */
726
- expect(call1.startMs).toBe(utToRecordingTimeMs(rec1.utSamples, 506));
727
- expect(call2.startMs).toBe(utToRecordingTimeMs(rec2.utSamples, 506));
728
- /* Confirms the fold actually moved the start later than the untouched
729
- common window would have. */
730
- expect(call1.startMs).toBeGreaterThan(utToRecordingTimeMs(rec1.utSamples, 500)!);
818
+ expect(startSpy).toHaveBeenCalledTimes(2);
819
+ expect(recordingIds.map(armingOf)).toEqual([false, false]);
731
820
  });
732
821
 
733
- it("leaves the lead-in untrimmed when no member ever reaches full resolution", async () => {
822
+ it("discarding the slow member mid-arm releases its force and lets the rest start", async () => {
823
+ const { groupId, recordingIds } = ctrl.startGroupedRecording([1, 2], {
824
+ forceFullResolution: true,
825
+ });
826
+ cam1.emitState(fullState(1));
827
+ await flush();
828
+ expect(startSpy).not.toHaveBeenCalled();
829
+
830
+ ctrl.discardRecording(recordingIds[1]);
831
+
832
+ expect(cam2.forceCalls).toEqual([true, false]);
833
+ expect(cam2.changeListenerCount).toBe(0);
834
+ expect(startSpy).toHaveBeenCalledTimes(1);
835
+ expect(armingOf(recordingIds[0])).toBe(false);
836
+
837
+ const handle = await ctrl.stopGroupedRecording(groupId);
838
+ expect(handle.recordings.map((r) => r.recordingId)).toEqual([recordingIds[0]]);
839
+ expect(handle.recordings[0].byteSize).toBeGreaterThan(0);
840
+ });
841
+
842
+ it("discarding the whole group mid-arm releases every force and never starts a recorder", async () => {
843
+ const { groupId } = ctrl.startGroupedRecording([1, 2], { forceFullResolution: true });
844
+ cam1.emitState(fullState(1));
845
+ await flush();
846
+
847
+ ctrl.discardGroupedRecording(groupId);
848
+
849
+ expect(cam1.forceCalls).toEqual([true, false]);
850
+ expect(cam2.forceCalls).toEqual([true, false]);
851
+ expect(cam2.changeListenerCount).toBe(0);
852
+ expect(ctrl.getSnapshot().active).toHaveLength(0);
853
+
854
+ cam2.emitState(fullState(2));
855
+ await flush();
856
+ expect(startSpy).not.toHaveBeenCalled();
857
+ });
858
+
859
+ it("stopping the group mid-arm settles empty clips, releases force, and never starts later", async () => {
860
+ vi.useFakeTimers();
734
861
  client.captureUt = 100;
735
862
  const { groupId } = ctrl.startGroupedRecording([1, 2], { forceFullResolution: true });
736
- client.captureUt = 105;
863
+
864
+ client.captureUt = 101;
737
865
  const handle = await ctrl.stopGroupedRecording(groupId);
738
866
 
739
- expect(handle.commonUtWindow).toEqual([100, 105]);
740
- expect(trimCalls).toHaveLength(2);
741
- for (const call of trimCalls) {
742
- expect(call.startMs).toBe(utToRecordingTimeMs(handle.recordings[0].utSamples, 100));
867
+ expect(handle.recordings).toHaveLength(2);
868
+ for (const rec of handle.recordings) expect(rec.byteSize).toBe(0);
869
+ expect(cam1.forceCalls).toEqual([true, false]);
870
+ expect(cam2.forceCalls).toEqual([true, false]);
871
+ /* Empty clips have nothing to cut. */
872
+ expect(trimCalls).toHaveLength(0);
873
+ expect(ctrl.getSnapshot().active).toHaveLength(0);
874
+
875
+ /* Neither a late bump nor the old timeout resurrects anything. */
876
+ cam1.emitState(fullState(1));
877
+ cam2.emitState(fullState(2));
878
+ await vi.advanceTimersByTimeAsync(ARM_TIMEOUT_MS);
879
+ expect(startSpy).not.toHaveBeenCalled();
880
+ });
881
+
882
+ it("stopping one member mid-arm lets the rest start without it", async () => {
883
+ const { recordingIds } = ctrl.startGroupedRecording([1, 2], {
884
+ forceFullResolution: true,
885
+ });
886
+ cam1.emitState(fullState(1));
887
+ await flush();
888
+
889
+ const stopped = await ctrl.stopRecording(recordingIds[1]);
890
+
891
+ expect(stopped.byteSize).toBe(0);
892
+ expect(cam2.forceCalls).toEqual([true, false]);
893
+ expect(startSpy).toHaveBeenCalledTimes(1);
894
+ expect(armingOf(recordingIds[0])).toBe(false);
895
+ });
896
+
897
+ it("leaves a group without full resolution exactly as before: no force, no arming, recorders start at once", () => {
898
+ const { recordingIds } = ctrl.startGroupedRecording([1, 2]);
899
+
900
+ expect(cam1.forceCalls).toEqual([]);
901
+ expect(cam2.forceCalls).toEqual([]);
902
+ expect(recordingIds.map(armingOf)).toEqual([false, false]);
903
+ expect(startSpy).toHaveBeenCalledTimes(2);
904
+ expect(cam1.changeListenerCount).toBe(0);
905
+ });
906
+ });
907
+
908
+ describe("decoded-frame gate", () => {
909
+ const sized = (flightId: number, width: number, height: number): CameraState =>
910
+ makeCameraState({
911
+ flightId,
912
+ renderWidth: width,
913
+ renderHeight: height,
914
+ operatorWidth: width,
915
+ operatorHeight: height,
916
+ });
917
+ const armingOf = (id: string): boolean | undefined =>
918
+ ctrl.getSnapshot().active.find((a) => a.recordingId === id)?.arming;
919
+
920
+ let infoSpy: ReturnType<typeof vi.spyOn>;
921
+
922
+ /* The real lag: the state reports the new size before the decoder
923
+ delivers it, so each camera here starts at the tile size with the
924
+ decoder decoupled from the state. */
925
+ const laggingCamera = (flightId: number): FakeCamera => {
926
+ const cam = client.camera(flightId);
927
+ cam.maxRenderSize = { width: 1024, height: 576 };
928
+ cam.decodedFollowsState = false;
929
+ cam.emitState(sized(flightId, 752, 416));
930
+ cam.setDecoded(752, 416);
931
+ return cam;
932
+ };
933
+
934
+ beforeEach(() => {
935
+ infoSpy = vi.spyOn(console, "info").mockImplementation(() => {});
936
+ });
937
+
938
+ afterEach(() => {
939
+ infoSpy.mockRestore();
940
+ vi.useRealTimers();
941
+ });
942
+
943
+ it("keeps a single recording armed while the state reports full size but decoded frames are still tile-size", async () => {
944
+ const cam = laggingCamera(42);
945
+ const id = ctrl.startRecording(42, { forceFullResolution: true });
946
+
947
+ cam.emitState(sized(42, 1024, 576));
948
+ await flush();
949
+ expect(armingOf(id)).toBe(true);
950
+
951
+ cam.setDecoded(1024, 576);
952
+ await flush();
953
+ expect(armingOf(id)).toBe(false);
954
+ expect(cam.probeCount).toBe(0);
955
+ expect(infoSpy).toHaveBeenCalledWith(
956
+ `kerbcast: recording ${id} started after arming (full resolution); decoded size 42=1024x576`,
957
+ );
958
+ });
959
+
960
+ it("accepts a decoded size a few pixels under the ceiling (encoder rounding)", async () => {
961
+ const cam = laggingCamera(42);
962
+ const id = ctrl.startRecording(42, { forceFullResolution: true });
963
+
964
+ cam.setDecoded(1024, 570);
965
+ await flush();
966
+ expect(armingOf(id)).toBe(false);
967
+ });
968
+
969
+ it("starts on ARM_TIMEOUT_MS when decoded frames never reach full size, logging the timeout and releasing the probe", async () => {
970
+ vi.useFakeTimers();
971
+ const cam = laggingCamera(42);
972
+ const id = ctrl.startRecording(42, { forceFullResolution: true });
973
+ cam.emitState(sized(42, 1024, 576));
974
+
975
+ await vi.advanceTimersByTimeAsync(ARM_TIMEOUT_MS - 1);
976
+ expect(armingOf(id)).toBe(true);
977
+ await vi.advanceTimersByTimeAsync(1);
978
+
979
+ expect(armingOf(id)).toBe(false);
980
+ expect(cam.probeCount).toBe(0);
981
+ expect(infoSpy).toHaveBeenCalledWith(
982
+ `kerbcast: recording ${id} started after arming (arm timeout); decoded size 42=752x416`,
983
+ );
984
+ });
985
+
986
+ it("releases the probe when a recording is discarded while waiting on decoded frames", async () => {
987
+ const cam = laggingCamera(42);
988
+ const id = ctrl.startRecording(42, { forceFullResolution: true });
989
+ expect(cam.probeCount).toBe(1);
990
+
991
+ ctrl.discardRecording(id);
992
+ expect(cam.probeCount).toBe(0);
993
+
994
+ cam.setDecoded(1024, 576);
995
+ await flush();
996
+ expect(ctrl.getSnapshot().active).toHaveLength(0);
997
+ });
998
+
999
+ it("releases the probe when a recording is stopped while waiting on decoded frames", async () => {
1000
+ const cam = laggingCamera(42);
1001
+ const id = ctrl.startRecording(42, { forceFullResolution: true });
1002
+
1003
+ const handle = await ctrl.stopRecording(id);
1004
+
1005
+ expect(handle.byteSize).toBe(0);
1006
+ expect(cam.probeCount).toBe(0);
1007
+ });
1008
+
1009
+ it("starts no probe and falls back to the state check when the ceiling is unknown", async () => {
1010
+ const cam = laggingCamera(42);
1011
+ cam.maxRenderSize = null;
1012
+ const id = ctrl.startRecording(42, { forceFullResolution: true });
1013
+ expect(cam.probeCount).toBe(0);
1014
+
1015
+ cam.emitState(sized(42, 1024, 576));
1016
+ await flush();
1017
+ expect(armingOf(id)).toBe(false);
1018
+ });
1019
+
1020
+ it("starts a group only once every member's decoded frames reach full size, whatever the state says", async () => {
1021
+ const startSpy = vi.spyOn(StubMediaRecorder.prototype, "start");
1022
+ try {
1023
+ const cam1 = laggingCamera(1);
1024
+ const cam2 = laggingCamera(2);
1025
+ const { groupId, recordingIds } = ctrl.startGroupedRecording([1, 2], {
1026
+ forceFullResolution: true,
1027
+ });
1028
+
1029
+ cam1.emitState(sized(1, 1024, 576));
1030
+ cam2.emitState(sized(2, 1024, 576));
1031
+ cam1.setDecoded(1024, 576);
1032
+ await flush();
1033
+ expect(startSpy).not.toHaveBeenCalled();
1034
+ expect(recordingIds.map(armingOf)).toEqual([true, true]);
1035
+
1036
+ cam2.setDecoded(1024, 576);
1037
+ await flush();
1038
+ expect(startSpy).toHaveBeenCalledTimes(2);
1039
+ expect(cam1.probeCount).toBe(0);
1040
+ expect(cam2.probeCount).toBe(0);
1041
+ expect(infoSpy).toHaveBeenCalledWith(
1042
+ `kerbcast: grouped recording ${groupId} started 2 member(s) after arming (all at full resolution); decoded sizes 1=1024x576 2=1024x576`,
1043
+ );
1044
+ } finally {
1045
+ startSpy.mockRestore();
743
1046
  }
744
1047
  });
1048
+
1049
+ it("logs the timeout and each member's last decoded size when a group times out", async () => {
1050
+ vi.useFakeTimers();
1051
+ const cam1 = laggingCamera(1);
1052
+ const cam2 = laggingCamera(2);
1053
+ const { groupId } = ctrl.startGroupedRecording([1, 2], { forceFullResolution: true });
1054
+ cam1.setDecoded(1024, 576);
1055
+
1056
+ await vi.advanceTimersByTimeAsync(ARM_TIMEOUT_MS);
1057
+
1058
+ expect(cam2.probeCount).toBe(0);
1059
+ expect(infoSpy).toHaveBeenCalledWith(
1060
+ `kerbcast: grouped recording ${groupId} started 2 member(s) after arming (arm timeout); decoded sizes 1=1024x576 2=752x416`,
1061
+ );
1062
+ });
1063
+
1064
+ it("releases every member's probe when a group is stopped or discarded mid-arm", async () => {
1065
+ const cam1 = laggingCamera(1);
1066
+ const cam2 = laggingCamera(2);
1067
+ const first = ctrl.startGroupedRecording([1, 2], { forceFullResolution: true });
1068
+ expect(cam1.probeCount + cam2.probeCount).toBe(2);
1069
+ await ctrl.stopGroupedRecording(first.groupId);
1070
+ expect(cam1.probeCount + cam2.probeCount).toBe(0);
1071
+
1072
+ const second = ctrl.startGroupedRecording([1, 2], { forceFullResolution: true });
1073
+ expect(cam1.probeCount + cam2.probeCount).toBe(2);
1074
+ ctrl.discardGroupedRecording(second.groupId);
1075
+ expect(cam1.probeCount + cam2.probeCount).toBe(0);
1076
+ });
745
1077
  });
746
1078
 
747
1079
  describe("release / ref-counting", () => {