wip(k1): checkpoint connection recovery rewrite
Capture the current unreleased K1 connection, recovery, lifecycle, viewer, and test work as a single known-bad baseline for subsequent fixes.
This commit is contained in:
@@ -23,6 +23,64 @@ interface DevicePluginHostValue {
|
||||
|
||||
const DevicePluginHostContext = createContext<DevicePluginHostValue | null>(null);
|
||||
|
||||
export const DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY =
|
||||
"nodedc.mission-core.device-model-selection.v1";
|
||||
|
||||
export interface DeviceModelSelectionStorage {
|
||||
getItem: (key: string) => string | null;
|
||||
setItem: (key: string, value: string) => void;
|
||||
removeItem: (key: string) => void;
|
||||
}
|
||||
|
||||
function browserDeviceModelSelectionStorage(): DeviceModelSelectionStorage | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
try {
|
||||
return window.localStorage;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore only a model that is present in the current reviewed registry.
|
||||
* Removed/renamed models and malformed browser values fail closed to the
|
||||
* picker and are cleared so a later remount cannot keep retrying stale state.
|
||||
*/
|
||||
export function restorePersistedDeviceModelId(
|
||||
registry: DevicePluginRegistry,
|
||||
storage: DeviceModelSelectionStorage | null,
|
||||
): string | null {
|
||||
if (!storage) return null;
|
||||
try {
|
||||
const stored = storage.getItem(DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY);
|
||||
const modelId = stored?.trim() || null;
|
||||
if (modelId && registry.resolveModel(modelId)) return modelId;
|
||||
if (stored !== null) {
|
||||
storage.removeItem(DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY);
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist only an already-admitted host transition; browser storage is never authority. */
|
||||
export function commitPersistedDeviceModelId(
|
||||
modelId: string | null,
|
||||
storage: DeviceModelSelectionStorage | null,
|
||||
): void {
|
||||
if (!storage) return;
|
||||
try {
|
||||
if (modelId === null) {
|
||||
storage.removeItem(DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
storage.setItem(DEVICE_PLUGIN_SELECTED_MODEL_STORAGE_KEY, modelId);
|
||||
} catch {
|
||||
// A denied/full localStorage must not block the in-memory host transition.
|
||||
}
|
||||
}
|
||||
|
||||
export function DevicePluginHostProvider({
|
||||
plugins,
|
||||
children,
|
||||
@@ -31,7 +89,10 @@ export function DevicePluginHostProvider({
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const registry = useMemo(() => createDevicePluginRegistry(plugins), [plugins]);
|
||||
const [selectedModelId, setSelectedModelId] = useState<string | null>(null);
|
||||
const selectionStorage = useMemo(browserDeviceModelSelectionStorage, []);
|
||||
const [selectedModelId, setSelectedModelId] = useState<string | null>(() =>
|
||||
restorePersistedDeviceModelId(registry, selectionStorage)
|
||||
);
|
||||
const [selectionTransitionPending, setSelectionTransitionPending] = useState(false);
|
||||
const [selectionTransitionError, setSelectionTransitionError] = useState<string | null>(null);
|
||||
const transitionInFlight = useRef(false);
|
||||
@@ -56,7 +117,12 @@ export function DevicePluginHostProvider({
|
||||
if (nextModelId !== null && !registry.resolveModel(nextModelId)) {
|
||||
throw new Error(`Модель устройства не зарегистрирована: ${nextModelId}.`);
|
||||
}
|
||||
if (nextModelId === selectedModelId) return true;
|
||||
if (nextModelId === selectedModelId) {
|
||||
// `clearSelection()` must clear a stale persisted value even when the
|
||||
// current in-memory selection is already empty.
|
||||
commitPersistedDeviceModelId(nextModelId, selectionStorage);
|
||||
return true;
|
||||
}
|
||||
|
||||
transitionInFlight.current = true;
|
||||
setSelectionTransitionPending(true);
|
||||
@@ -88,13 +154,14 @@ export function DevicePluginHostProvider({
|
||||
}
|
||||
}
|
||||
setSelectedModelId(nextModelId);
|
||||
commitPersistedDeviceModelId(nextModelId, selectionStorage);
|
||||
return true;
|
||||
} finally {
|
||||
transitionInFlight.current = false;
|
||||
setSelectionTransitionPending(false);
|
||||
}
|
||||
},
|
||||
[registry, selectedModelId],
|
||||
[registry, selectedModelId, selectionStorage],
|
||||
);
|
||||
|
||||
const deactivationRegistrars = useMemo(
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import type {
|
||||
ObservationSourceDelivery,
|
||||
ObservationSourceDescriptor,
|
||||
} from "../runtime/contracts";
|
||||
|
||||
export const CAMERA_WAKE_GAP_MS = 5_000;
|
||||
export const CAMERA_HIDDEN_REOPEN_MS = 1_000;
|
||||
export const CAMERA_REOPEN_COOLDOWN_MS = 1_000;
|
||||
|
||||
export type CameraPlaybackRecoveryEvent =
|
||||
| { type: "document-hidden" }
|
||||
| { type: "document-visible" }
|
||||
| { type: "network-online" }
|
||||
| { type: "page-restore"; persisted: boolean }
|
||||
| { type: "heartbeat" };
|
||||
|
||||
export interface CameraPlaybackRecoveryState {
|
||||
authorityIdentity: string;
|
||||
hiddenAt: number | null;
|
||||
lastObservedAt: number;
|
||||
lastReopenAt: number | null;
|
||||
}
|
||||
|
||||
export interface CameraPlaybackRecoveryContext {
|
||||
activeAuthorityIdentity: string | null;
|
||||
now: number;
|
||||
documentVisible: boolean;
|
||||
networkOnline: boolean;
|
||||
}
|
||||
|
||||
export interface CameraPlaybackRecoveryDecision {
|
||||
state: CameraPlaybackRecoveryState;
|
||||
reopen: boolean;
|
||||
}
|
||||
|
||||
export function cameraTransportCallbackIsCurrent(
|
||||
activeEpoch: number,
|
||||
callbackEpoch: number,
|
||||
disposed: boolean,
|
||||
): boolean {
|
||||
return !disposed && activeEpoch === callbackEpoch;
|
||||
}
|
||||
|
||||
export function cameraBrowserTransportIdentity(
|
||||
delivery: ObservationSourceDelivery & { kind: "mse-fmp4-websocket" },
|
||||
authorityIdentity: string | null,
|
||||
): string {
|
||||
return JSON.stringify([
|
||||
delivery.id,
|
||||
delivery.url,
|
||||
delivery.mediaType,
|
||||
authorityIdentity,
|
||||
]);
|
||||
}
|
||||
|
||||
function trimmedString(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown): value is number {
|
||||
return Number.isInteger(value) && (value as number) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind browser-only recovery to one exact, server-authoritative live camera.
|
||||
* A point-cloud recovery, retained delivery or selected camera without the
|
||||
* active acquisition/session tuple must not reopen a decoder.
|
||||
*/
|
||||
export function liveCameraPlaybackAuthorityIdentity(
|
||||
source: ObservationSourceDescriptor,
|
||||
): string | null {
|
||||
const delivery = source.delivery;
|
||||
const activation = source.activation;
|
||||
const deviceId = trimmedString(source.binding?.deviceId);
|
||||
const deviceSessionId = trimmedString(source.binding?.deviceSessionId);
|
||||
const acquisitionId = trimmedString(source.binding?.acquisitionId);
|
||||
const sourceId = trimmedString(source.sourceId);
|
||||
const descriptorId = trimmedString(source.id);
|
||||
const activationGroupId = trimmedString(activation?.groupId);
|
||||
const deliveryId = trimmedString(delivery?.id);
|
||||
const deliveryUrl = trimmedString(delivery?.url);
|
||||
const presentationLease = source.presentationLease;
|
||||
const recoveryPresentation = presentationLease?.kind === "active-stream-recovery";
|
||||
const recoveryLeaseValid = Boolean(
|
||||
recoveryPresentation
|
||||
&& trimmedString(presentationLease.runtimeId)
|
||||
&& trimmedString(presentationLease.acquisitionId) === acquisitionId
|
||||
&& positiveInteger(presentationLease.acquisitionStateRevision)
|
||||
&& positiveInteger(presentationLease.producerGeneration)
|
||||
&& positiveInteger(presentationLease.recoveryGeneration),
|
||||
);
|
||||
const availabilityAuthoritative = source.availability === "streaming"
|
||||
|| (
|
||||
recoveryLeaseValid
|
||||
&& (source.availability === "connecting" || source.availability === "degraded")
|
||||
);
|
||||
const mediaType = delivery?.kind === "mse-fmp4-websocket"
|
||||
? trimmedString(delivery.mediaType)
|
||||
: "";
|
||||
if (
|
||||
source.modality !== "video"
|
||||
|| !availabilityAuthoritative
|
||||
|| (presentationLease != null && !recoveryLeaseValid)
|
||||
|| delivery?.kind !== "mse-fmp4-websocket"
|
||||
|| activation?.selected !== true
|
||||
|| activation.maxActive !== 1
|
||||
|| !activationGroupId
|
||||
|| !descriptorId
|
||||
|| !sourceId
|
||||
|| !deliveryId
|
||||
|| !deliveryUrl
|
||||
|| !/^video\/mp4(?:\s*;|$)/i.test(mediaType)
|
||||
|| !deviceId
|
||||
|| !deviceSessionId
|
||||
|| !acquisitionId
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return JSON.stringify([
|
||||
descriptorId,
|
||||
sourceId,
|
||||
deviceId,
|
||||
deviceSessionId,
|
||||
acquisitionId,
|
||||
activationGroupId,
|
||||
activation.maxActive,
|
||||
deliveryId,
|
||||
deliveryUrl,
|
||||
mediaType,
|
||||
recoveryLeaseValid ? [
|
||||
presentationLease?.runtimeId,
|
||||
presentationLease?.acquisitionStateRevision,
|
||||
presentationLease?.producerGeneration,
|
||||
presentationLease?.recoveryGeneration,
|
||||
] : null,
|
||||
]);
|
||||
}
|
||||
|
||||
export function initialCameraPlaybackRecoveryState(
|
||||
authorityIdentity: string,
|
||||
now: number,
|
||||
): CameraPlaybackRecoveryState {
|
||||
return {
|
||||
authorityIdentity,
|
||||
hiddenAt: null,
|
||||
lastObservedAt: now,
|
||||
lastReopenAt: null,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Reduce browser lifecycle signals without performing I/O. `reopen=true`
|
||||
* means replace the current browser WebSocket + MSE pair; it never means a
|
||||
* device START/STOP, camera selection or network mutation.
|
||||
*/
|
||||
export function reduceCameraPlaybackRecovery(
|
||||
current: CameraPlaybackRecoveryState,
|
||||
event: CameraPlaybackRecoveryEvent,
|
||||
context: CameraPlaybackRecoveryContext,
|
||||
): CameraPlaybackRecoveryDecision {
|
||||
const { now } = context;
|
||||
if (event.type === "document-hidden") {
|
||||
return {
|
||||
state: {
|
||||
...current,
|
||||
hiddenAt: now,
|
||||
lastObservedAt: now,
|
||||
},
|
||||
reopen: false,
|
||||
};
|
||||
}
|
||||
|
||||
const authorityCurrent = Boolean(
|
||||
context.activeAuthorityIdentity
|
||||
&& context.activeAuthorityIdentity === current.authorityIdentity,
|
||||
);
|
||||
const documentReady = context.documentVisible && context.networkOnline;
|
||||
let candidate = false;
|
||||
let hiddenAt = current.hiddenAt;
|
||||
|
||||
if (event.type === "document-visible") {
|
||||
candidate = hiddenAt !== null && now - hiddenAt >= CAMERA_HIDDEN_REOPEN_MS;
|
||||
hiddenAt = null;
|
||||
} else if (event.type === "network-online") {
|
||||
candidate = true;
|
||||
} else if (event.type === "page-restore") {
|
||||
candidate = event.persisted;
|
||||
} else if (event.type === "heartbeat") {
|
||||
candidate = now - current.lastObservedAt >= CAMERA_WAKE_GAP_MS;
|
||||
}
|
||||
|
||||
const outsideCooldown = current.lastReopenAt === null
|
||||
|| now - current.lastReopenAt >= CAMERA_REOPEN_COOLDOWN_MS;
|
||||
const reopen = candidate && authorityCurrent && documentReady && outsideCooldown;
|
||||
return {
|
||||
state: {
|
||||
...current,
|
||||
hiddenAt,
|
||||
lastObservedAt: now,
|
||||
lastReopenAt: reopen ? now : current.lastReopenAt,
|
||||
},
|
||||
reopen,
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,8 @@
|
||||
import type {
|
||||
ObservationSourceDescriptor,
|
||||
SpatialSourceDescriptor,
|
||||
} from "../runtime/contracts";
|
||||
|
||||
export interface LiveReceiverWatchdogState {
|
||||
lastBackendActivitySequence: number | null;
|
||||
lastViewerRangeMaxNs: number | null;
|
||||
@@ -19,16 +24,163 @@ export interface LiveReceiverRecoveryState {
|
||||
awaitingRecovery: boolean;
|
||||
}
|
||||
|
||||
export type LiveReceiverRecoverySignal = "retry" | "exhausted";
|
||||
export interface LiveReceiverOpenWatchdogState {
|
||||
lastBackendActivitySequence: number | null;
|
||||
openedAtMs: number;
|
||||
}
|
||||
|
||||
export type LiveReceiverOpenWatchdogSignal =
|
||||
| "wait-for-store"
|
||||
| "refresh-receiver"
|
||||
| "restart-receiver";
|
||||
|
||||
export interface LiveReceiverOpenWatchdogResult {
|
||||
state: LiveReceiverOpenWatchdogState;
|
||||
recoveryState: LiveReceiverRecoveryState;
|
||||
signal: LiveReceiverOpenWatchdogSignal;
|
||||
openForMs: number;
|
||||
}
|
||||
|
||||
export type LiveReceiverRecoverySignal = "retry" | "exhausted" | "stale";
|
||||
|
||||
export interface LiveReceiverRecoveryResult {
|
||||
state: LiveReceiverRecoveryState;
|
||||
signal: LiveReceiverRecoverySignal;
|
||||
attempt: number;
|
||||
delayMs: number | null;
|
||||
}
|
||||
|
||||
export interface LiveReceiverRecoveryRequest {
|
||||
activeAuthorityIdentity: string | null;
|
||||
expectedAuthorityIdentity: string | null;
|
||||
disposed?: boolean;
|
||||
maxAttempts?: number;
|
||||
}
|
||||
|
||||
export const LIVE_RECEIVER_STALL_THRESHOLD_MS = 5_000;
|
||||
export const LIVE_RECEIVER_MAX_RECOVERY_ATTEMPTS = 3;
|
||||
// The bridge publishes its URL only after StoreInfo, blueprint and static
|
||||
// scene data have been flushed. A receiver that still has not admitted that
|
||||
// store after one operator-visible four-second window is wedged, not merely
|
||||
// slow; keeping it for 48 seconds made a healthy live scan look blank.
|
||||
export const LIVE_RECEIVER_OPEN_MAX_AGE_MS = 4_000;
|
||||
const LIVE_RECEIVER_RECOVERY_DELAYS_MS = [400, 1_000, 2_000, 5_000] as const;
|
||||
|
||||
export function liveReceiverRecoveryRetryDelay(attempt: number): number {
|
||||
const normalizedAttempt = Number.isSafeInteger(attempt) && attempt > 0 ? attempt : 1;
|
||||
return LIVE_RECEIVER_RECOVERY_DELAYS_MS[
|
||||
Math.min(normalizedAttempt - 1, LIVE_RECEIVER_RECOVERY_DELAYS_MS.length - 1)
|
||||
];
|
||||
}
|
||||
|
||||
function trimmedString(value: unknown): string {
|
||||
return typeof value === "string" ? value.trim() : "";
|
||||
}
|
||||
|
||||
function positiveInteger(value: unknown): value is number {
|
||||
return Number.isInteger(value) && (value as number) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind durable browser-only Rerun recovery to one exact authoritative spatial
|
||||
* presentation. A healthy live descriptor is fenced by its acquisition and
|
||||
* session tuple; a reconnecting/degraded descriptor additionally requires the
|
||||
* server-issued active-stream-recovery generation lease.
|
||||
*/
|
||||
export function liveRerunRecoveryAuthorityIdentity(
|
||||
source: ObservationSourceDescriptor | null | undefined,
|
||||
spatialSource: SpatialSourceDescriptor | null | undefined,
|
||||
): string | null {
|
||||
if (!source || !spatialSource) return null;
|
||||
const descriptorId = trimmedString(source.id);
|
||||
const sourceId = trimmedString(source.sourceId);
|
||||
const semanticChannelId = trimmedString(source.semanticChannelId);
|
||||
const previewUrl = trimmedString(source.previewUrl);
|
||||
const spatialId = trimmedString(spatialSource.id);
|
||||
const spatialUrl = trimmedString(spatialSource.url);
|
||||
const deviceId = trimmedString(source.binding?.deviceId);
|
||||
const deviceSessionId = trimmedString(source.binding?.deviceSessionId);
|
||||
const acquisitionId = trimmedString(source.binding?.acquisitionId);
|
||||
const pluginId = trimmedString(source.provider?.pluginId);
|
||||
const pluginVersion = trimmedString(source.provider?.pluginVersion);
|
||||
const modelId = trimmedString(source.provider?.modelId);
|
||||
const compatibilityProfileId = trimmedString(source.provider?.compatibilityProfileId);
|
||||
const clockId = trimmedString(source.capabilities?.clockId);
|
||||
const presentationLease = source.presentationLease;
|
||||
const recoveryPresentation = presentationLease?.kind === "active-stream-recovery";
|
||||
const recoveryLeaseValid = Boolean(
|
||||
recoveryPresentation
|
||||
&& trimmedString(presentationLease.runtimeId)
|
||||
&& trimmedString(presentationLease.acquisitionId) === acquisitionId
|
||||
&& positiveInteger(presentationLease.acquisitionStateRevision)
|
||||
&& positiveInteger(presentationLease.producerGeneration)
|
||||
&& positiveInteger(presentationLease.recoveryGeneration),
|
||||
);
|
||||
const availabilityAuthoritative = source.availability === "streaming"
|
||||
|| (
|
||||
recoveryLeaseValid
|
||||
&& (source.availability === "connecting" || source.availability === "degraded")
|
||||
);
|
||||
if (
|
||||
source.modality !== "point-cloud"
|
||||
|| source.transport !== "rerun-grpc"
|
||||
|| spatialSource.kind !== "rerun-grpc"
|
||||
|| source.capabilities.timelineMode !== "live-only"
|
||||
|| source.capabilities.spatialRegistration !== "native"
|
||||
|| source.delivery != null
|
||||
|| !availabilityAuthoritative
|
||||
|| (presentationLease != null && !recoveryLeaseValid)
|
||||
|| !descriptorId
|
||||
|| !sourceId
|
||||
|| !semanticChannelId
|
||||
|| !previewUrl
|
||||
|| previewUrl !== spatialUrl
|
||||
|| !spatialId
|
||||
|| spatialId !== acquisitionId
|
||||
|| !deviceId
|
||||
|| !deviceSessionId
|
||||
|| !acquisitionId
|
||||
|| clockId !== acquisitionId
|
||||
|| !pluginId
|
||||
|| !pluginVersion
|
||||
|| !modelId
|
||||
|| !compatibilityProfileId
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return JSON.stringify([
|
||||
descriptorId,
|
||||
sourceId,
|
||||
semanticChannelId,
|
||||
deviceId,
|
||||
deviceSessionId,
|
||||
acquisitionId,
|
||||
spatialId,
|
||||
spatialUrl,
|
||||
pluginId,
|
||||
pluginVersion,
|
||||
modelId,
|
||||
compatibilityProfileId,
|
||||
recoveryLeaseValid ? [
|
||||
presentationLease?.runtimeId,
|
||||
presentationLease?.acquisitionStateRevision,
|
||||
presentationLease?.producerGeneration,
|
||||
presentationLease?.recoveryGeneration,
|
||||
] : null,
|
||||
]);
|
||||
}
|
||||
|
||||
export function liveReceiverRecoveryAuthorityIsCurrent(
|
||||
activeAuthorityIdentity: string | null,
|
||||
expectedAuthorityIdentity: string | null,
|
||||
disposed = false,
|
||||
): boolean {
|
||||
return Boolean(
|
||||
!disposed
|
||||
&& expectedAuthorityIdentity
|
||||
&& activeAuthorityIdentity === expectedAuthorityIdentity,
|
||||
);
|
||||
}
|
||||
|
||||
export function initialLiveReceiverWatchdogState(): LiveReceiverWatchdogState {
|
||||
return {
|
||||
@@ -47,16 +199,104 @@ export function initialLiveReceiverRecoveryState(): LiveReceiverRecoveryState {
|
||||
};
|
||||
}
|
||||
|
||||
function validBackendActivitySequence(value: number | null | undefined): number | null {
|
||||
return Number.isSafeInteger(value) && (value ?? -1) >= 0 ? (value as number) : null;
|
||||
}
|
||||
|
||||
export function initialLiveReceiverOpenWatchdogState(
|
||||
backendActivitySequence: number | null = null,
|
||||
openedAtMs = Date.now(),
|
||||
): LiveReceiverOpenWatchdogState {
|
||||
return {
|
||||
lastBackendActivitySequence: validBackendActivitySequence(backendActivitySequence),
|
||||
openedAtMs: Number.isFinite(openedAtMs) ? openedAtMs : Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep one still-opening Rerun receiver alive while the backend is proving
|
||||
* fresh publication progress. Recreating the WASM receiver on a fixed timer
|
||||
* can repeatedly discard an otherwise healthy late StoreInfo replay. Rolling
|
||||
* patience is nevertheless bounded: a receiver that has not admitted a store
|
||||
* by the absolute open-age limit is refreshed without consuming the recovery
|
||||
* budget. A true lack of backend progress delegates to the bounded restart
|
||||
* policy. Recovery debt is cleared only after viewer admission, never merely
|
||||
* because the backend counter advanced.
|
||||
*/
|
||||
export function advanceLiveReceiverOpenWatchdog(
|
||||
current: LiveReceiverOpenWatchdogState,
|
||||
recoveryState: LiveReceiverRecoveryState,
|
||||
backendActivitySequence: number | null,
|
||||
nowMs = Date.now(),
|
||||
maxOpenAgeMs = LIVE_RECEIVER_OPEN_MAX_AGE_MS,
|
||||
): LiveReceiverOpenWatchdogResult {
|
||||
const sequence = validBackendActivitySequence(backendActivitySequence);
|
||||
const previous = current.lastBackendActivitySequence;
|
||||
const backendAdvanced = sequence !== null && (
|
||||
(previous === null && sequence > 0) ||
|
||||
(previous !== null && sequence > previous)
|
||||
);
|
||||
const state = {
|
||||
lastBackendActivitySequence: sequence === null
|
||||
? previous
|
||||
: Math.max(sequence, previous ?? 0),
|
||||
openedAtMs: current.openedAtMs,
|
||||
};
|
||||
const openForMs = Math.max(0, nowMs - current.openedAtMs);
|
||||
if (backendAdvanced) {
|
||||
return {
|
||||
state,
|
||||
recoveryState,
|
||||
signal: openForMs >= maxOpenAgeMs
|
||||
? "refresh-receiver"
|
||||
: "wait-for-store",
|
||||
openForMs,
|
||||
};
|
||||
}
|
||||
return {
|
||||
state,
|
||||
recoveryState,
|
||||
signal: "restart-receiver",
|
||||
openForMs,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Bound viewer-only restarts independently from scanner and acquisition
|
||||
* lifecycle. The caller may dispose and recreate the browser receiver, but
|
||||
* must never issue START/STOP or reconnect the physical device.
|
||||
* lifecycle unless the caller proves that one exact live/recovery authority
|
||||
* is still current. Under that fence retries remain durable and use a capped
|
||||
* delay while the attempt counter stays truthful. The caller may dispose and
|
||||
* recreate only the browser receiver; it must never issue START/STOP or
|
||||
* reconnect the physical device.
|
||||
*/
|
||||
export function requestLiveReceiverRecovery(
|
||||
current: LiveReceiverRecoveryState,
|
||||
maxAttempts = LIVE_RECEIVER_MAX_RECOVERY_ATTEMPTS,
|
||||
request?: LiveReceiverRecoveryRequest,
|
||||
): LiveReceiverRecoveryResult {
|
||||
if (current.attempts >= maxAttempts) {
|
||||
const maxAttempts = positiveInteger(request?.maxAttempts)
|
||||
? request.maxAttempts
|
||||
: LIVE_RECEIVER_MAX_RECOVERY_ATTEMPTS;
|
||||
const expectedAuthorityIdentity = request?.expectedAuthorityIdentity ?? null;
|
||||
const exactAuthorityCurrent = liveReceiverRecoveryAuthorityIsCurrent(
|
||||
request?.activeAuthorityIdentity ?? null,
|
||||
expectedAuthorityIdentity,
|
||||
request?.disposed === true,
|
||||
);
|
||||
if (
|
||||
request
|
||||
&& (
|
||||
request.disposed === true
|
||||
|| request.activeAuthorityIdentity !== expectedAuthorityIdentity
|
||||
)
|
||||
) {
|
||||
return {
|
||||
state: current,
|
||||
signal: "stale",
|
||||
attempt: current.attempts,
|
||||
delayMs: null,
|
||||
};
|
||||
}
|
||||
if (current.attempts >= maxAttempts && !exactAuthorityCurrent) {
|
||||
return {
|
||||
state: {
|
||||
attempts: current.attempts,
|
||||
@@ -64,6 +304,7 @@ export function requestLiveReceiverRecovery(
|
||||
},
|
||||
signal: "exhausted",
|
||||
attempt: current.attempts,
|
||||
delayMs: null,
|
||||
};
|
||||
}
|
||||
const attempt = current.attempts + 1;
|
||||
@@ -74,6 +315,7 @@ export function requestLiveReceiverRecovery(
|
||||
},
|
||||
signal: "retry",
|
||||
attempt,
|
||||
delayMs: exactAuthorityCurrent ? liveReceiverRecoveryRetryDelay(attempt) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -91,10 +333,7 @@ export function advanceLiveReceiverWatchdog(
|
||||
},
|
||||
thresholdMs = LIVE_RECEIVER_STALL_THRESHOLD_MS,
|
||||
): LiveReceiverWatchdogResult {
|
||||
const backendSequence = Number.isSafeInteger(sample.backendActivitySequence) &&
|
||||
(sample.backendActivitySequence ?? -1) >= 0
|
||||
? sample.backendActivitySequence
|
||||
: null;
|
||||
const backendSequence = validBackendActivitySequence(sample.backendActivitySequence);
|
||||
const viewerRange = Number.isFinite(sample.viewerRangeMaxNs) &&
|
||||
(sample.viewerRangeMaxNs ?? -1) >= 0
|
||||
? sample.viewerRangeMaxNs
|
||||
|
||||
@@ -22,7 +22,66 @@ export interface LiveViewerDiagnostic {
|
||||
recoveryAttempt?: number | null;
|
||||
}
|
||||
|
||||
export interface LiveViewerLineage {
|
||||
uiBuildId: string;
|
||||
documentInstanceId: string;
|
||||
viewerInstanceId: string;
|
||||
lifecycleGeneration: number;
|
||||
}
|
||||
|
||||
export interface LiveViewerDiagnosticScheduler {
|
||||
setTimeout(callback: () => void, delayMilliseconds: number): number;
|
||||
clearTimeout(handle: number): void;
|
||||
setInterval(callback: () => void, delayMilliseconds: number): number;
|
||||
clearInterval(handle: number): void;
|
||||
}
|
||||
|
||||
export interface LiveViewerDiagnosticLifecycle {
|
||||
readonly lineage: LiveViewerLineage;
|
||||
readonly signal: AbortSignal;
|
||||
active(): boolean;
|
||||
admitted(): boolean;
|
||||
post(event: LiveViewerDiagnostic): void;
|
||||
verifyBuild(): void;
|
||||
armAdmissionTimeout(callback: () => void, delayMilliseconds: number): void;
|
||||
armAdmissionInterval(callback: () => void, delayMilliseconds: number): void;
|
||||
clearAdmissionTimeout(): void;
|
||||
clearAdmissionInterval(): void;
|
||||
markAdmitted(): void;
|
||||
dispose(): void;
|
||||
}
|
||||
|
||||
export interface StaleUiBuild {
|
||||
loadedUiBuildId: string;
|
||||
expectedUiBuildId: string;
|
||||
}
|
||||
|
||||
export interface UiBuildStaleCoordinator {
|
||||
subscribe(listener: (event: StaleUiBuild) => void): () => void;
|
||||
report(event: StaleUiBuild): void;
|
||||
stale(): boolean;
|
||||
}
|
||||
|
||||
const LIVE_VIEWER_DIAGNOSTIC_SCHEMA = "missioncore.live-viewer-diagnostic/v2";
|
||||
const LIVE_VIEWER_CLIENT_CONTRACT_SCHEMA = "missioncore.live-viewer-client-contract/v1";
|
||||
const UI_BUILD_HEADER = "x-missioncore-ui-build";
|
||||
const DEVELOPMENT_UI_BUILD_ID = "development";
|
||||
const SAFE_STREAM_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const HASHED_UI_BUILD_ID = /^\/assets\/[A-Za-z0-9._/-]+-[A-Za-z0-9_-]{8,}\.js$/;
|
||||
const UI_BUILD_CHECK_INTERVAL_MILLISECONDS = 15_000;
|
||||
const UI_BUILD_RELOAD_DELAY_MILLISECONDS = 50;
|
||||
|
||||
let documentInstanceId: string | null = null;
|
||||
let sharedUiBuildCoordinator: UiBuildStaleCoordinator | null = null;
|
||||
let buildMonitorSubscribers = 0;
|
||||
let buildMonitorInterval: number | null = null;
|
||||
let buildMonitorAbort: AbortController | null = null;
|
||||
let buildMonitorOnlineListener: (() => void) | null = null;
|
||||
let buildMonitorVisibilityListener: (() => void) | null = null;
|
||||
|
||||
function randomInstanceId(): string {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
|
||||
function safeInteger(value: number | null | undefined): number | undefined {
|
||||
return typeof value === "number" && Number.isSafeInteger(value) && value >= 0
|
||||
@@ -30,11 +89,75 @@ function safeInteger(value: number | null | undefined): number | undefined {
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function postLiveViewerDiagnostic(event: LiveViewerDiagnostic): void {
|
||||
function browserScheduler(): LiveViewerDiagnosticScheduler {
|
||||
return {
|
||||
setTimeout: (callback, delayMilliseconds) => window.setTimeout(callback, delayMilliseconds),
|
||||
clearTimeout: (handle) => window.clearTimeout(handle),
|
||||
setInterval: (callback, delayMilliseconds) => window.setInterval(callback, delayMilliseconds),
|
||||
clearInterval: (handle) => window.clearInterval(handle),
|
||||
};
|
||||
}
|
||||
|
||||
export function uiBuildIdFromModuleScripts(
|
||||
scriptSources: readonly string[],
|
||||
baseUrl: string,
|
||||
): string {
|
||||
for (const source of scriptSources) {
|
||||
try {
|
||||
const pathname = new URL(source, baseUrl).pathname;
|
||||
if (HASHED_UI_BUILD_ID.test(pathname)) return pathname;
|
||||
} catch {
|
||||
// A malformed non-entry script is not the running application build.
|
||||
}
|
||||
}
|
||||
return DEVELOPMENT_UI_BUILD_ID;
|
||||
}
|
||||
|
||||
export function currentUiBuildId(): string {
|
||||
const scripts = Array.from(
|
||||
document.querySelectorAll<HTMLScriptElement>('script[type="module"][src]'),
|
||||
(script) => script.src,
|
||||
);
|
||||
return uiBuildIdFromModuleScripts(scripts, window.location.href);
|
||||
}
|
||||
|
||||
export function liveViewerDocumentInstanceId(): string {
|
||||
documentInstanceId ??= randomInstanceId();
|
||||
return documentInstanceId;
|
||||
}
|
||||
|
||||
export function createLiveViewerInstanceId(): string {
|
||||
return randomInstanceId();
|
||||
}
|
||||
|
||||
export function createLiveViewerLineage(
|
||||
viewerInstanceId: string,
|
||||
lifecycleGeneration: number,
|
||||
overrides: Partial<Pick<LiveViewerLineage, "uiBuildId" | "documentInstanceId">> = {},
|
||||
): LiveViewerLineage {
|
||||
if (!Number.isSafeInteger(lifecycleGeneration) || lifecycleGeneration < 1) {
|
||||
throw new Error("Live viewer lifecycle generation must be a positive safe integer");
|
||||
}
|
||||
return {
|
||||
uiBuildId: overrides.uiBuildId ?? currentUiBuildId(),
|
||||
documentInstanceId: overrides.documentInstanceId ?? liveViewerDocumentInstanceId(),
|
||||
viewerInstanceId,
|
||||
lifecycleGeneration,
|
||||
};
|
||||
}
|
||||
|
||||
export function liveViewerDiagnosticBody(
|
||||
event: LiveViewerDiagnostic,
|
||||
lineage: LiveViewerLineage,
|
||||
): Record<string, string | number> {
|
||||
const streamId = event.streamId?.trim();
|
||||
const body = {
|
||||
schema_version: "missioncore.live-viewer-diagnostic/v1",
|
||||
return {
|
||||
schema_version: LIVE_VIEWER_DIAGNOSTIC_SCHEMA,
|
||||
event_code: event.eventCode,
|
||||
ui_build_id: lineage.uiBuildId,
|
||||
document_instance_id: lineage.documentInstanceId,
|
||||
viewer_instance_id: lineage.viewerInstanceId,
|
||||
lifecycle_generation: lineage.lifecycleGeneration,
|
||||
...(event.failureStage ? { failure_stage: event.failureStage } : {}),
|
||||
...(streamId && SAFE_STREAM_ID.test(streamId) ? { stream_id: streamId } : {}),
|
||||
...(safeInteger(event.backendActivitySequence) === undefined
|
||||
@@ -50,12 +173,252 @@ export function postLiveViewerDiagnostic(event: LiveViewerDiagnostic): void {
|
||||
? {}
|
||||
: { recovery_attempt: safeInteger(event.recoveryAttempt) }),
|
||||
};
|
||||
}
|
||||
|
||||
export function createUiBuildStaleCoordinator({
|
||||
scheduleReload,
|
||||
reload,
|
||||
}: {
|
||||
scheduleReload: (callback: () => void, delayMilliseconds: number) => void;
|
||||
reload: () => void;
|
||||
}): UiBuildStaleCoordinator {
|
||||
const listeners = new Set<(event: StaleUiBuild) => void>();
|
||||
let staleEvent: StaleUiBuild | null = null;
|
||||
let reloadScheduled = false;
|
||||
return {
|
||||
subscribe(listener) {
|
||||
listeners.add(listener);
|
||||
if (staleEvent) listener(staleEvent);
|
||||
return () => listeners.delete(listener);
|
||||
},
|
||||
report(event) {
|
||||
if (event.loadedUiBuildId === event.expectedUiBuildId || staleEvent) return;
|
||||
staleEvent = event;
|
||||
for (const listener of [...listeners]) listener(event);
|
||||
if (reloadScheduled) return;
|
||||
reloadScheduled = true;
|
||||
scheduleReload(reload, UI_BUILD_RELOAD_DELAY_MILLISECONDS);
|
||||
},
|
||||
stale() {
|
||||
return staleEvent !== null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function browserUiBuildCoordinator(): UiBuildStaleCoordinator {
|
||||
sharedUiBuildCoordinator ??= createUiBuildStaleCoordinator({
|
||||
scheduleReload: (callback, delayMilliseconds) => {
|
||||
window.setTimeout(callback, delayMilliseconds);
|
||||
},
|
||||
reload: () => window.location.reload(),
|
||||
});
|
||||
return sharedUiBuildCoordinator;
|
||||
}
|
||||
|
||||
function inspectUiBuildResponse(
|
||||
response: Response,
|
||||
loadedUiBuildId: string,
|
||||
signal?: AbortSignal,
|
||||
): void {
|
||||
if (signal?.aborted || loadedUiBuildId === DEVELOPMENT_UI_BUILD_ID) return;
|
||||
const expectedUiBuildId = response.headers.get(UI_BUILD_HEADER);
|
||||
if (
|
||||
expectedUiBuildId &&
|
||||
expectedUiBuildId !== loadedUiBuildId &&
|
||||
(response.status === 409 || response.ok)
|
||||
) {
|
||||
browserUiBuildCoordinator().report({ loadedUiBuildId, expectedUiBuildId });
|
||||
}
|
||||
}
|
||||
|
||||
export function postLiveViewerDiagnostic(
|
||||
event: LiveViewerDiagnostic,
|
||||
lineage: LiveViewerLineage,
|
||||
signal?: AbortSignal,
|
||||
): void {
|
||||
if (signal?.aborted) return;
|
||||
void fetch("/api/v1/viewer/live-diagnostics", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
body: JSON.stringify(liveViewerDiagnosticBody(event, lineage)),
|
||||
keepalive: true,
|
||||
signal,
|
||||
}).then((response) => {
|
||||
inspectUiBuildResponse(response, lineage.uiBuildId, signal);
|
||||
}).catch(() => {
|
||||
// Diagnostics must never interfere with the live receiver recovery path.
|
||||
// Diagnostics and build fencing must never interfere with receiver recovery.
|
||||
});
|
||||
}
|
||||
|
||||
export function verifyLiveViewerClientBuild(
|
||||
lineage: Pick<LiveViewerLineage, "uiBuildId">,
|
||||
signal?: AbortSignal,
|
||||
): void {
|
||||
if (signal?.aborted || lineage.uiBuildId === DEVELOPMENT_UI_BUILD_ID) return;
|
||||
void fetch("/api/v1/viewer/client-contract", {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
cache: "no-store",
|
||||
signal,
|
||||
}).then(async (response) => {
|
||||
if (signal?.aborted) return;
|
||||
inspectUiBuildResponse(response, lineage.uiBuildId, signal);
|
||||
if (!response.ok) return;
|
||||
const contract = await response.json() as unknown;
|
||||
if (
|
||||
signal?.aborted ||
|
||||
typeof contract !== "object" ||
|
||||
contract === null ||
|
||||
!("schema_version" in contract) ||
|
||||
contract.schema_version !== LIVE_VIEWER_CLIENT_CONTRACT_SCHEMA ||
|
||||
!("ui_build_id" in contract) ||
|
||||
typeof contract.ui_build_id !== "string" ||
|
||||
contract.ui_build_id === lineage.uiBuildId
|
||||
) return;
|
||||
browserUiBuildCoordinator().report({
|
||||
loadedUiBuildId: lineage.uiBuildId,
|
||||
expectedUiBuildId: contract.ui_build_id,
|
||||
});
|
||||
}).catch(() => {
|
||||
// Offline periods are handled by the existing live-stream recovery path.
|
||||
});
|
||||
}
|
||||
|
||||
function startBuildMonitor(): void {
|
||||
if (buildMonitorAbort || typeof window === "undefined") return;
|
||||
const lineage = createLiveViewerLineage(createLiveViewerInstanceId(), 1);
|
||||
const controller = new AbortController();
|
||||
buildMonitorAbort = controller;
|
||||
const verify = createAbortFencedBuildVerifier(
|
||||
controller.signal,
|
||||
(signal) => verifyLiveViewerClientBuild(lineage, signal),
|
||||
);
|
||||
buildMonitorInterval = window.setInterval(
|
||||
verify,
|
||||
UI_BUILD_CHECK_INTERVAL_MILLISECONDS,
|
||||
);
|
||||
buildMonitorOnlineListener = verify;
|
||||
buildMonitorVisibilityListener = () => {
|
||||
if (document.visibilityState === "visible") verify();
|
||||
};
|
||||
window.addEventListener("online", buildMonitorOnlineListener);
|
||||
document.addEventListener("visibilitychange", buildMonitorVisibilityListener);
|
||||
verify();
|
||||
}
|
||||
|
||||
export function createAbortFencedBuildVerifier(
|
||||
signal: AbortSignal,
|
||||
verifier: (signal: AbortSignal) => void,
|
||||
): () => void {
|
||||
return () => {
|
||||
if (signal.aborted) return;
|
||||
verifier(signal);
|
||||
};
|
||||
}
|
||||
|
||||
function stopBuildMonitor(): void {
|
||||
buildMonitorAbort?.abort();
|
||||
buildMonitorAbort = null;
|
||||
if (buildMonitorInterval !== null) window.clearInterval(buildMonitorInterval);
|
||||
buildMonitorInterval = null;
|
||||
if (buildMonitorOnlineListener) {
|
||||
window.removeEventListener("online", buildMonitorOnlineListener);
|
||||
}
|
||||
if (buildMonitorVisibilityListener) {
|
||||
document.removeEventListener("visibilitychange", buildMonitorVisibilityListener);
|
||||
}
|
||||
buildMonitorOnlineListener = null;
|
||||
buildMonitorVisibilityListener = null;
|
||||
}
|
||||
|
||||
export function subscribeToLiveViewerBuildFence(
|
||||
listener: (event: StaleUiBuild) => void,
|
||||
): () => void {
|
||||
const unsubscribe = browserUiBuildCoordinator().subscribe(listener);
|
||||
buildMonitorSubscribers += 1;
|
||||
if (buildMonitorSubscribers === 1) startBuildMonitor();
|
||||
return () => {
|
||||
unsubscribe();
|
||||
buildMonitorSubscribers = Math.max(0, buildMonitorSubscribers - 1);
|
||||
if (buildMonitorSubscribers === 0) stopBuildMonitor();
|
||||
};
|
||||
}
|
||||
|
||||
export function createLiveViewerDiagnosticLifecycle({
|
||||
lineage,
|
||||
scheduler = browserScheduler(),
|
||||
diagnosticPoster = postLiveViewerDiagnostic,
|
||||
buildVerifier = verifyLiveViewerClientBuild,
|
||||
}: {
|
||||
lineage: LiveViewerLineage;
|
||||
scheduler?: LiveViewerDiagnosticScheduler;
|
||||
diagnosticPoster?: (
|
||||
event: LiveViewerDiagnostic,
|
||||
lineage: LiveViewerLineage,
|
||||
signal?: AbortSignal,
|
||||
) => void;
|
||||
buildVerifier?: (
|
||||
lineage: Pick<LiveViewerLineage, "uiBuildId">,
|
||||
signal?: AbortSignal,
|
||||
) => void;
|
||||
}): LiveViewerDiagnosticLifecycle {
|
||||
const abort = new AbortController();
|
||||
let isActive = true;
|
||||
let isAdmitted = false;
|
||||
let admissionTimeout: number | null = null;
|
||||
let admissionInterval: number | null = null;
|
||||
const clearAdmissionTimeout = () => {
|
||||
if (admissionTimeout === null) return;
|
||||
scheduler.clearTimeout(admissionTimeout);
|
||||
admissionTimeout = null;
|
||||
};
|
||||
const clearAdmissionInterval = () => {
|
||||
if (admissionInterval === null) return;
|
||||
scheduler.clearInterval(admissionInterval);
|
||||
admissionInterval = null;
|
||||
};
|
||||
return {
|
||||
lineage,
|
||||
signal: abort.signal,
|
||||
active: () => isActive,
|
||||
admitted: () => isAdmitted,
|
||||
post(event) {
|
||||
if (!isActive) return;
|
||||
diagnosticPoster(event, lineage, abort.signal);
|
||||
},
|
||||
verifyBuild() {
|
||||
if (!isActive) return;
|
||||
buildVerifier(lineage, abort.signal);
|
||||
},
|
||||
armAdmissionTimeout(callback, delayMilliseconds) {
|
||||
clearAdmissionTimeout();
|
||||
if (!isActive || isAdmitted) return;
|
||||
admissionTimeout = scheduler.setTimeout(() => {
|
||||
admissionTimeout = null;
|
||||
if (isActive && !isAdmitted) callback();
|
||||
}, delayMilliseconds);
|
||||
},
|
||||
armAdmissionInterval(callback, delayMilliseconds) {
|
||||
clearAdmissionInterval();
|
||||
if (!isActive || isAdmitted) return;
|
||||
admissionInterval = scheduler.setInterval(() => {
|
||||
if (isActive && !isAdmitted) callback();
|
||||
}, delayMilliseconds);
|
||||
},
|
||||
clearAdmissionTimeout,
|
||||
clearAdmissionInterval,
|
||||
markAdmitted() {
|
||||
if (!isActive) return;
|
||||
isAdmitted = true;
|
||||
clearAdmissionTimeout();
|
||||
clearAdmissionInterval();
|
||||
},
|
||||
dispose() {
|
||||
if (!isActive) return;
|
||||
isActive = false;
|
||||
abort.abort();
|
||||
clearAdmissionTimeout();
|
||||
clearAdmissionInterval();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ export interface ObservationLayoutController {
|
||||
setFloatingMaximized: (sourceId: string, maximized: boolean) => void;
|
||||
setWindowRect: (sourceId: string, rect: ObservationWindowRect) => void;
|
||||
setViewportSize: (size: ObservationViewportSize) => void;
|
||||
activateAutomaticDefaults: () => void;
|
||||
snapshot: () => ObservationLayoutSnapshot | null;
|
||||
restore: (snapshot: ObservationLayoutSnapshot) => void;
|
||||
}
|
||||
@@ -68,6 +69,66 @@ function canOpenByDefault(source: ObservationSourceDescriptor): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One exact live presentation lease. The stable source id is deliberately not
|
||||
* sufficient: the same live camera and even the same browser delivery generation
|
||||
* can be reused by a later acquisition.
|
||||
*/
|
||||
export function automaticLivePresentationIdentity(
|
||||
source: ObservationSourceDescriptor,
|
||||
): string | null {
|
||||
const acquisitionId = source.binding.acquisitionId?.trim();
|
||||
const deliveryId = source.delivery?.id?.trim();
|
||||
if (
|
||||
!acquisitionId
|
||||
|| !deliveryId
|
||||
|| !canOpenByDefault(source)
|
||||
|| source.activation?.selected !== true
|
||||
) return null;
|
||||
return JSON.stringify([source.id, acquisitionId, deliveryId]);
|
||||
}
|
||||
|
||||
/** A deliberate close fences every delivery generation in that acquisition. */
|
||||
export function livePresentationCloseFence(
|
||||
source: ObservationSourceDescriptor,
|
||||
): string | null {
|
||||
const acquisitionId = source.binding.acquisitionId?.trim();
|
||||
return acquisitionId ? JSON.stringify([source.id, acquisitionId]) : null;
|
||||
}
|
||||
|
||||
export interface LiveDefaultPresentationAdmission {
|
||||
visibleIds: string[];
|
||||
removedIds: string[];
|
||||
admittedIdentities: string[];
|
||||
}
|
||||
|
||||
export function admitLiveDefaultPresentations(
|
||||
currentIds: readonly string[],
|
||||
sources: readonly ObservationSourceDescriptor[],
|
||||
admittedIdentities: ReadonlySet<string>,
|
||||
closedAcquisitionSources: ReadonlySet<string>,
|
||||
): LiveDefaultPresentationAdmission {
|
||||
let change = { visibleIds: [...currentIds], removedIds: [] as string[] };
|
||||
const removed = new Set<string>();
|
||||
const admitted: string[] = [];
|
||||
for (const source of sources) {
|
||||
const presentationIdentity = automaticLivePresentationIdentity(source);
|
||||
if (
|
||||
!presentationIdentity
|
||||
|| admittedIdentities.has(presentationIdentity)
|
||||
|| closedAcquisitionSources.has(livePresentationCloseFence(source) ?? "")
|
||||
) continue;
|
||||
change = openObservationSource(change.visibleIds, source.id, sources);
|
||||
change.removedIds.forEach((sourceId) => removed.add(sourceId));
|
||||
admitted.push(presentationIdentity);
|
||||
}
|
||||
return {
|
||||
visibleIds: change.visibleIds,
|
||||
removedIds: [...removed],
|
||||
admittedIdentities: admitted,
|
||||
};
|
||||
}
|
||||
|
||||
function catalogIdentity(sources: readonly ObservationSourceDescriptor[]): string {
|
||||
return sources
|
||||
.map((source) => [
|
||||
@@ -107,6 +168,8 @@ export function useObservationLayout(
|
||||
const viewportSizeRef = useRef<ObservationViewportSize | null>(null);
|
||||
const desiredSnapshotRef = useRef<ObservationLayoutSnapshot | null>(null);
|
||||
const restoredLayoutAuthorityRef = useRef(false);
|
||||
const admittedLivePresentationIdentitiesRef = useRef(new Set<string>());
|
||||
const closedLiveAcquisitionSourcesRef = useRef(new Set<string>());
|
||||
const initializedCatalog = useRef<string | null>(null);
|
||||
const sourceIdList = sources.map((source) => source.id).sort();
|
||||
const sourceIdsIdentity = sourceIdList.join("\u0000");
|
||||
@@ -284,31 +347,25 @@ export function useObservationLayout(
|
||||
sources,
|
||||
]);
|
||||
|
||||
const selectedDeliveryIdentity = sources
|
||||
.filter((source) => source.capabilities.defaultVisible && source.activation?.selected && source.delivery)
|
||||
.map((source) => [
|
||||
source.id,
|
||||
source.delivery?.id,
|
||||
source.activation?.groupId,
|
||||
source.activation?.maxActive,
|
||||
].join(":"))
|
||||
.sort()
|
||||
.join("|");
|
||||
const selectedDeliveryIdentity = JSON.stringify(sources
|
||||
.map(automaticLivePresentationIdentity)
|
||||
.filter((candidate): candidate is string => candidate !== null)
|
||||
.sort());
|
||||
|
||||
useEffect(() => {
|
||||
if (restoredLayoutAuthorityRef.current) return;
|
||||
const selected = sources.filter(
|
||||
(source) => source.capabilities.defaultVisible && source.activation?.selected && source.delivery,
|
||||
const admission = admitLiveDefaultPresentations(
|
||||
visibleIdsRef.current,
|
||||
sources,
|
||||
admittedLivePresentationIdentitiesRef.current,
|
||||
closedLiveAcquisitionSourcesRef.current,
|
||||
);
|
||||
if (!selected.length) return;
|
||||
let change = { visibleIds: visibleIdsRef.current, removedIds: [] as string[] };
|
||||
const removed = new Set<string>();
|
||||
for (const source of selected) {
|
||||
change = openObservationSource(change.visibleIds, source.id, sources);
|
||||
change.removedIds.forEach((sourceId) => removed.add(sourceId));
|
||||
}
|
||||
commitVisibleIds(change.visibleIds);
|
||||
clearPresentation([...removed], false);
|
||||
if (!admission.admittedIdentities.length) return;
|
||||
admission.admittedIdentities.forEach((presentationIdentity) => {
|
||||
admittedLivePresentationIdentitiesRef.current.add(presentationIdentity);
|
||||
});
|
||||
commitVisibleIds(admission.visibleIds);
|
||||
clearPresentation(admission.removedIds, false);
|
||||
persistLiveLayout();
|
||||
}, [clearPresentation, commitVisibleIds, persistLiveLayout, selectedDeliveryIdentity]);
|
||||
|
||||
@@ -334,6 +391,8 @@ export function useObservationLayout(
|
||||
markPending(source, false);
|
||||
}
|
||||
}
|
||||
const closeFence = livePresentationCloseFence(source);
|
||||
if (closeFence) closedLiveAcquisitionSourcesRef.current.add(closeFence);
|
||||
restoredLayoutAuthorityRef.current = false;
|
||||
const change = closeObservationSource(visibleIdsRef.current, sourceId);
|
||||
commitVisibleIds(change.visibleIds);
|
||||
@@ -356,6 +415,8 @@ export function useObservationLayout(
|
||||
markPending(source, false);
|
||||
}
|
||||
}
|
||||
const closeFence = livePresentationCloseFence(source);
|
||||
if (closeFence) closedLiveAcquisitionSourcesRef.current.delete(closeFence);
|
||||
restoredLayoutAuthorityRef.current = false;
|
||||
const change = openObservationSource(visibleIdsRef.current, sourceId, sources);
|
||||
commitVisibleIds(change.visibleIds);
|
||||
@@ -427,6 +488,46 @@ export function useObservationLayout(
|
||||
}
|
||||
}, [applyDesiredSnapshot, persistLiveLayout]);
|
||||
|
||||
const activateAutomaticDefaults = useCallback(() => {
|
||||
// A new operator-started live acquisition owns its initial presentation.
|
||||
// Keep saved geometry, but do not let an older layout suppress a camera
|
||||
// that the device plugin has just selected and delivered automatically.
|
||||
restoredLayoutAuthorityRef.current = false;
|
||||
const currentSources = sourcesRef.current;
|
||||
let nextVisibleIds = visibleIdsRef.current;
|
||||
for (const source of currentSources.filter(canOpenByDefault)) {
|
||||
if (automaticLivePresentationIdentity(source)) continue;
|
||||
nextVisibleIds = openObservationSource(
|
||||
nextVisibleIds,
|
||||
source.id,
|
||||
currentSources,
|
||||
).visibleIds;
|
||||
}
|
||||
const admission = admitLiveDefaultPresentations(
|
||||
nextVisibleIds,
|
||||
currentSources,
|
||||
admittedLivePresentationIdentitiesRef.current,
|
||||
closedLiveAcquisitionSourcesRef.current,
|
||||
);
|
||||
admission.admittedIdentities.forEach((presentationIdentity) => {
|
||||
admittedLivePresentationIdentitiesRef.current.add(presentationIdentity);
|
||||
});
|
||||
nextVisibleIds = admission.visibleIds;
|
||||
commitVisibleIds(nextVisibleIds);
|
||||
clearPresentation(admission.removedIds, false);
|
||||
const firstFloatingDefault = currentSources.find(
|
||||
(source) => (
|
||||
canOpenByDefault(source)
|
||||
&& source.capabilities.overlay
|
||||
&& nextVisibleIds.includes(source.id)
|
||||
),
|
||||
);
|
||||
if (firstFloatingDefault) {
|
||||
commitActiveFloatingSourceId(firstFloatingDefault.id);
|
||||
}
|
||||
persistLiveLayout();
|
||||
}, [clearPresentation, commitActiveFloatingSourceId, commitVisibleIds, persistLiveLayout]);
|
||||
|
||||
const snapshot = useCallback((): ObservationLayoutSnapshot | null => {
|
||||
if (!viewportSizeRef.current) return null;
|
||||
persistLiveLayout();
|
||||
@@ -464,6 +565,7 @@ export function useObservationLayout(
|
||||
setFloatingMaximized,
|
||||
setWindowRect,
|
||||
setViewportSize,
|
||||
activateAutomaticDefaults,
|
||||
snapshot,
|
||||
restore,
|
||||
};
|
||||
|
||||
@@ -154,6 +154,15 @@ export interface ObservationSourceActivation {
|
||||
controllable: boolean;
|
||||
}
|
||||
|
||||
export interface ObservationSourcePresentationLease {
|
||||
kind: "active-stream-recovery";
|
||||
runtimeId: string;
|
||||
acquisitionId: string;
|
||||
acquisitionStateRevision: number;
|
||||
producerGeneration: number;
|
||||
recoveryGeneration: number;
|
||||
}
|
||||
|
||||
export interface ObservationSourceDescriptor {
|
||||
id: string;
|
||||
sourceId: string;
|
||||
@@ -168,6 +177,7 @@ export interface ObservationSourceDescriptor {
|
||||
previewUrl?: string | null;
|
||||
delivery?: ObservationSourceDelivery | null;
|
||||
activation?: ObservationSourceActivation | null;
|
||||
presentationLease?: ObservationSourcePresentationLease | null;
|
||||
provider: ObservationSourceProvider;
|
||||
binding: ObservationSourceBinding;
|
||||
capabilities: ObservationSourceCapabilities;
|
||||
@@ -201,6 +211,7 @@ export interface MissionRuntimeController {
|
||||
backendStatus: BackendStatus;
|
||||
pendingAction: string | null;
|
||||
refresh: () => void | Promise<void>;
|
||||
resetConnectionScenario?: () => Promise<boolean>;
|
||||
updateViewerSettings: (settings: ViewerSettings) => Promise<boolean>;
|
||||
setObservationSourceActive?: (sourceId: string, active: boolean) => Promise<boolean>;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user