@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/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.
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
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
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
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
|
|
152
|
-
*
|
|
153
|
-
*
|
|
154
|
-
*
|
|
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
|
|
418
|
-
*
|
|
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
|
-
*
|
|
425
|
-
*
|
|
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
|
-
*
|
|
595
|
-
*
|
|
596
|
-
*
|
|
597
|
-
*
|
|
598
|
-
*
|
|
599
|
-
*
|
|
600
|
-
*
|
|
601
|
-
*
|
|
602
|
-
*
|
|
603
|
-
*
|
|
604
|
-
*
|
|
605
|
-
*
|
|
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
|
|
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
|
|
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
|
-
*
|
|
654
|
-
*
|
|
655
|
-
*
|
|
656
|
-
*
|
|
657
|
-
*
|
|
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
|
-
): {
|
|
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
|
-
/*
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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
|
-
|
|
680
|
-
if (timer != null) clearTimeout(timer);
|
|
681
|
-
unsubscribe();
|
|
794
|
+
release();
|
|
682
795
|
resolve(reached);
|
|
683
796
|
};
|
|
684
797
|
|
|
685
|
-
if (
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
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
|
-
|
|
699
|
-
|
|
700
|
-
|
|
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
|
-
|
|
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
|
-
* `
|
|
722
|
-
*
|
|
723
|
-
*
|
|
724
|
-
*
|
|
725
|
-
*
|
|
726
|
-
*
|
|
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 &&
|
|
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.
|
|
1001
|
-
*
|
|
1002
|
-
*
|
|
1003
|
-
*
|
|
1004
|
-
*
|
|
1005
|
-
*
|
|
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
|
-
/*
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
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 (
|
|
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,
|
|
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
|