@ksp-gonogo/kerbcast-react 1.11.0-rc.4 → 1.11.0

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/README.md CHANGED
@@ -68,6 +68,19 @@ comments):
68
68
  feed, and whether it draws its own standby icon (set `false` when a
69
69
  container renders a single shared out-of-flight overlay).
70
70
  - `useStream`. Override how the stream is sourced. See below.
71
+ - `renderSize`. `"auto"` (the default) measures the feed and asks the
72
+ sidecar for a matching resolution; `"none"` leaves resolution alone.
73
+
74
+ `renderSize="auto"` picks the resolution, never the shape. The feed takes
75
+ the camera's configured capture aspect (`CameraState.maxWidth` x
76
+ `maxHeight`, from settings.cfg; 16:9 when unknown), centres itself at that
77
+ aspect inside whatever box you give it, and asks for the size of that
78
+ picture: width rounded up to a multiple of 16, height at the aspect. A
79
+ resize that only follows the incoming stream is never reported back, so a
80
+ frame you size from the video (or from `renderWidth`/`renderHeight`)
81
+ cannot loop with the request. A resize within about a second of the stream
82
+ changing that matches its new shape counts as following it. Layout changes
83
+ outside that window always report.
71
84
 
72
85
  An imperative `CameraFeedHandle` (via `ref`) exposes `stepCamera`,
73
86
  `setPanAxis`, `setZoomRate`, `nudgePan`, and `nudgeZoom` for wiring feeds
@@ -89,10 +102,12 @@ quality follows how large it is shown (pass `reportSize={false}` to opt out).
89
102
 
90
103
  ## Hooks
91
104
 
92
- - `useReportDisplaySize(flightId, ref)`. Self-measure an element and drive the
93
- client's per-consumer `reportDisplaySize` (auto-resolution). The feed
94
- primitives use it internally; multiple views of one camera from a client
95
- collapse to a single MAX report.
105
+ - `useReportDisplaySize(flightId, ref, options)`. Self-measure an element and
106
+ drive the client's per-consumer `reportDisplaySize` (auto-resolution). The
107
+ feed primitives use it internally; multiple views of one camera from a
108
+ client collapse to a single MAX report. Pass `aspect` for the shape to ask
109
+ for (default 16:9), and `videoRef` / `streamSize` so a resize that follows
110
+ the stream is not reported.
96
111
 
97
112
  - `useKerbcastCameras(): CameraState[]`. The live camera registry,
98
113
  re-rendering as cameras appear, change, or are destroyed.
@@ -12,7 +12,7 @@ import { useKerbcastCameras } from "./hooks/useKerbcastCameras";
12
12
  import { useKerbcastInFlight } from "./hooks/useKerbcastInFlight";
13
13
  import { useKerbcastStream } from "./hooks/useKerbcastStream";
14
14
  import { useRecordings } from "./hooks/useRecordings";
15
- import { useReportDisplaySize } from "./hooks/useReportDisplaySize";
15
+ import { DEFAULT_FEED_ASPECT, useReportDisplaySize } from "./hooks/useReportDisplaySize";
16
16
  import { useTargetBracketsPreference } from "./hooks/useTargetBracketsPreference";
17
17
  import { StandbyIcon } from "./StandbyIcon";
18
18
  import { isCameraDestroyed } from "./lifecycle";
@@ -391,10 +391,29 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
391
391
  // -------------------------------------------------------------------------
392
392
  // Display-size reporting (auto-resolution). The feed self-measures its
393
393
  // rendered box and reports it per-consumer; the sidecar maxes across
394
- // consumers. Reports the real w x h (part cams are not square). Disabled by
395
- // `renderSize="none"`. See useReportDisplaySize for debounce / bucketing.
394
+ // consumers. Auto picks the resolution, never the shape: the request and
395
+ // the feed's own box keep the camera's configured capture aspect (16:9
396
+ // when unknown). Disabled by `renderSize="none"`. The video and the
397
+ // sidecar's render size let it ignore a resize that only follows the
398
+ // stream. See useReportDisplaySize for debounce / bucketing / the guard.
396
399
  // -------------------------------------------------------------------------
397
- useReportDisplaySize(flightId, wrapRef, { enabled: renderSize === "auto" });
400
+ const baseW = camera?.maxWidth ?? 0;
401
+ const baseH = camera?.maxHeight ?? 0;
402
+ const hasBase = baseW > 0 && baseH > 0;
403
+ const feedAspect = hasBase ? baseW / baseH : DEFAULT_FEED_ASPECT;
404
+ const feedShape = useMemo(() => ({
405
+ "--kc-feed-w": hasBase ? baseW : 16,
406
+ "--kc-feed-h": hasBase ? baseH : 9,
407
+ }), [hasBase, baseW, baseH]);
408
+ const renderedW = camera?.renderWidth ?? 0;
409
+ const renderedH = camera?.renderHeight ?? 0;
410
+ const renderedSize = useMemo(() => ({ width: renderedW, height: renderedH }), [renderedW, renderedH]);
411
+ useReportDisplaySize(flightId, wrapRef, {
412
+ enabled: renderSize === "auto",
413
+ aspect: feedAspect,
414
+ videoRef,
415
+ streamSize: renderedSize,
416
+ });
398
417
  // -------------------------------------------------------------------------
399
418
  // UI state
400
419
  // -------------------------------------------------------------------------
