@ksp-gonogo/kerbcast-react 1.11.0 → 1.12.0-rc.1
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 +59 -0
- package/dist/AnchorMarkers.d.ts +128 -0
- package/dist/AnchorMarkers.js +162 -0
- package/dist/CameraFeed.d.ts +45 -4
- package/dist/CameraFeed.js +78 -10
- package/dist/CommandBrackets.d.ts +61 -12
- package/dist/CommandBrackets.js +106 -53
- package/dist/KerbalFaceFeed.d.ts +1 -1
- package/dist/KerbalFaceFeed.js +1 -1
- package/dist/RecordingsTray.d.ts +6 -0
- package/dist/RecordingsTray.js +17 -1
- package/dist/hooks/useCommandDisplayUt.d.ts +16 -3
- package/dist/hooks/useCommandDisplayUt.js +57 -16
- package/dist/hooks/useKerbcastWaitingForSlot.d.ts +5 -0
- package/dist/hooks/useKerbcastWaitingForSlot.js +20 -0
- package/dist/hooks/useTargetBracketsPreference.d.ts +3 -2
- package/dist/hooks/useTargetBracketsPreference.js +18 -5
- package/dist/index.d.ts +4 -1
- package/dist/index.js +5 -1
- package/dist/sharedAnchors.d.ts +3 -0
- package/dist/sharedAnchors.js +61 -0
- package/dist/videoBox.d.ts +10 -0
- package/dist/videoBox.js +40 -0
- package/package.json +2 -2
- package/src/AnchorMarkers.test.tsx +173 -0
- package/src/AnchorMarkers.tsx +341 -0
- package/src/CameraFeed.test.tsx +223 -6
- package/src/CameraFeed.tsx +145 -14
- package/src/CommandBrackets.test.tsx +204 -4
- package/src/CommandBrackets.tsx +170 -53
- package/src/KerbalFaceFeed.tsx +1 -1
- package/src/RecordingsTray.test.tsx +15 -0
- package/src/RecordingsTray.tsx +25 -1
- package/src/hooks/useCommandDisplayUt.test.tsx +74 -3
- package/src/hooks/useCommandDisplayUt.ts +61 -15
- package/src/hooks/useKerbcastWaitingForSlot.ts +25 -0
- package/src/hooks/useRecordings.sharedState.test.tsx +6 -3
- package/src/hooks/useRecordings.test.tsx +6 -3
- package/src/hooks/useTargetBracketsPreference.ts +15 -5
- package/src/index.ts +23 -1
- package/src/sharedAnchors.ts +67 -0
- package/src/videoBox.ts +49 -0
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type EffectiveCommandTrack, type FrameUtSource } from "@ksp-gonogo/kerbcast";
|
|
1
|
+
import { type EffectiveCommandTrack, type FrameAnchor, type FrameUtSource } from "@ksp-gonogo/kerbcast";
|
|
2
2
|
import { type ReactNode } from "react";
|
|
3
3
|
/** Default colours; each is overridable through the named CSS custom property. */
|
|
4
4
|
export declare const COMMAND_BRACKET_COLOURS: {
|
|
@@ -22,7 +22,10 @@ export interface BracketPoint {
|
|
|
22
22
|
}
|
|
23
23
|
/** The bracket box, in the pixel space of the bracket SVG (the video element's box). */
|
|
24
24
|
export interface BracketGeometry {
|
|
25
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* Box centre: where the subject is in the displayed frame, or the frame
|
|
27
|
+
* centre (the aim point) while that is not known.
|
|
28
|
+
*/
|
|
26
29
|
cx: number;
|
|
27
30
|
cy: number;
|
|
28
31
|
/** Half the box side in display px. */
|
|
@@ -35,11 +38,12 @@ export interface BracketGeometry {
|
|
|
35
38
|
bottomRight: BracketPoint;
|
|
36
39
|
}
|
|
37
40
|
/**
|
|
38
|
-
* The bracket box centred on the displayed frame
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
+
* The bracket box, centred on the displayed frame or on `centre` (in the
|
|
42
|
+
* same pixel space as `rect`). `bracketHalfPx` is in capture px of a
|
|
43
|
+
* `frameHeightPx`-tall frame, so it scales by the displayed height; a
|
|
44
|
+
* missing half-size draws the minimum box.
|
|
41
45
|
*/
|
|
42
|
-
export declare function bracketGeometry(rect: ContentRect, bracketHalfPx: number | null, frameHeightPx: number): BracketGeometry;
|
|
46
|
+
export declare function bracketGeometry(rect: ContentRect, bracketHalfPx: number | null, frameHeightPx: number, centre?: BracketPoint | null): BracketGeometry;
|
|
43
47
|
export type CommandBracketCorner = "topLeft" | "bottomRight";
|
|
44
48
|
export interface CommandBracketCornerStyle {
|
|
45
49
|
colour: string;
|
|
@@ -62,6 +66,19 @@ export interface CommandBracketContext {
|
|
|
62
66
|
displayUt: number;
|
|
63
67
|
utSource: FrameUtSource | "override" | null;
|
|
64
68
|
state: EffectiveCommandTrack;
|
|
69
|
+
/**
|
|
70
|
+
* The subject's anchor in the displayed frame: where the game projected
|
|
71
|
+
* the craft, whether it is in frame and whether something hides it.
|
|
72
|
+
* `null` without one (an older sidecar or plugin, or before the first
|
|
73
|
+
* stamped frame), when the box is centred on the frame instead.
|
|
74
|
+
*/
|
|
75
|
+
subject?: FrameAnchor | null;
|
|
76
|
+
/**
|
|
77
|
+
* True while the aim lies off the frame (the view is still turning onto
|
|
78
|
+
* it): the box then sits on the frame's edge nearest the aim, with a
|
|
79
|
+
* chevron pointing on toward it.
|
|
80
|
+
*/
|
|
81
|
+
aimOffscreen?: boolean;
|
|
65
82
|
}
|
|
66
83
|
/**
|
|
67
84
|
* Consumer additions to a command feed's brackets. Each part is optional and
|
|
@@ -95,15 +112,47 @@ export interface CommandBracketsProps {
|
|
|
95
112
|
height: number;
|
|
96
113
|
} | null;
|
|
97
114
|
overlay?: CommandBracketOverlay | null;
|
|
115
|
+
/**
|
|
116
|
+
* The subject's anchor in the displayed frame. While it is visible and no
|
|
117
|
+
* command lock is held the box sits on it and takes its size from it.
|
|
118
|
+
*/
|
|
119
|
+
subject?: FrameAnchor | null;
|
|
120
|
+
/** The capture frame size `subject` is in (the stamp's width and height). */
|
|
121
|
+
subjectFrameSize?: {
|
|
122
|
+
width: number;
|
|
123
|
+
height: number;
|
|
124
|
+
} | null;
|
|
125
|
+
/**
|
|
126
|
+
* The aim anchor in the displayed frame. The aimed box sits on it rather
|
|
127
|
+
* than on the frame centre, so it stays on the aim while the view turns
|
|
128
|
+
* onto a moved one.
|
|
129
|
+
*/
|
|
130
|
+
aim?: FrameAnchor | null;
|
|
98
131
|
}
|
|
99
132
|
/**
|
|
100
|
-
* Target brackets for a command camera feed
|
|
101
|
-
*
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
*
|
|
133
|
+
* Target brackets for a command camera feed. With no command lock held and
|
|
134
|
+
* the subject's anchor visible in the displayed frame, the box sits on the
|
|
135
|
+
* craft where the game saw it, so it stays on the craft when an aim points
|
|
136
|
+
* elsewhere; while the aimed box is in frame and holds the craft it is drawn
|
|
137
|
+
* instead, since it frames the craft's whole extent as the view does. Otherwise (the anchor out of frame or occluded, which then
|
|
138
|
+
* carries no position, no anchor at all, or a command authority holding the
|
|
139
|
+
* lock) the box is the 1.11 one: on the aim (the frame centre, or the aim
|
|
140
|
+
* anchor while the view turns onto a moved aim), sized from the aimed range,
|
|
141
|
+
* red and dashed when the aim line is occluded. `ctx.subject` always
|
|
142
|
+
* carries the anchor, so a consumer can draw it as a second mark. An overlay
|
|
143
|
+
* can restyle either corner and draw its own marks. Without a fix, or over a
|
|
144
|
+
* blanked feed, the default corners are left out but the overlay and readout
|
|
145
|
+
* still run.
|
|
146
|
+
*/
|
|
147
|
+
export declare function CommandBrackets({ state, displayUt, utSource, video, fallbackSize, overlay, subject, subjectFrameSize, aim, }: CommandBracketsProps): import("react").JSX.Element;
|
|
148
|
+
/**
|
|
149
|
+
* CommandBrackets fed with the subject's and the aim's anchors from each frame of
|
|
150
|
+
* `flightId` that `video` presents. Re-renders at the frame rate, which is
|
|
151
|
+
* why it is its own component.
|
|
105
152
|
*/
|
|
106
|
-
export declare function
|
|
153
|
+
export declare function LiveCommandBrackets({ flightId, ...props }: CommandBracketsProps & {
|
|
154
|
+
flightId: number | null;
|
|
155
|
+
}): import("react").JSX.Element;
|
|
107
156
|
/** Reason a command feed is blanked, centred on black over the feed. */
|
|
108
157
|
export declare function CommandBlank({ reason }: {
|
|
109
158
|
reason: string;
|
package/dist/CommandBrackets.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
-
import { commandReadout, } from "@ksp-gonogo/kerbcast";
|
|
3
|
-
import { useEffect, useState } from "react";
|
|
2
|
+
import { COMMAND_AIM_ANCHOR_ID, COMMAND_SUBJECT_ANCHOR_ID, KERBCAST_ANCHOR_OWNER, anchorPoint, commandReadout, findAnchor, frameToScreen, } from "@ksp-gonogo/kerbcast";
|
|
4
3
|
import styled from "styled-components";
|
|
4
|
+
import { useDisplayedFrame } from "./AnchorMarkers";
|
|
5
|
+
import { useVideoBox } from "./videoBox";
|
|
5
6
|
const BRACKET_STROKE_PX = 2;
|
|
6
7
|
const BRACKET_MIN_HALF_PX = 14;
|
|
7
8
|
const BRACKET_ARM_FRACTION = 0.3;
|
|
@@ -26,16 +27,17 @@ export function containedContentRect(boxWidth, boxHeight, mediaWidth, mediaHeigh
|
|
|
26
27
|
return { x: (boxWidth - width) / 2, y: (boxHeight - height) / 2, width, height };
|
|
27
28
|
}
|
|
28
29
|
/**
|
|
29
|
-
* The bracket box centred on the displayed frame
|
|
30
|
-
*
|
|
31
|
-
*
|
|
30
|
+
* The bracket box, centred on the displayed frame or on `centre` (in the
|
|
31
|
+
* same pixel space as `rect`). `bracketHalfPx` is in capture px of a
|
|
32
|
+
* `frameHeightPx`-tall frame, so it scales by the displayed height; a
|
|
33
|
+
* missing half-size draws the minimum box.
|
|
32
34
|
*/
|
|
33
|
-
export function bracketGeometry(rect, bracketHalfPx, frameHeightPx) {
|
|
35
|
+
export function bracketGeometry(rect, bracketHalfPx, frameHeightPx, centre) {
|
|
34
36
|
const scale = frameHeightPx > 0 ? rect.height / frameHeightPx : 1;
|
|
35
37
|
const half = Math.max(BRACKET_MIN_HALF_PX, (bracketHalfPx ?? 0) * scale);
|
|
36
38
|
const arm = Math.max(BRACKET_MIN_ARM_PX, BRACKET_ARM_FRACTION * half * 2);
|
|
37
|
-
const cx = rect.x + rect.width / 2;
|
|
38
|
-
const cy = rect.y + rect.height / 2;
|
|
39
|
+
const cx = centre ? centre.x : rect.x + rect.width / 2;
|
|
40
|
+
const cy = centre ? centre.y : rect.y + rect.height / 2;
|
|
39
41
|
return {
|
|
40
42
|
cx,
|
|
41
43
|
cy,
|
|
@@ -46,8 +48,41 @@ export function bracketGeometry(rect, bracketHalfPx, frameHeightPx) {
|
|
|
46
48
|
bottomRight: { x: cx + half, y: cy + half },
|
|
47
49
|
};
|
|
48
50
|
}
|
|
49
|
-
|
|
50
|
-
|
|
51
|
+
/** Whether `p` lies inside the box `g`. */
|
|
52
|
+
function holds(g, p) {
|
|
53
|
+
return p.x >= g.topLeft.x && p.x <= g.bottomRight.x && p.y >= g.topLeft.y && p.y <= g.bottomRight.y;
|
|
54
|
+
}
|
|
55
|
+
/** `g` moved so the whole box lies inside `rect`. */
|
|
56
|
+
function clampToRect(g, rect) {
|
|
57
|
+
const clamp = (v, lo, hi) => (lo > hi ? (lo + hi) / 2 : Math.min(hi, Math.max(lo, v)));
|
|
58
|
+
const cx = clamp(g.cx, rect.x + g.half, rect.x + rect.width - g.half);
|
|
59
|
+
const cy = clamp(g.cy, rect.y + g.half, rect.y + rect.height - g.half);
|
|
60
|
+
return {
|
|
61
|
+
...g,
|
|
62
|
+
cx,
|
|
63
|
+
cy,
|
|
64
|
+
topLeft: { x: cx - g.half, y: cy - g.half },
|
|
65
|
+
bottomRight: { x: cx + g.half, y: cy + g.half },
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
/** A chevron just outside the box, pointing from its centre toward `toward`. */
|
|
69
|
+
function edgeCue(g, toward) {
|
|
70
|
+
const dx = toward.x - g.cx;
|
|
71
|
+
const dy = toward.y - g.cy;
|
|
72
|
+
const len = Math.hypot(dx, dy);
|
|
73
|
+
if (!(len > 0))
|
|
74
|
+
return null;
|
|
75
|
+
const ux = dx / len;
|
|
76
|
+
const uy = dy / len;
|
|
77
|
+
const tipX = g.cx + ux * (g.half + g.arm);
|
|
78
|
+
const tipY = g.cy + uy * (g.half + g.arm);
|
|
79
|
+
const backX = tipX - ux * g.arm;
|
|
80
|
+
const backY = tipY - uy * g.arm;
|
|
81
|
+
const w = g.arm * 0.6;
|
|
82
|
+
return `M${backX - uy * w} ${backY + ux * w}L${tipX} ${tipY}L${backX + uy * w} ${backY - ux * w}`;
|
|
83
|
+
}
|
|
84
|
+
function cameraCornerStyle(occluded) {
|
|
85
|
+
return occluded
|
|
51
86
|
? { colour: COMMAND_BRACKET_COLOURS.occluded, dashed: true }
|
|
52
87
|
: { colour: COMMAND_BRACKET_COLOURS.clear, dashed: false };
|
|
53
88
|
}
|
|
@@ -58,50 +93,51 @@ function applyCorner(base, override) {
|
|
|
58
93
|
hidden: override?.hidden ?? false,
|
|
59
94
|
};
|
|
60
95
|
}
|
|
61
|
-
/** Tracks the rendered box and intrinsic size of a video element. */
|
|
62
|
-
function useVideoBox(video) {
|
|
63
|
-
const [box, setBox] = useState({ boxW: 0, boxH: 0, mediaW: 0, mediaH: 0 });
|
|
64
|
-
useEffect(() => {
|
|
65
|
-
if (!video)
|
|
66
|
-
return;
|
|
67
|
-
const measure = () => {
|
|
68
|
-
const next = {
|
|
69
|
-
boxW: video.clientWidth,
|
|
70
|
-
boxH: video.clientHeight,
|
|
71
|
-
mediaW: video.videoWidth,
|
|
72
|
-
mediaH: video.videoHeight,
|
|
73
|
-
};
|
|
74
|
-
setBox((prev) => prev.boxW === next.boxW &&
|
|
75
|
-
prev.boxH === next.boxH &&
|
|
76
|
-
prev.mediaW === next.mediaW &&
|
|
77
|
-
prev.mediaH === next.mediaH
|
|
78
|
-
? prev
|
|
79
|
-
: next);
|
|
80
|
-
};
|
|
81
|
-
measure();
|
|
82
|
-
const observer = new ResizeObserver(measure);
|
|
83
|
-
observer.observe(video);
|
|
84
|
-
video.addEventListener("loadedmetadata", measure);
|
|
85
|
-
video.addEventListener("resize", measure);
|
|
86
|
-
return () => {
|
|
87
|
-
observer.disconnect();
|
|
88
|
-
video.removeEventListener("loadedmetadata", measure);
|
|
89
|
-
video.removeEventListener("resize", measure);
|
|
90
|
-
};
|
|
91
|
-
}, [video]);
|
|
92
|
-
return box;
|
|
93
|
-
}
|
|
94
96
|
/**
|
|
95
|
-
* Target brackets for a command camera feed
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
97
|
+
* Target brackets for a command camera feed. With no command lock held and
|
|
98
|
+
* the subject's anchor visible in the displayed frame, the box sits on the
|
|
99
|
+
* craft where the game saw it, so it stays on the craft when an aim points
|
|
100
|
+
* elsewhere; while the aimed box is in frame and holds the craft it is drawn
|
|
101
|
+
* instead, since it frames the craft's whole extent as the view does. Otherwise (the anchor out of frame or occluded, which then
|
|
102
|
+
* carries no position, no anchor at all, or a command authority holding the
|
|
103
|
+
* lock) the box is the 1.11 one: on the aim (the frame centre, or the aim
|
|
104
|
+
* anchor while the view turns onto a moved aim), sized from the aimed range,
|
|
105
|
+
* red and dashed when the aim line is occluded. `ctx.subject` always
|
|
106
|
+
* carries the anchor, so a consumer can draw it as a second mark. An overlay
|
|
107
|
+
* can restyle either corner and draw its own marks. Without a fix, or over a
|
|
108
|
+
* blanked feed, the default corners are left out but the overlay and readout
|
|
109
|
+
* still run.
|
|
100
110
|
*/
|
|
101
|
-
export function CommandBrackets({ state, displayUt, utSource, video, fallbackSize, overlay, }) {
|
|
111
|
+
export function CommandBrackets({ state, displayUt, utSource, video, fallbackSize, overlay, subject, subjectFrameSize, aim, }) {
|
|
102
112
|
const { boxW, boxH, mediaW, mediaH } = useVideoBox(video);
|
|
103
113
|
const rect = containedContentRect(boxW, boxH, mediaW || fallbackSize?.width || 0, mediaH || fallbackSize?.height || 0);
|
|
104
|
-
const
|
|
114
|
+
const frameW = subjectFrameSize?.width || mediaW || fallbackSize?.width || 0;
|
|
115
|
+
const frameH = subjectFrameSize?.height || mediaH || fallbackSize?.height || 0;
|
|
116
|
+
/* Under the lock the box is the authority's aim, as in 1.11. */
|
|
117
|
+
const point = subject && !state.authority && frameW > 0 && frameH > 0 ? anchorPoint(subject) : null;
|
|
118
|
+
const placed = point !== null;
|
|
119
|
+
const aimPoint = aim && frameW > 0 && frameH > 0 ? anchorPoint(aim) : null;
|
|
120
|
+
const aimScreen = aimPoint ? frameToScreen(aimPoint, frameW, frameH, { ...rect, scale: 1 }) : null;
|
|
121
|
+
const aimOffscreen = !point && !!aimScreen && (!aim?.inFrame || !!aim?.behind);
|
|
122
|
+
const aimed = bracketGeometry(rect, state.bracketHalfPx, state.frameHeightPx || mediaH, aimScreen);
|
|
123
|
+
const subjectScreen = point ? frameToScreen(point, frameW, frameH, { ...rect, scale: 1 }) : null;
|
|
124
|
+
/*
|
|
125
|
+
* The aimed box, when it is in frame and holds the subject, frames the
|
|
126
|
+
* craft as the view does: around a craft and its open parachute rather
|
|
127
|
+
* than a sphere about its centre of mass.
|
|
128
|
+
*/
|
|
129
|
+
const framed = !!subjectScreen &&
|
|
130
|
+
!!aimScreen &&
|
|
131
|
+
!!aim?.inFrame &&
|
|
132
|
+
!aim?.behind &&
|
|
133
|
+
state.bracketHalfPx != null &&
|
|
134
|
+
holds(aimed, subjectScreen);
|
|
135
|
+
const unclamped = subjectScreen && !framed
|
|
136
|
+
? bracketGeometry(rect, subject?.apparentRadiusPx ?? null, frameH, subjectScreen)
|
|
137
|
+
: aimed;
|
|
138
|
+
/* An aim off the frame (mid-slew) keeps the box on the edge nearest it, with a cue pointing on. */
|
|
139
|
+
const g = aimOffscreen ? clampToRect(unclamped, rect) : unclamped;
|
|
140
|
+
const cue = aimOffscreen && aimScreen ? edgeCue(g, aimScreen) : null;
|
|
105
141
|
const ctx = {
|
|
106
142
|
geometry: g,
|
|
107
143
|
width: boxW,
|
|
@@ -109,18 +145,35 @@ export function CommandBrackets({ state, displayUt, utSource, video, fallbackSiz
|
|
|
109
145
|
displayUt,
|
|
110
146
|
utSource: utSource ?? null,
|
|
111
147
|
state,
|
|
148
|
+
subject: subject ?? null,
|
|
149
|
+
aimOffscreen,
|
|
112
150
|
};
|
|
113
151
|
const overrides = typeof overlay?.corners === "function" ? overlay.corners(ctx) : overlay?.corners;
|
|
114
|
-
const camera = cameraCornerStyle(state);
|
|
152
|
+
const camera = cameraCornerStyle(placed ? !!subject?.occluded : state.occluded);
|
|
115
153
|
const topLeft = applyCorner(camera, overrides?.topLeft);
|
|
116
154
|
const bottomRight = applyCorner(camera, overrides?.bottomRight);
|
|
117
155
|
const { x: left, y: top } = g.topLeft;
|
|
118
156
|
const { x: right, y: bottom } = g.bottomRight;
|
|
119
157
|
const blanked = !!state.blank;
|
|
120
158
|
const drawCorners = state.hasFix && !blanked;
|
|
121
|
-
|
|
159
|
+
/* A feed waiting for its target says so itself; NO FIX would only repeat it. */
|
|
160
|
+
const defaultReadout = blanked || state.waitingForTarget ? "" : commandReadout(state);
|
|
122
161
|
const readout = overlay?.readout ? overlay.readout(defaultReadout, ctx) : defaultReadout;
|
|
123
|
-
return (_jsxs(_Fragment, { children: [(drawCorners || overlay?.render) && (_jsxs(BracketSvg, { "aria-hidden": "true", "data-camera-axis": state.camera, width: boxW, height: boxH, children: [drawCorners && !topLeft.hidden && (_jsx("path", { "data-corner": "topLeft", d: `M${left} ${top + g.arm}L${left} ${top}L${left + g.arm} ${top}`, fill: "none", strokeWidth: g.stroke, strokeDasharray: topLeft.dashed ? "3 2" : undefined, style: { stroke: topLeft.colour } })), drawCorners && !bottomRight.hidden && (_jsx("path", { "data-corner": "bottomRight", d: `M${right} ${bottom - g.arm}L${right} ${bottom}L${right - g.arm} ${bottom}`, fill: "none", strokeWidth: g.stroke, strokeDasharray: bottomRight.dashed ? "3 2" : undefined, style: { stroke: bottomRight.colour } })), overlay?.render && _jsx("g", { "data-bracket-overlay": "", children: overlay.render(ctx) })] })), readout && (_jsx(Readout, { "data-ut-source": utSource ?? undefined, style: { left: g.cx, top: bottom + READOUT_GAP_PX }, children: readout }))] }));
|
|
162
|
+
return (_jsxs(_Fragment, { children: [(drawCorners || overlay?.render) && (_jsxs(BracketSvg, { "aria-hidden": "true", "data-camera-axis": state.camera, "data-subject": subject ? (placed ? "placed" : "centred") : undefined, width: boxW, height: boxH, children: [drawCorners && !topLeft.hidden && (_jsx("path", { "data-corner": "topLeft", d: `M${left} ${top + g.arm}L${left} ${top}L${left + g.arm} ${top}`, fill: "none", strokeWidth: g.stroke, strokeDasharray: topLeft.dashed ? "3 2" : undefined, style: { stroke: topLeft.colour } })), drawCorners && !bottomRight.hidden && (_jsx("path", { "data-corner": "bottomRight", d: `M${right} ${bottom - g.arm}L${right} ${bottom}L${right - g.arm} ${bottom}`, fill: "none", strokeWidth: g.stroke, strokeDasharray: bottomRight.dashed ? "3 2" : undefined, style: { stroke: bottomRight.colour } })), drawCorners && cue && (_jsx("path", { "data-aim-cue": "", d: cue, fill: "none", strokeWidth: g.stroke, style: { stroke: camera.colour } })), overlay?.render && _jsx("g", { "data-bracket-overlay": "", children: overlay.render(ctx) })] })), readout && (_jsx(Readout, { "data-ut-source": utSource ?? undefined, style: { left: g.cx, top: bottom + READOUT_GAP_PX }, children: readout }))] }));
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* CommandBrackets fed with the subject's and the aim's anchors from each frame of
|
|
166
|
+
* `flightId` that `video` presents. Re-renders at the frame rate, which is
|
|
167
|
+
* why it is its own component.
|
|
168
|
+
*/
|
|
169
|
+
export function LiveCommandBrackets({ flightId, ...props }) {
|
|
170
|
+
const frame = useDisplayedFrame(true, flightId, props.video);
|
|
171
|
+
const subject = findAnchor(frame?.anchors, COMMAND_SUBJECT_ANCHOR_ID, KERBCAST_ANCHOR_OWNER);
|
|
172
|
+
const aim = findAnchor(frame?.anchors, COMMAND_AIM_ANCHOR_ID, KERBCAST_ANCHOR_OWNER);
|
|
173
|
+
const size = frame?.frameWidth && frame.frameHeight
|
|
174
|
+
? { width: frame.frameWidth, height: frame.frameHeight }
|
|
175
|
+
: null;
|
|
176
|
+
return _jsx(CommandBrackets, { ...props, subject: subject, subjectFrameSize: size, aim: aim });
|
|
124
177
|
}
|
|
125
178
|
/** Reason a command feed is blanked, centred on black over the feed. */
|
|
126
179
|
export function CommandBlank({ reason }) {
|
package/dist/KerbalFaceFeed.d.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Renders ONE kerbal's face-camera stream in a fixed SQUARE frame. It is the
|
|
5
5
|
* reusable building block a crew bar (kerbcast web) or a mission-control
|
|
6
|
-
* surface
|
|
6
|
+
* surface composes: identity-keyed by `flightId`, it binds the live
|
|
7
7
|
* `MediaStream` to a persistent `<video>` and NEVER remounts that element on
|
|
8
8
|
* re-layout (the "never remount a live feed" rule — the same srcObject-via-ref
|
|
9
9
|
* technique `CameraFeed` uses).
|
package/dist/KerbalFaceFeed.js
CHANGED
|
@@ -4,7 +4,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
|
4
4
|
*
|
|
5
5
|
* Renders ONE kerbal's face-camera stream in a fixed SQUARE frame. It is the
|
|
6
6
|
* reusable building block a crew bar (kerbcast web) or a mission-control
|
|
7
|
-
* surface
|
|
7
|
+
* surface composes: identity-keyed by `flightId`, it binds the live
|
|
8
8
|
* `MediaStream` to a persistent `<video>` and NEVER remounts that element on
|
|
9
9
|
* re-layout (the "never remount a live feed" rule — the same srcObject-via-ref
|
|
10
10
|
* technique `CameraFeed` uses).
|
package/dist/RecordingsTray.d.ts
CHANGED
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
* labeller CameraFeed uses) so this stays decoupled from any particular
|
|
22
22
|
* camera registry; omit it for a plain "Camera <flightId>" fallback.
|
|
23
23
|
*/
|
|
24
|
+
import type { RecordingEndReason } from "@ksp-gonogo/kerbcast";
|
|
24
25
|
export interface RecordingsTrayProps {
|
|
25
26
|
/**
|
|
26
27
|
* Resolve a flightId to a display label for a clip's camera name. Omit for
|
|
@@ -35,3 +36,8 @@ export interface RecordingsTrayProps {
|
|
|
35
36
|
* `useRecordings()` directly, same requirement as `CameraFeed`).
|
|
36
37
|
*/
|
|
37
38
|
export declare function RecordingsTray({ cameraLabel, className }: RecordingsTrayProps): import("react").JSX.Element;
|
|
39
|
+
/**
|
|
40
|
+
* A short line saying why a recording stopped on its own, or null for one a
|
|
41
|
+
* person stopped or that ran its set length.
|
|
42
|
+
*/
|
|
43
|
+
export declare function recordingEndNote(endedBy: RecordingEndReason): string | null;
|
package/dist/RecordingsTray.js
CHANGED
|
@@ -18,7 +18,7 @@ function ClipRow({ rec, label, url, onDiscard }) {
|
|
|
18
18
|
const utRange = rec.utStart != null && rec.utEnd != null
|
|
19
19
|
? `${formatUt(rec.utStart)} - ${formatUt(rec.utEnd)}`
|
|
20
20
|
: "no UT (out of flight)";
|
|
21
|
-
return (_jsxs(Clip, { children: [url ? (_jsx(ClipPreview, { controls: true, preload: "metadata", src: url, "aria-label": `Preview: ${label}` })) : (_jsx(ClipPreviewPlaceholder, { "aria-hidden": "true" })), _jsxs(ClipMeta, { children: [_jsx(ClipName, { children: label }), _jsx(ClipDetail, { children: utRange }), _jsxs(ClipDetail, { children: [formatDuration(rec.durationMs), " \u00B7 ", formatBytes(rec.byteSize)] })] }), _jsxs(ClipActions, { children: [url && (_jsx(TrayLink, { href: url, download: clipFileName(rec, label), children: "Download" })), onDiscard && (_jsx(TrayButton, { type: "button", "$danger": true, onClick: onDiscard, children: "Discard" }))] })] }));
|
|
21
|
+
return (_jsxs(Clip, { children: [url ? (_jsx(ClipPreview, { controls: true, preload: "metadata", src: url, "aria-label": `Preview: ${label}` })) : (_jsx(ClipPreviewPlaceholder, { "aria-hidden": "true" })), _jsxs(ClipMeta, { children: [_jsx(ClipName, { children: label }), _jsx(ClipDetail, { children: utRange }), _jsxs(ClipDetail, { children: [formatDuration(rec.durationMs), " \u00B7 ", formatBytes(rec.byteSize)] }), rec.endedBy && recordingEndNote(rec.endedBy) && (_jsx(ClipDetail, { children: recordingEndNote(rec.endedBy) }))] }), _jsxs(ClipActions, { children: [url && (_jsx(TrayLink, { href: url, download: clipFileName(rec, label), children: "Download" })), onDiscard && (_jsx(TrayButton, { type: "button", "$danger": true, onClick: onDiscard, children: "Discard" }))] })] }));
|
|
22
22
|
}
|
|
23
23
|
function GroupItem({ group, labelFor, objectUrls, onDiscardGroup }) {
|
|
24
24
|
return (_jsxs(GroupCard, { "aria-label": `Grouped recording, ${group.recordings.length} feeds`, children: [_jsxs(GroupHeader, { children: [_jsxs(GroupHeaderText, { children: [_jsxs(GroupTitle, { children: ["Grouped recording \u00B7 ", group.recordings.length, " feeds"] }), group.commonUtWindow && (_jsxs(GroupMeta, { children: ["common UT ", formatUt(group.commonUtWindow[0]), " -", " ", formatUt(group.commonUtWindow[1])] }))] }), _jsxs(GroupActions, { children: [_jsx(TrayButton, { type: "button", onClick: () => downloadAll(group.recordings, objectUrls, labelFor), children: "Download all" }), _jsx(TrayButton, { type: "button", "$danger": true, onClick: onDiscardGroup, children: "Discard set" })] })] }), group.recordings.map((rec) => (_jsx(ClipRow, { rec: rec, label: labelFor(rec.flightId), url: objectUrls.get(rec.recordingId) }, rec.recordingId)))] }));
|
|
@@ -74,6 +74,22 @@ function useClipObjectUrls(clips) {
|
|
|
74
74
|
function formatUt(ut) {
|
|
75
75
|
return `T+${Math.round(ut)}s`;
|
|
76
76
|
}
|
|
77
|
+
/**
|
|
78
|
+
* A short line saying why a recording stopped on its own, or null for one a
|
|
79
|
+
* person stopped or that ran its set length.
|
|
80
|
+
*/
|
|
81
|
+
export function recordingEndNote(endedBy) {
|
|
82
|
+
switch (endedBy) {
|
|
83
|
+
case "camera-gone":
|
|
84
|
+
return "Stopped: the camera went away";
|
|
85
|
+
case "feed-closed":
|
|
86
|
+
return "Stopped: its feed was closed";
|
|
87
|
+
case "disconnected":
|
|
88
|
+
return "Stopped: the connection was lost";
|
|
89
|
+
default:
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
77
93
|
function formatDuration(ms) {
|
|
78
94
|
const totalSeconds = Math.max(0, Math.round(ms / 1000));
|
|
79
95
|
const hours = Math.floor(totalSeconds / 3600);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type FrameUtSource, type CommandTrackEvent } from "@ksp-gonogo/kerbcast";
|
|
1
|
+
import { type FrameUtSource, type CommandTrackEvent, type FrameCommandState } from "@ksp-gonogo/kerbcast";
|
|
2
2
|
export { MAX_UT_EXTRAPOLATION_MS, extrapolateCaptureUt } from "@ksp-gonogo/kerbcast";
|
|
3
3
|
/** Real milliseconds until the first event after `displayUt`, or `null` when none is ahead. */
|
|
4
4
|
export declare function msUntilNextEvent(events: readonly CommandTrackEvent[] | undefined, displayUt: number, warpRate: number): number | null;
|
|
@@ -8,7 +8,15 @@ export declare function crossesEvent(events: readonly CommandTrackEvent[] | unde
|
|
|
8
8
|
export interface CommandDisplayUt {
|
|
9
9
|
ut: number;
|
|
10
10
|
source: FrameUtSource | "override";
|
|
11
|
+
/** The command state that rode with the displayed frame (1.12 and later), or the host's own with an override. */
|
|
12
|
+
command?: FrameCommandState;
|
|
11
13
|
}
|
|
14
|
+
/**
|
|
15
|
+
* What of a frame's command state a viewer can see change: the discrete
|
|
16
|
+
* fields whole, ranges to 3 significant figures, pixel sizes to half a
|
|
17
|
+
* pixel. Frames that agree on it draw the same, so they skip a re-render.
|
|
18
|
+
*/
|
|
19
|
+
export declare function commandShownKey(c: FrameCommandState | undefined): string;
|
|
12
20
|
/**
|
|
13
21
|
* The UT a command feed is showing. An explicit `override` (a host with its
|
|
14
22
|
* own delayed playout) wins. Otherwise, given the feed's `flightId` and the
|
|
@@ -17,6 +25,11 @@ export interface CommandDisplayUt {
|
|
|
17
25
|
* interpolated to now (`"estimated"`). Re-evaluated twice a second while
|
|
18
26
|
* `enabled` so readout ages keep counting, and again the moment the
|
|
19
27
|
* displayed UT reaches the next of `events`, so a state change lands on its
|
|
20
|
-
* frame rather than on the next tick.
|
|
28
|
+
* frame rather than on the next tick. The displayed frame's own command
|
|
29
|
+
* state comes back as `command`, re-rendering when what it shows changes:
|
|
30
|
+
* an exact frame's, an estimated one's (which lags the picture, so is never
|
|
31
|
+
* early; its own UT is used too, so events and lists stay on that frame),
|
|
32
|
+
* or with an override the host's own `overrideCommand`. `null` when
|
|
33
|
+
* nothing is known.
|
|
21
34
|
*/
|
|
22
|
-
export declare function useCommandDisplayUt(enabled: boolean, override?: number, events?: readonly CommandTrackEvent[], flightId?: number | null, video?: HTMLVideoElement | null): CommandDisplayUt | null;
|
|
35
|
+
export declare function useCommandDisplayUt(enabled: boolean, override?: number, events?: readonly CommandTrackEvent[], flightId?: number | null, video?: HTMLVideoElement | null, overrideCommand?: FrameCommandState | null): CommandDisplayUt | null;
|
|
@@ -22,6 +22,35 @@ export function crossesEvent(events, from, to) {
|
|
|
22
22
|
const hi = Math.max(from, to);
|
|
23
23
|
return events.some((e) => e.ut > lo && e.ut <= hi);
|
|
24
24
|
}
|
|
25
|
+
/**
|
|
26
|
+
* What of a frame's command state a viewer can see change: the discrete
|
|
27
|
+
* fields whole, ranges to 3 significant figures, pixel sizes to half a
|
|
28
|
+
* pixel. Frames that agree on it draw the same, so they skip a re-render.
|
|
29
|
+
*/
|
|
30
|
+
export function commandShownKey(c) {
|
|
31
|
+
if (!c)
|
|
32
|
+
return "";
|
|
33
|
+
const sig = (v) => (v == null ? "" : Number(v.toPrecision(3)));
|
|
34
|
+
const half = (v) => (v == null ? "" : Math.round(v * 2));
|
|
35
|
+
return JSON.stringify([
|
|
36
|
+
c.hasFix,
|
|
37
|
+
sig(c.rangeM),
|
|
38
|
+
half(c.bracketHalfPx),
|
|
39
|
+
c.frameHeightPx,
|
|
40
|
+
c.framingFits,
|
|
41
|
+
half(c.trueOffsetPx),
|
|
42
|
+
c.occluded,
|
|
43
|
+
c.occluder,
|
|
44
|
+
c.sunlit,
|
|
45
|
+
c.blank ?? null,
|
|
46
|
+
c.authority?.owner ?? null,
|
|
47
|
+
c.subjectPersistentId,
|
|
48
|
+
c.refusal ?? null,
|
|
49
|
+
c.refusalCode ?? null,
|
|
50
|
+
c.refusalText ?? null,
|
|
51
|
+
c.waitingForTarget ?? false,
|
|
52
|
+
]);
|
|
53
|
+
}
|
|
25
54
|
/**
|
|
26
55
|
* The UT a command feed is showing. An explicit `override` (a host with its
|
|
27
56
|
* own delayed playout) wins. Otherwise, given the feed's `flightId` and the
|
|
@@ -30,9 +59,14 @@ export function crossesEvent(events, from, to) {
|
|
|
30
59
|
* interpolated to now (`"estimated"`). Re-evaluated twice a second while
|
|
31
60
|
* `enabled` so readout ages keep counting, and again the moment the
|
|
32
61
|
* displayed UT reaches the next of `events`, so a state change lands on its
|
|
33
|
-
* frame rather than on the next tick.
|
|
62
|
+
* frame rather than on the next tick. The displayed frame's own command
|
|
63
|
+
* state comes back as `command`, re-rendering when what it shows changes:
|
|
64
|
+
* an exact frame's, an estimated one's (which lags the picture, so is never
|
|
65
|
+
* early; its own UT is used too, so events and lists stay on that frame),
|
|
66
|
+
* or with an override the host's own `overrideCommand`. `null` when
|
|
67
|
+
* nothing is known.
|
|
34
68
|
*/
|
|
35
|
-
export function useCommandDisplayUt(enabled, override, events, flightId, video) {
|
|
69
|
+
export function useCommandDisplayUt(enabled, override, events, flightId, video, overrideCommand) {
|
|
36
70
|
const client = useKerbcastClient();
|
|
37
71
|
const sample = useRef({
|
|
38
72
|
captureUt: client.clock.captureUt,
|
|
@@ -58,39 +92,46 @@ export function useCommandDisplayUt(enabled, override, events, flightId, video)
|
|
|
58
92
|
const interval = setInterval(tick, TICK_MS);
|
|
59
93
|
return () => clearInterval(interval);
|
|
60
94
|
}, [enabled]);
|
|
61
|
-
/*
|
|
62
|
-
source,
|
|
95
|
+
/* Frames re-render only when they change what is drawn: a switch of
|
|
96
|
+
source, a step across an event, or a change the command state shows.
|
|
97
|
+
Ages catch up on the tick. An estimated frame is kept only for the
|
|
98
|
+
command state it carries. */
|
|
63
99
|
const overriding = override !== undefined;
|
|
64
100
|
useEffect(() => {
|
|
65
101
|
frame.current = null;
|
|
66
102
|
if (!enabled || overriding || flightId == null || !video)
|
|
67
103
|
return;
|
|
68
104
|
return client.watchDisplayedFrames(flightId, video, (f) => {
|
|
69
|
-
frame.current = f.source === "exact" ? f : null;
|
|
105
|
+
frame.current = f.source === "exact" || f.command ? f : null;
|
|
70
106
|
const prev = shown.current;
|
|
71
107
|
if (!frame.current) {
|
|
72
|
-
if (prev?.source === "exact")
|
|
108
|
+
if (prev?.source === "exact" || prev?.command)
|
|
73
109
|
tick();
|
|
74
110
|
return;
|
|
75
111
|
}
|
|
76
112
|
if (prev === null ||
|
|
77
|
-
prev.source !== "exact" ||
|
|
78
|
-
|
|
113
|
+
(f.source === "exact" && prev.source !== "exact") ||
|
|
114
|
+
(f.source !== "exact" && prev.source === "exact") ||
|
|
115
|
+
crossesEvent(eventsRef.current, prev.ut, f.ut) ||
|
|
116
|
+
commandShownKey(prev.command) !== commandShownKey(f.command)) {
|
|
79
117
|
tick();
|
|
80
118
|
}
|
|
81
119
|
});
|
|
82
120
|
}, [client, enabled, overriding, flightId, video]);
|
|
83
121
|
const { captureUt, warpRate, at } = sample.current;
|
|
84
|
-
const
|
|
122
|
+
const seen = frame.current;
|
|
123
|
+
const withCommand = (d, c) => c ? { ...d, command: c } : d;
|
|
85
124
|
const displayUt = overriding
|
|
86
|
-
? { ut: override, source: "override" }
|
|
87
|
-
: exact
|
|
88
|
-
? { ut:
|
|
89
|
-
:
|
|
90
|
-
?
|
|
91
|
-
:
|
|
125
|
+
? withCommand({ ut: override, source: "override" }, overrideCommand)
|
|
126
|
+
: seen?.source === "exact"
|
|
127
|
+
? withCommand({ ut: seen.ut, source: "exact" }, seen.command)
|
|
128
|
+
: seen
|
|
129
|
+
? withCommand({ ut: seen.ut, source: "estimated" }, seen.command)
|
|
130
|
+
: captureUt === null
|
|
131
|
+
? null
|
|
132
|
+
: { ut: extrapolateCaptureUt(captureUt, warpRate, at, nowMs()), source: "estimated" };
|
|
92
133
|
shown.current = displayUt;
|
|
93
|
-
const untilEdge = enabled && displayUt?.source === "estimated"
|
|
134
|
+
const untilEdge = enabled && displayUt?.source === "estimated" && !seen
|
|
94
135
|
? msUntilNextEvent(events, displayUt.ut, warpRate)
|
|
95
136
|
: null;
|
|
96
137
|
/* Edges further out than a tick are caught by a later render's timer. */
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { useKerbcastClient } from "../context";
|
|
3
|
+
/**
|
|
4
|
+
* Whether this client wants camera `flightId` but has no video slot to carry
|
|
5
|
+
* it (see `KerbcastCameraHandle.waitingForSlot`). False for a null id.
|
|
6
|
+
*/
|
|
7
|
+
export function useKerbcastWaitingForSlot(flightId) {
|
|
8
|
+
const client = useKerbcastClient();
|
|
9
|
+
const [waiting, setWaiting] = useState(() => flightId === null ? false : client.camera(flightId).waitingForSlot);
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
if (flightId === null) {
|
|
12
|
+
setWaiting(false);
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
const cam = client.camera(flightId);
|
|
16
|
+
setWaiting(cam.waitingForSlot);
|
|
17
|
+
return cam.on("waiting", setWaiting);
|
|
18
|
+
}, [client, flightId]);
|
|
19
|
+
return waiting;
|
|
20
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Whether target brackets are shown for one command camera, remembered per
|
|
3
3
|
* camera in this browser. Defaults on. `key` is null for a feed with no
|
|
4
|
-
* command camera, which reads as on and ignores toggles.
|
|
4
|
+
* command camera, which reads as on and ignores toggles. A preference saved
|
|
5
|
+
* under `legacyKey` (the key an older version used) is moved to `key`.
|
|
5
6
|
*/
|
|
6
|
-
export declare function useTargetBracketsPreference(key: string | null): [boolean, () => void];
|
|
7
|
+
export declare function useTargetBracketsPreference(key: string | null, legacyKey?: string | null): [boolean, () => void];
|
|
@@ -1,8 +1,20 @@
|
|
|
1
1
|
import { useCallback, useMemo, useState } from "react";
|
|
2
2
|
const STORAGE_PREFIX = "kerbcast:target-brackets:";
|
|
3
|
-
|
|
3
|
+
/* A preference saved under `legacyKey` moves to `key` the first time it is read. */
|
|
4
|
+
function readPreference(key, legacyKey) {
|
|
4
5
|
try {
|
|
5
|
-
|
|
6
|
+
const storage = globalThis.localStorage;
|
|
7
|
+
if (!storage)
|
|
8
|
+
return true;
|
|
9
|
+
const value = storage.getItem(STORAGE_PREFIX + key);
|
|
10
|
+
if (value !== null || legacyKey === null || legacyKey === key)
|
|
11
|
+
return value !== "0";
|
|
12
|
+
const legacy = storage.getItem(STORAGE_PREFIX + legacyKey);
|
|
13
|
+
if (legacy === null)
|
|
14
|
+
return true;
|
|
15
|
+
storage.setItem(STORAGE_PREFIX + key, legacy);
|
|
16
|
+
storage.removeItem(STORAGE_PREFIX + legacyKey);
|
|
17
|
+
return legacy !== "0";
|
|
6
18
|
}
|
|
7
19
|
catch {
|
|
8
20
|
return true;
|
|
@@ -22,11 +34,12 @@ function writePreference(key, on) {
|
|
|
22
34
|
/**
|
|
23
35
|
* Whether target brackets are shown for one command camera, remembered per
|
|
24
36
|
* camera in this browser. Defaults on. `key` is null for a feed with no
|
|
25
|
-
* command camera, which reads as on and ignores toggles.
|
|
37
|
+
* command camera, which reads as on and ignores toggles. A preference saved
|
|
38
|
+
* under `legacyKey` (the key an older version used) is moved to `key`.
|
|
26
39
|
*/
|
|
27
|
-
export function useTargetBracketsPreference(key) {
|
|
40
|
+
export function useTargetBracketsPreference(key, legacyKey = null) {
|
|
28
41
|
const [overrides, setOverrides] = useState({});
|
|
29
|
-
const stored = useMemo(() => (key === null ? true : readPreference(key)), [key]);
|
|
42
|
+
const stored = useMemo(() => (key === null ? true : readPreference(key, legacyKey)), [key, legacyKey]);
|
|
30
43
|
const on = key !== null && key in overrides ? overrides[key] : stored;
|
|
31
44
|
const toggle = useCallback(() => {
|
|
32
45
|
if (key === null)
|