@@ -672,41 +691,41 @@ const CameraFeedInner = forwardRef(function CameraFeedInner({ flightId: requeste
672
691
  builtInActions);
673
692
  const stepButtons = hasCameras ? (_jsxs(StepButtons, { children: [_jsx(OverlayIconButton, { type: "button", "aria-label": "Previous camera", disabled: !canStep, onClick: () => stepCamera(-1), children: "\u2039" }), _jsx(OverlayIconButton, { type: "button", "aria-label": "Next camera", disabled: !canStep, onClick: () => stepCamera(1), children: "\u203A" })] })) : null;
674
693
  const actionBar = hasActionBar ? (_jsx(FeedActionBar, { leading: stepButtons, entries: entries })) : null;
675
- return (_jsx(Stage, { ref: wrapRef, "$pinned": chromePinned, children: flightId === null ? (_jsxs(_Fragment, { children: [_jsx(Empty, { children: emptyMessage }), topOverlay, actionBar] })) : (_jsxs(_Fragment, { children: [_jsx(StyledVideo, { ref: attachVideo, autoPlay: true, playsInline: true, muted: true, controls: false, onClick: () => setChromePinned((v) => !v) }), topOverlay, actionBar, qualityAvailable &&
676
- qualityMenu.open &&
677
- qualityMenu.position &&
678
- camera &&
679
- createPortal(_jsxs(QualityMenu, { ref: qualityMenu.menuRef, id: qualityMenuId, role: "menu", "aria-label": "Quality", style: qualityMenu.position, children: [_jsx(CameraMenuItem, { type: "button", role: "menuitemradio", "aria-checked": camera.viewerQuality == null, "$selected": camera.viewerQuality == null, onClick: () => selectQuality(null), children: "Auto" }), QUALITY_PRESETS.map(({ preset, label, scale }) => (_jsxs(CameraMenuItem, { type: "button", role: "menuitemradio", "aria-checked": camera.viewerQuality === preset, "$selected": camera.viewerQuality === preset, onClick: () => selectQuality(preset), children: [label, " (", presetDim(camera.operatorWidth, scale), "\u00D7", presetDim(camera.operatorHeight, scale), ")"] }, preset))), _jsxs(QualityMeta, { role: "status", children: ["now ", camera.renderWidth, "\u00D7", camera.renderHeight, qualityThrottled ? " · throttled" : ""] })] }), document.body), trackingAvailable &&
680
- trackingMenu.open &&
681
- trackingMenu.position &&
682
- camera &&
683
- createPortal(_jsxs(QualityMenu, { ref: trackingMenu.menuRef, id: trackingMenuId, role: "menu", "aria-label": "Auto-track", style: trackingMenu.position, children: [_jsx(CameraMenuItem, { type: "button", role: "menuitemradio", "aria-checked": trackMode === TrackMode.ActiveVessel, "$selected": trackMode === TrackMode.ActiveVessel, onClick: () => selectTrack(TrackMode.ActiveVessel), children: "Track active vessel" }), _jsx(CameraMenuItem, { type: "button", role: "menuitemradio", "aria-checked": trackMode === TrackMode.Target, "$selected": trackMode === TrackMode.Target, onClick: () => selectTrack(TrackMode.Target), children: "Track target" })] }), document.body), commandTargetAvailable &&
684
- commandTargetMenu.open &&
685
- commandTargetMenu.position &&
686
- createPortal(_jsx(CameraMenu, { ref: commandTargetMenu.menuRef, id: commandTargetMenuId, role: "menu", "aria-label": "Target", style: commandTargetMenu.position, children: commandTargets.map((t) => {
687
- const eligible = t.eligibility === CommandTargetEligibility.Ok;
688
- const current = t.persistentId === commandTargetId;
689
- return (_jsxs(CameraMenuItem, { type: "button", role: "menuitemradio", "aria-checked": current, "$selected": current, disabled: !eligible, title: eligible ? undefined : t.eligibilityText, onClick: () => selectCommandTarget(t.persistentId), children: [t.vesselName, t.rangeM != null ? ` · ${formatCommandRange(t.rangeM)}` : "", eligible ? "" : ` · ${t.eligibilityText}`] }, t.persistentId));
690
- }) }), document.body), commandState &&
691
- !outOfFlight &&
692
- (_jsxs(_Fragment, { children: [commandState.blank && _jsx(CommandBlank, { reason: commandState.blank }), targetBracketsAvailable && bracketsOn && (_jsx(CommandBrackets, { state: commandState, displayUt: commandDisplayUt?.ut ?? commandTrack.ut, utSource: commandDisplayUt?.source ?? null, video: videoEl, fallbackSize: camera ? { width: camera.renderWidth, height: camera.renderHeight } : null, overlay: targetBracketOverlay }))] })), outOfFlight ? (showStandbyIcon && (_jsx(StandbyOverlay, { role: "status", "aria-label": "Standby, no active flight", children: _jsx(StandbyIconWrap, { children: _jsx(StandbyIcon, { size: 40 }) }) }))) : (isDestroyed && (_jsx(SignalLostOverlay, { role: "status", "aria-label": "Signal lost", children: _jsx(SignalLostText, { "$animated": effectiveShowStatic, children: "SIGNAL LOST" }) }))), !outOfFlight && !effectiveShowStatic && isStale && !isDestroyed && (_jsxs(_Fragment, { children: [_jsx(StaleScrim, { "aria-hidden": "true" }), _jsxs(StaleBadge, { role: "status", "aria-label": "Feed stale", children: [_jsx(StaleIcon, {}), "Stale"] })] })), recordingActive && armingActive && (_jsxs(ArmingBadge, { role: "status", "aria-label": "Arming", children: [_jsx(ArmingDot, { "aria-hidden": "true" }), "ARMING"] })), recordingActive && !armingActive && (_jsxs(RecBadge, { role: "status", "aria-label": "Recording", children: [_jsx(RecDot, { "aria-hidden": "true" }), "REC ", formatElapsed(recordingElapsedMs)] })), showZoom && (_jsxs(ZoomControlsWrap, { "$disabled": manualDisabled, "aria-hidden": manualDisabled, children: [_jsx(ZoomButton, { type: "button", "aria-label": "Zoom in", "$pos": "top", disabled: manualDisabled, onPointerDown: () => controllerRef.current?.setZoomRate(1), onPointerUp: () => controllerRef.current?.setZoomRate(0), onPointerLeave: () => controllerRef.current?.setZoomRate(0), onPointerCancel: () => controllerRef.current?.setZoomRate(0), onClick: (e) => {
693
- if (e.detail === 0)
694
- controllerRef.current?.nudgeZoom(-1);
695
- }, children: "+" }), _jsx(FovSlider, { type: "range", "aria-label": "Zoom", min: camera.fovMin, max: camera.fovMax, step: 0.5, value: sliderFov, disabled: manualDisabled, onChange: (e) => {
696
- const v = Number(e.target.value);
697
- controllerRef.current?.fovSliderInput(v);
698
- }, onPointerDown: () => {
699
- controllerRef.current?.setFovSliderDragging(true);
700
- }, onPointerUp: () => {
701
- controllerRef.current?.setFovSliderDragging(false);
702
- }, onPointerCancel: () => {
703
- controllerRef.current?.setFovSliderDragging(false);
704
- } }), _jsx(ZoomButton, { type: "button", "aria-label": "Zoom out", "$pos": "bottom", disabled: manualDisabled, onPointerDown: () => controllerRef.current?.setZoomRate(-1), onPointerUp: () => controllerRef.current?.setZoomRate(0), onPointerLeave: () => controllerRef.current?.setZoomRate(0), onPointerCancel: () => controllerRef.current?.setZoomRate(0), onClick: (e) => {
705
- if (e.detail === 0)
706
- controllerRef.current?.nudgeZoom(1);
707
- }, children: "\u2212" })] })), showPan && (_jsxs(PanControl, { role: "group", "aria-label": "Pan camera", "$disabled": manualDisabled, "aria-hidden": manualDisabled, children: [_jsx(PanArrow, { type: "button", "$dir": "up", "aria-label": "Pan up", disabled: manualDisabled || !supportsPitch, onClick: () => controllerRef.current?.nudgePan(0, 1), children: "\u25B2" }), _jsx(PanArrow, { type: "button", "$dir": "down", "aria-label": "Pan down", disabled: manualDisabled || !supportsPitch, onClick: () => controllerRef.current?.nudgePan(0, -1), children: "\u25BC" }), _jsx(PanArrow, { type: "button", "$dir": "left", "aria-label": "Pan left", disabled: manualDisabled, onClick: () => controllerRef.current?.nudgePan(-1, 0), children: "\u25C0" }), _jsx(PanArrow, { type: "button", "$dir": "right", "aria-label": "Pan right", disabled: manualDisabled, onClick: () => controllerRef.current?.nudgePan(1, 0), children: "\u25B6" }), _jsx(PanBall, { "aria-hidden": "true", title: "Drag to pan", onPointerDown: manualDisabled ? undefined : handleBallDown, onPointerMove: manualDisabled ? undefined : handleBallMove, onPointerUp: manualDisabled ? undefined : handleBallUp, onPointerCancel: manualDisabled ? undefined : handleBallUp, style: {
708
- transform: `translate(${ballPos.x}px, ${ballPos.y}px)`,
709
- } })] }))] })) }));
694
+ return (_jsx(FeedBox, { style: feedShape, children: _jsx(Stage, { ref: wrapRef, "$pinned": chromePinned, children: flightId === null ? (_jsxs(_Fragment, { children: [_jsx(Empty, { children: emptyMessage }), topOverlay, actionBar] })) : (_jsxs(_Fragment, { children: [_jsx(StyledVideo, { ref: attachVideo, autoPlay: true, playsInline: true, muted: true, controls: false, onClick: () => setChromePinned((v) => !v) }), topOverlay, actionBar, qualityAvailable &&
695
+ qualityMenu.open &&
696
+ qualityMenu.position &&
697
+ camera &&
698
+ createPortal(_jsxs(QualityMenu, { ref: qualityMenu.menuRef, id: qualityMenuId, role: "menu", "aria-label": "Quality", style: qualityMenu.position, children: [_jsx(CameraMenuItem, { type: "button", role: "menuitemradio", "aria-checked": camera.viewerQuality == null, "$selected": camera.viewerQuality == null, onClick: () => selectQuality(null), children: "Auto" }), QUALITY_PRESETS.map(({ preset, label, scale }) => (_jsxs(CameraMenuItem, { type: "button", role: "menuitemradio", "aria-checked": camera.viewerQuality === preset, "$selected": camera.viewerQuality === preset, onClick: () => selectQuality(preset), children: [label, " (", presetDim(camera.operatorWidth, scale), "\u00D7", presetDim(camera.operatorHeight, scale), ")"] }, preset))), _jsxs(QualityMeta, { role: "status", children: ["now ", camera.renderWidth, "\u00D7", camera.renderHeight, qualityThrottled ? " · throttled" : ""] })] }), document.body), trackingAvailable &&
699
+ trackingMenu.open &&
700
+ trackingMenu.position &&
701
+ camera &&
702
+ createPortal(_jsxs(QualityMenu, { ref: trackingMenu.menuRef, id: trackingMenuId, role: "menu", "aria-label": "Auto-track", style: trackingMenu.position, children: [_jsx(CameraMenuItem, { type: "button", role: "menuitemradio", "aria-checked": trackMode === TrackMode.ActiveVessel, "$selected": trackMode === TrackMode.ActiveVessel, onClick: () => selectTrack(TrackMode.ActiveVessel), children: "Track active vessel" }), _jsx(CameraMenuItem, { type: "button", role: "menuitemradio", "aria-checked": trackMode === TrackMode.Target, "$selected": trackMode === TrackMode.Target, onClick: () => selectTrack(TrackMode.Target), children: "Track target" })] }), document.body), commandTargetAvailable &&
703
+ commandTargetMenu.open &&
704
+ commandTargetMenu.position &&
705
+ createPortal(_jsx(CameraMenu, { ref: commandTargetMenu.menuRef, id: commandTargetMenuId, role: "menu", "aria-label": "Target", style: commandTargetMenu.position, children: commandTargets.map((t) => {
706
+ const eligible = t.eligibility === CommandTargetEligibility.Ok;
707
+ const current = t.persistentId === commandTargetId;
708
+ return (_jsxs(CameraMenuItem, { type: "button", role: "menuitemradio", "aria-checked": current, "$selected": current, disabled: !eligible, title: eligible ? undefined : t.eligibilityText, onClick: () => selectCommandTarget(t.persistentId), children: [t.vesselName, t.rangeM != null ? ` · ${formatCommandRange(t.rangeM)}` : "", eligible ? "" : ` · ${t.eligibilityText}`] }, t.persistentId));
709
+ }) }), document.body), commandState &&
710
+ !outOfFlight &&
711
+ (_jsxs(_Fragment, { children: [commandState.blank && _jsx(CommandBlank, { reason: commandState.blank }), targetBracketsAvailable && bracketsOn && (_jsx(CommandBrackets, { state: commandState, displayUt: commandDisplayUt?.ut ?? commandTrack.ut, utSource: commandDisplayUt?.source ?? null, video: videoEl, fallbackSize: camera ? { width: camera.renderWidth, height: camera.renderHeight } : null, overlay: targetBracketOverlay }))] })), outOfFlight ? (showStandbyIcon && (_jsx(StandbyOverlay, { role: "status", "aria-label": "Standby, no active flight", children: _jsx(StandbyIconWrap, { children: _jsx(StandbyIcon, { size: 40 }) }) }))) : (isDestroyed && (_jsx(SignalLostOverlay, { role: "status", "aria-label": "Signal lost", children: _jsx(SignalLostText, { "$animated": effectiveShowStatic, children: "SIGNAL LOST" }) }))), !outOfFlight && !effectiveShowStatic && isStale && !isDestroyed && (_jsxs(_Fragment, { children: [_jsx(StaleScrim, { "aria-hidden": "true" }), _jsxs(StaleBadge, { role: "status", "aria-label": "Feed stale", children: [_jsx(StaleIcon, {}), "Stale"] })] })), recordingActive && armingActive && (_jsxs(ArmingBadge, { role: "status", "aria-label": "Arming", children: [_jsx(ArmingDot, { "aria-hidden": "true" }), "ARMING"] })), recordingActive && !armingActive && (_jsxs(RecBadge, { role: "status", "aria-label": "Recording", children: [_jsx(RecDot, { "aria-hidden": "true" }), "REC ", formatElapsed(recordingElapsedMs)] })), showZoom && (_jsxs(ZoomControlsWrap, { "$disabled": manualDisabled, "aria-hidden": manualDisabled, children: [_jsx(ZoomButton, { type: "button", "aria-label": "Zoom in", "$pos": "top", disabled: manualDisabled, onPointerDown: () => controllerRef.current?.setZoomRate(1), onPointerUp: () => controllerRef.current?.setZoomRate(0), onPointerLeave: () => controllerRef.current?.setZoomRate(0), onPointerCancel: () => controllerRef.current?.setZoomRate(0), onClick: (e) => {
712
+ if (e.detail === 0)
713
+ controllerRef.current?.nudgeZoom(-1);
714
+ }, children: "+" }), _jsx(FovSlider, { type: "range", "aria-label": "Zoom", min: camera.fovMin, max: camera.fovMax, step: 0.5, value: sliderFov, disabled: manualDisabled, onChange: (e) => {
715
+ const v = Number(e.target.value);
716
+ controllerRef.current?.fovSliderInput(v);
717
+ }, onPointerDown: () => {
718
+ controllerRef.current?.setFovSliderDragging(true);
719
+ }, onPointerUp: () => {
720
+ controllerRef.current?.setFovSliderDragging(false);
721
+ }, onPointerCancel: () => {
722
+ controllerRef.current?.setFovSliderDragging(false);
723
+ } }), _jsx(ZoomButton, { type: "button", "aria-label": "Zoom out", "$pos": "bottom", disabled: manualDisabled, onPointerDown: () => controllerRef.current?.setZoomRate(-1), onPointerUp: () => controllerRef.current?.setZoomRate(0), onPointerLeave: () => controllerRef.current?.setZoomRate(0), onPointerCancel: () => controllerRef.current?.setZoomRate(0), onClick: (e) => {
724
+ if (e.detail === 0)
725
+ controllerRef.current?.nudgeZoom(1);
726
+ }, children: "\u2212" })] })), showPan && (_jsxs(PanControl, { role: "group", "aria-label": "Pan camera", "$disabled": manualDisabled, "aria-hidden": manualDisabled, children: [_jsx(PanArrow, { type: "button", "$dir": "up", "aria-label": "Pan up", disabled: manualDisabled || !supportsPitch, onClick: () => controllerRef.current?.nudgePan(0, 1), children: "\u25B2" }), _jsx(PanArrow, { type: "button", "$dir": "down", "aria-label": "Pan down", disabled: manualDisabled || !supportsPitch, onClick: () => controllerRef.current?.nudgePan(0, -1), children: "\u25BC" }), _jsx(PanArrow, { type: "button", "$dir": "left", "aria-label": "Pan left", disabled: manualDisabled, onClick: () => controllerRef.current?.nudgePan(-1, 0), children: "\u25C0" }), _jsx(PanArrow, { type: "button", "$dir": "right", "aria-label": "Pan right", disabled: manualDisabled, onClick: () => controllerRef.current?.nudgePan(1, 0), children: "\u25B6" }), _jsx(PanBall, { "aria-hidden": "true", title: "Drag to pan", onPointerDown: manualDisabled ? undefined : handleBallDown, onPointerMove: manualDisabled ? undefined : handleBallMove, onPointerUp: manualDisabled ? undefined : handleBallUp, onPointerCancel: manualDisabled ? undefined : handleBallUp, style: {
727
+ transform: `translate(${ballPos.x}px, ${ballPos.y}px)`,
728
+ } })] }))] })) }) }));
710
729
  });
711
730
  // ---------------------------------------------------------------------------
712
731
  // Public CameraFeed - wraps inner in a nested provider when client prop given
@@ -1207,7 +1226,26 @@ const Panel = styled.div `
1207
1226
  border-radius: 4px;
1208
1227
  overflow: hidden;
1209
1228
  `;
1229
+ /*
1230
+ * Fills the host and centres the Stage at the camera's configured aspect
1231
+ * (--kc-feed-w / --kc-feed-h), so the picture fills the Stage and the host's
1232
+ * spare room sits outside it.
1233
+ */
1234
+ const FeedBox = styled.div `
1235
+ width: 100%;
1236
+ height: 100%;
1237
+ min-width: 0;
1238
+ min-height: 0;
1239
+ container-type: size;
1240
+ display: flex;
1241
+ align-items: center;
1242
+ justify-content: center;
1243
+ `;
1210
1244
  const Stage = styled(Panel) `
1245
+ width: min(100cqw, 100cqh * var(--kc-feed-w) / var(--kc-feed-h));
1246
+ height: auto;
1247
+ aspect-ratio: var(--kc-feed-w) / var(--kc-feed-h);
1248
+ flex: none;
1211
1249
  padding: 0;
1212
1250
  gap: 0;
1213
1251
  position: relative;
@@ -91,11 +91,11 @@ export function KerbalFaceFeed({ flightId, size, reportSize = true, actions, sho
91
91
  // Reporting follows the mount lifecycle; the registry collapses multiple
92
92
  // feeds of the same kerbal to one MAX report.
93
93
  const frameRef = useRef(null);
94
- useReportDisplaySize(flightId, frameRef, { square: true, enabled: reportSize });
94
+ const videoRef = useRef(null);
95
+ useReportDisplaySize(flightId, frameRef, { square: true, enabled: reportSize, videoRef });
95
96
  // Bind the stream to the persistent <video> without remounting it — the
96
97
  // element stays mounted across every re-layout / prop change, only its
97
98
  // srcObject is (re)assigned. A stall (stream -> null) leaves the last frame.
98
- const videoRef = useRef(null);
99
99
  useEffect(() => {
100
100
  if (videoRef.current && stream) {
101
101
  videoRef.current.srcObject = stream;
@@ -1,8 +1,31 @@
1
- /** Report size for a `width` x `height` box: bucketed width, height at the box's aspect. */
2
- export declare function displayReportSize(width: number, height: number): {
1
+ /** Shape of a camera whose configured capture size is not known. */
2
+ export declare const DEFAULT_FEED_ASPECT: number;
3
+ /**
4
+ * Report size for a `width` x `height` box: the `aspect` picture that fits it,
5
+ * width bucketed. Never the box's own shape.
6
+ */
7
+ export declare function displayReportSize(width: number, height: number, aspect?: number): {
3
8
  width: number;
4
9
  height: number;
5
10
  };
11
+ export interface Size {
12
+ width: number;
13
+ height: number;
14
+ }
15
+ /** The stream changing from `from` (null when nothing was known before) to `to`. */
16
+ export interface StreamChange {
17
+ from: Size | null;
18
+ to: Size;
19
+ }
20
+ /**
21
+ * Whether a box going from `prev` to `box` is explained by `change`: the new
22
+ * box has the new stream's aspect, and it grew or shrank no more than the
23
+ * stream's own change in size or aspect accounts for. A layout that fits the
24
+ * stream into a fixed tile changes with the aspect; one that shows the video
25
+ * at its own size changes with the size. A box that jumps further (entering
26
+ * fullscreen at the same aspect) is a layout change.
27
+ */
28
+ export declare function followsStream(prev: Size, box: Size, change: StreamChange): boolean;
6
29
  export interface ReportDisplaySizeOptions {
7
30
  /**
8
31
  * Report while mounted. Default true. Set false for a fixed-resolution feed
@@ -11,10 +34,25 @@ export interface ReportDisplaySizeOptions {
11
34
  */
12
35
  enabled?: boolean;
13
36
  /**
14
- * Report a square (side x side, the min edge) rather than the raw w x h.
15
- * Face cams are square; part cams are not.
37
+ * Report a square (side x side, the min edge) rather than a picture at
38
+ * `aspect`. Face cams are square; part cams are not.
16
39
  */
17
40
  square?: boolean;
41
+ /**
42
+ * Width over height of the picture to ask for, normally the camera's
43
+ * configured capture size. Default 16:9. Ignored when `square` is set.
44
+ */
45
+ aspect?: number;
46
+ /**
47
+ * The `<video>` playing this camera. Its resolution changes are watched so a
48
+ * box resize that only follows the stream is not reported back.
49
+ */
50
+ videoRef?: React.RefObject<HTMLVideoElement | null>;
51
+ /**
52
+ * The size the sidecar says it is rendering this camera at, for the same
53
+ * purpose: a layout may size itself from this rather than from the video.
54
+ */
55
+ streamSize?: Size | null;
18
56
  }
19
57
  /**
20
58
  * Self-measure a feed element and drive the client's per-consumer
@@ -25,5 +63,8 @@ export interface ReportDisplaySizeOptions {
25
63
  * mount lifecycle: on unmount / `flightId` change / `enabled` flip it
26
64
  * deregisters, and the sidecar's clear-on-departure (unsubscribe) does the
27
65
  * server-side clear.
66
+ *
67
+ * A box resize that follows a change in the incoming stream is not reported,
68
+ * so a layout sized from the stream cannot feed it back into its own request.
28
69
  */
29
- export declare function useReportDisplaySize(flightId: number | null, ref: React.RefObject<Element | null>, { enabled, square }?: ReportDisplaySizeOptions): void;
70
+ export declare function useReportDisplaySize(flightId: number | null, ref: React.RefObject<Element | null>, { enabled, square, aspect, videoRef, streamSize, }?: ReportDisplaySizeOptions): void;
@@ -1,28 +1,85 @@
1
- import { useEffect, useId, useLayoutEffect, useState } from "react";
1
+ import { useCallback, useEffect, useId, useLayoutEffect, useRef, useState } from "react";
2
2
  import { useKerbcastDisplaySizes } from "../context";
3
3
  /*
4
4
  * Debounce + bucket for self-measured display-size reporting. The first
5
5
  * measurement after mount reports promptly (so a feed never sits at a stale
6
- * resolution until it is resized); later churn is debounced. The width is
7
- * ceiled to a BUCKET_PX multiple: that meets-the-minimum-need (never
8
- * under-serve) and gives a material-change threshold so a feed re-reports
9
- * only when a resize crosses a bucket boundary. The height follows from the
10
- * box's own aspect, rounded to even for H.264, so the frame fills the box
11
- * instead of letterboxing inside it.
6
+ * resolution until it is resized); later churn is debounced. Auto sets the
7
+ * resolution, never the shape: the request keeps the camera's configured
8
+ * aspect and is sized to the picture of that aspect that fits the box (the
9
+ * feed letterboxes with object-fit: contain). The width is ceiled to a
10
+ * BUCKET_PX multiple: that meets-the-minimum-need (never under-serve) and
11
+ * gives a material-change threshold so a feed re-reports only when a resize
12
+ * crosses a bucket boundary. The height follows at the aspect, rounded to
13
+ * even for H.264.
12
14
  */
13
15
  const DEBOUNCE_MS = 200;
14
16
  const BUCKET_PX = 16;
17
+ /*
18
+ * A resolution change must never cause another one. A box sized from the
19
+ * stream (a frame kept at the video's aspect, a `<video>` at its intrinsic
20
+ * size) would otherwise close a loop: the stream sets the box, the box sets
21
+ * the request, the request sets the stream. A stream that arrives smaller
22
+ * than asked (adaptive quality, a sidecar cap) would then shrink the next
23
+ * request, and so on down.
24
+ *
25
+ * FOLLOW_MS: a box change that starts this soon after the stream changed
26
+ * shape, and matches what that change explains, is the stream's doing. It is
27
+ * adopted as the new baseline and not reported. Layout reacts to a stream
28
+ * change within a frame or two; this leaves room for a slow phone.
29
+ *
30
+ * SUSPECT_MS / HOLD_MS: the backstop for a follow the check above misses (a
31
+ * very late layout, or one that does not keep the stream's shape). A box
32
+ * change this close to a stream change may still be a loop, so only one such
33
+ * report is let through per HOLD_MS; further ones are refused. Box changes
34
+ * away from any stream change always report.
35
+ */
36
+ const FOLLOW_MS = 1000;
37
+ const SUSPECT_MS = 2500;
38
+ const HOLD_MS = 5000;
39
+ const ASPECT_TOLERANCE = 0.06;
40
+ const MAGNITUDE_SLACK = 1.15;
15
41
  function bucket(px) {
16
42
  const b = Math.ceil(px / BUCKET_PX) * BUCKET_PX;
17
43
  return b < BUCKET_PX ? BUCKET_PX : b;
18
44
  }
19
- /** Report size for a `width` x `height` box: bucketed width, height at the box's aspect. */
20
- export function displayReportSize(width, height) {
21
- const w = bucket(width);
22
- if (!(width > 0) || !(height > 0))
23
- return { width: w, height: bucket(height) };
24
- const h = Math.max(2, Math.round((w * height) / width / 2) * 2);
25
- return { width: w, height: h };
45
+ /** Shape of a camera whose configured capture size is not known. */
46
+ export const DEFAULT_FEED_ASPECT = 16 / 9;
47
+ /**
48
+ * Report size for a `width` x `height` box: the `aspect` picture that fits it,
49
+ * width bucketed. Never the box's own shape.
50
+ */
51
+ export function displayReportSize(width, height, aspect = DEFAULT_FEED_ASPECT) {
52
+ const a = aspect > 0 && Number.isFinite(aspect) ? aspect : DEFAULT_FEED_ASPECT;
53
+ const fit = width > 0 && height > 0 ? Math.min(width, height * a) : width;
54
+ const w = bucket(fit);
55
+ return { width: w, height: Math.max(2, Math.round(w / a / 2) * 2) };
56
+ }
57
+ function spread(a, b) {
58
+ return a > b ? a / b : b / a;
59
+ }
60
+ /**
61
+ * Whether a box going from `prev` to `box` is explained by `change`: the new
62
+ * box has the new stream's aspect, and it grew or shrank no more than the
63
+ * stream's own change in size or aspect accounts for. A layout that fits the
64
+ * stream into a fixed tile changes with the aspect; one that shows the video
65
+ * at its own size changes with the size. A box that jumps further (entering
66
+ * fullscreen at the same aspect) is a layout change.
67
+ */
68
+ export function followsStream(prev, box, change) {
69
+ const { to, from } = change;
70
+ if (!(to.width > 0) || !(to.height > 0))
71
+ return false;
72
+ if (!(prev.width > 0) || !(prev.height > 0))
73
+ return false;
74
+ const aspect = to.width / to.height;
75
+ const heightFits = Math.abs(box.height - box.width / aspect) <= Math.max(2, ASPECT_TOLERANCE * box.height);
76
+ const widthFits = Math.abs(box.width - box.height * aspect) <= Math.max(2, ASPECT_TOLERANCE * box.width);
77
+ if (!heightFits && !widthFits)
78
+ return false;
79
+ if (!from)
80
+ return true;
81
+ const explained = Math.max(spread(to.width * to.height, from.width * from.height), spread(aspect, from.width / from.height));
82
+ return spread(box.width * box.height, prev.width * prev.height) <= explained * MAGNITUDE_SLACK;
26
83
  }
27
84
  /**
28
85
  * Self-measure a feed element and drive the client's per-consumer
@@ -33,21 +90,81 @@ export function displayReportSize(width, height) {
33
90
  * mount lifecycle: on unmount / `flightId` change / `enabled` flip it
34
91
  * deregisters, and the sidecar's clear-on-departure (unsubscribe) does the
35
92
  * server-side clear.
93
+ *
94
+ * A box resize that follows a change in the incoming stream is not reported,
95
+ * so a layout sized from the stream cannot feed it back into its own request.
36
96
  */
37
- export function useReportDisplaySize(flightId, ref, { enabled = true, square = false } = {}) {
97
+ export function useReportDisplaySize(flightId, ref, { enabled = true, square = false, aspect = DEFAULT_FEED_ASPECT, videoRef, streamSize, } = {}) {
38
98
  const displaySizes = useKerbcastDisplaySizes();
39
99
  const instanceId = useId();
40
100
  /*
41
- * Track the element behind the ref as state so an element that mounts or is
42
- * swapped after the first render (conditional render, a feed that appears
43
- * once a stream arrives) is observed. Checked after every commit of the
44
- * host component; the state only changes when the element does.
101
+ * Track the elements behind the refs as state so an element that mounts or
102
+ * is swapped after the first render (conditional render, a feed that appears
103
+ * once a stream arrives) is observed. Checked after every commit of the host
104
+ * component; the state only changes when the element does.
45
105
  */
46
106
  const [el, setEl] = useState(null);
107
+ const [video, setVideo] = useState(null);
47
108
  useLayoutEffect(() => {
48
109
  if (ref.current !== el)
49
110
  setEl(ref.current);
111
+ const v = videoRef?.current ?? null;
112
+ if (v !== video)
113
+ setVideo(v);
50
114
  });
115
+ const changes = useRef([]);
116
+ const lastStream = useRef(null);
117
+ const lastBySource = useRef({ video: null, render: null });
118
+ /*
119
+ * Each source is compared with its own last value, so the video and the
120
+ * sidecar's render size reporting the same new shape at different moments
121
+ * are both noted. The first render size is only a baseline: a layout built
122
+ * from it was already in place when the first measurement went out.
123
+ */
124
+ const noteStream = useCallback((source, width, height) => {
125
+ if (!(width > 0) || !(height > 0))
126
+ return;
127
+ const seen = lastBySource.current[source];
128
+ if (seen && seen.width === width && seen.height === height)
129
+ return;
130
+ const to = { width, height };
131
+ lastBySource.current[source] = to;
132
+ const from = lastStream.current;
133
+ lastStream.current = to;
134
+ if (!from && source === "render")
135
+ return;
136
+ const now = Date.now();
137
+ changes.current = changes.current.filter((c) => now - c.at <= SUSPECT_MS + DEBOUNCE_MS);
138
+ changes.current.push({ at: now, from, to });
139
+ }, []);
140
+ useEffect(() => {
141
+ if (!video)
142
+ return;
143
+ const sync = () => noteStream("video", video.videoWidth, video.videoHeight);
144
+ sync();
145
+ video.addEventListener("loadedmetadata", sync);
146
+ video.addEventListener("resize", sync);
147
+ return () => {
148
+ video.removeEventListener("loadedmetadata", sync);
149
+ video.removeEventListener("resize", sync);
150
+ };
151
+ }, [video, noteStream]);
152
+ /* A layout effect, so it is noted before the observer sees the box this commit lays out. */
153
+ const streamW = streamSize?.width ?? 0;
154
+ const streamH = streamSize?.height ?? 0;
155
+ useLayoutEffect(() => {
156
+ noteStream("render", streamW, streamH);
157
+ }, [streamW, streamH, noteStream]);
158
+ /*
159
+ * The aspect is read through a ref so the camera's configured size arriving
160
+ * after mount re-sizes the current request without a clear and re-register.
161
+ */
162
+ const aspectRef = useRef(aspect);
163
+ const reapply = useRef(null);
164
+ useEffect(() => {
165
+ aspectRef.current = aspect;
166
+ reapply.current?.();
167
+ }, [aspect]);
51
168
  useEffect(() => {
52
169
  if (flightId === null || !enabled)
53
170
  return;
@@ -57,33 +174,71 @@ export function useReportDisplaySize(flightId, ref, { enabled = true, square = f
57
174
  let lastW = -1;
58
175
  let lastH = -1;
59
176
  let firstReported = false;
60
- const report = (width, height) => {
61
- const side = bucket(Math.min(width, height));
62
- const { width: bw, height: bh } = square
63
- ? { width: side, height: side }
64
- : displayReportSize(width, height);
65
- if (bw === lastW && bh === lastH)
177
+ let baseline = null;
178
+ let lastSuspectAt = Number.NEGATIVE_INFINITY;
179
+ const sizeFor = (box) => {
180
+ if (!square)
181
+ return displayReportSize(box.width, box.height, aspectRef.current);
182
+ const side = bucket(Math.min(box.width, box.height));
183
+ return { width: side, height: side };
184
+ };
185
+ const send = ({ width, height }) => {
186
+ lastW = width;
187
+ lastH = height;
188
+ displaySizes.report(flightId, instanceId, width, height);
189
+ };
190
+ /*
191
+ * Judged when the debounce settles, so a stream change noted a little after
192
+ * the observer fired (a host that commits the stream and the box in a
193
+ * different order) still counts.
194
+ */
195
+ const settle = (box, seenAt) => {
196
+ const prev = baseline;
197
+ baseline = box;
198
+ const now = Date.now();
199
+ const near = changes.current.filter((c) => c.at <= now && c.at >= seenAt - SUSPECT_MS);
200
+ if (prev && near.some((c) => c.at >= seenAt - FOLLOW_MS && followsStream(prev, box, c))) {
201
+ return;
202
+ }
203
+ const next = sizeFor(box);
204
+ if (next.width === lastW && next.height === lastH)
66
205
  return;
67
- lastW = bw;
68
- lastH = bh;
69
- displaySizes.report(flightId, instanceId, bw, bh);
206
+ if (near.length > 0) {
207
+ const held = now - lastSuspectAt < HOLD_MS;
208
+ lastSuspectAt = now;
209
+ if (held)
210
+ return;
211
+ }
212
+ send(next);
70
213
  };
71
214
  const observer = new ResizeObserver((entries) => {
72
215
  const entry = entries[0];
73
216
  if (!entry)
74
217
  return;
75
- const { width, height } = entry.contentRect;
218
+ const box = { width: entry.contentRect.width, height: entry.contentRect.height };
76
219
  if (!firstReported) {
77
220
  firstReported = true;
78
- report(width, height);
221
+ baseline = box;
222
+ const next = sizeFor(box);
223
+ if (next.width !== lastW || next.height !== lastH)
224
+ send(next);
79
225
  return;
80
226
  }
227
+ const seenAt = Date.now();
81
228
  if (timer !== null)
82
229
  clearTimeout(timer);
83
- timer = setTimeout(() => report(width, height), DEBOUNCE_MS);
230
+ timer = setTimeout(() => settle(box, seenAt), DEBOUNCE_MS);
84
231
  });
85
232
  observer.observe(el);
233
+ reapply.current = () => {
234
+ if (!baseline)
235
+ return;
236
+ const next = sizeFor(baseline);
237
+ if (next.width !== lastW || next.height !== lastH)
238
+ send(next);
239
+ };
86
240
  return () => {
241
+ reapply.current = null;
87
242
  observer.disconnect();
88
243
  if (timer !== null)
89
244
  clearTimeout(timer);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ksp-gonogo/kerbcast-react",
3
- "version": "1.11.0-rc.4",
3
+ "version": "1.11.0",
4
4
  "description": "React components and hooks for kerbcast camera feeds.",
5
5
  "license": "MIT",
6
6
  "author": "Jon Pepler",
@@ -45,7 +45,7 @@
45
45
  "access": "public"
46
46
  },
47
47
  "dependencies": {
48
- "@ksp-gonogo/kerbcast": "^1.11.0-rc.4"
48
+ "@ksp-gonogo/kerbcast": "^1.11.0"
49
49
  },
50
50
  "peerDependencies": {
51
51
  "react": "^18 || ^19",