fix(k1): admit live rerun only after real data

This commit is contained in:
DCCONSTRUCTIONS
2026-08-20 12:54:55 +03:00
parent 321cde70e8
commit aee60f12b9
5 changed files with 150 additions and 31 deletions
+2 -2
View File
@@ -780,8 +780,8 @@ export default function App() {
</StatusBadge>
) : activeDefinition.kind === "spatial" ? (
<div className="observation-header-tools">
<StatusBadge tone={runtime.state?.sourceMode === "live" ? "success" : "neutral"}>
{runtime.state?.sourceMode === "live" ? "Эфир" : "Ожидание эфира"}
<StatusBadge tone={["live", "replay"].includes(runtime.state?.sourceMode ?? "") ? "success" : "neutral"}>
{runtime.state?.sourceMode === "live" ? "Эфир" : runtime.state?.sourceMode === "replay" ? "Повтор записи" : "Ожидание эфира"}
</StatusBadge>
{layoutSaveNotice || workspaceLayoutProfile.error ? (
<span
@@ -275,6 +275,40 @@ export function isUsableRecordedPlaybackRange(
);
}
/**
* A live receiver is presentable only after the exact browser store exposes
* real timeline data that the backend has also confirmed publishing.
* `WebViewer.start()` and a non-null active recording id are transport setup,
* not evidence that the spatial scene can render.
*/
export function isLiveRerunPresentationReady(
viewerStarted: boolean,
rangeNs: { min: number; max: number } | null,
backendActivitySequence: number | null,
): boolean {
return viewerStarted &&
Number.isSafeInteger(backendActivitySequence) &&
(backendActivitySequence ?? 0) > 0 &&
isUsableRecordedPlaybackRange(rangeNs);
}
/**
* Key the native receiver to its data-plane binding. Recovery authority is a
* retry fence projected from changing supervisor snapshots; it must not tear
* down a healthy WebViewer while this acquisition and URL remain unchanged.
*/
export function liveRerunReceiverBindingIdentity(
sourceUrl: string,
liveStreamId: string | null,
followLive: boolean,
): string {
return JSON.stringify([
followLive ? "live" : "recorded",
sourceUrl.trim(),
followLive ? liveStreamId?.trim() ?? "" : "",
]);
}
/** Describe progressive archive availability without gating first rendering. */
export function recordedPlaybackBufferState(
rangeNs: { min: number; max: number } | null,
@@ -938,6 +972,11 @@ export function RerunViewport({
presentationGate,
recordedArtifact !== null,
);
const liveReceiverBindingIdentity = liveRerunReceiverBindingIdentity(
sourceUrl,
liveStreamId,
followLive,
);
useEffect(() => subscribeToLiveViewerBuildFence(() => {
uiBuildStaleRef.current = true;
@@ -949,7 +988,7 @@ export function RerunViewport({
useEffect(() => {
liveRecoveryRef.current = initialLiveReceiverRecoveryState();
}, [followLive, liveRecoveryAuthorityIdentity, liveStreamId, sourceUrl]);
}, [liveReceiverBindingIdentity]);
useEffect(() => {
const normalizedSource = sourceUrl.trim();
@@ -1152,9 +1191,10 @@ export function RerunViewport({
recoveryAttempt: liveRecoveryRef.current.attempts || null,
});
}
const currentRecoveryAuthorityIdentity = liveRecoveryAuthorityRef.current;
const recovery = requestLiveReceiverRecovery(liveRecoveryRef.current, {
activeAuthorityIdentity: liveRecoveryAuthorityRef.current,
expectedAuthorityIdentity: liveRecoveryAuthorityIdentity,
activeAuthorityIdentity: currentRecoveryAuthorityIdentity,
expectedAuthorityIdentity: currentRecoveryAuthorityIdentity,
disposed,
});
liveRecoveryRef.current = recovery.state;
@@ -1245,7 +1285,7 @@ export function RerunViewport({
stalledForMs: Math.round(observed.stalledForMs),
recoveryAttempt: Math.min(
liveRecoveryRef.current.attempts + 1,
liveRecoveryAuthorityIdentity
liveRecoveryAuthorityRef.current
? Number.MAX_SAFE_INTEGER
: LIVE_RECEIVER_MAX_RECOVERY_ATTEMPTS,
),
@@ -1356,17 +1396,9 @@ export function RerunViewport({
) return;
recordingOpened = true;
if (!isRecordedSource) {
diagnosticLifecycle.markAdmitted();
if (liveRecoveryRef.current.awaitingRecovery) {
diagnosticLifecycle.post({
eventCode: "live_receiver_recovered",
streamId: liveStreamIdRef.current,
backendActivitySequence: liveActivitySequenceRef.current,
viewerRangeMaxNs: latestLiveRangeMaxNs,
recoveryAttempt: liveRecoveryRef.current.attempts,
});
}
liveRecoveryRef.current = initialLiveReceiverRecoveryState();
// Store discovery only establishes a candidate. Admission is
// committed below after this recording exposes a usable live
// range backed by an actual published frame.
}
if (
recordedBlueprintUrl &&
@@ -1479,7 +1511,11 @@ export function RerunViewport({
setRecordingBufferProgress(recordedBuffer.bufferProgress);
}
const readyToRender = followLive
? viewerStartResolved
? isLiveRerunPresentationReady(
viewerStartResolved,
rangeNs,
liveActivitySequenceRef.current,
)
: isRecordedPlaybackReady(viewerStartResolved, artifactVerified, recordedBuffer);
const presentationReady = presentationGateRef.current === "ready";
if (!followLive && (!readyToRender || !presentationReady) && playing) {
@@ -1531,6 +1567,25 @@ export function RerunViewport({
});
if (readyToRender && !readyPublished) {
readyPublished = true;
if (followLive) {
diagnosticLifecycle.post({
eventCode: "live_receiver_active_store_admitted",
streamId: liveStreamIdRef.current,
backendActivitySequence: liveActivitySequenceRef.current,
viewerRangeMaxNs: rangeNs?.max ?? null,
});
diagnosticLifecycle.markAdmitted();
if (liveRecoveryRef.current.awaitingRecovery) {
diagnosticLifecycle.post({
eventCode: "live_receiver_recovered",
streamId: liveStreamIdRef.current,
backendActivitySequence: liveActivitySequenceRef.current,
viewerRangeMaxNs: rangeNs?.max ?? null,
recoveryAttempt: liveRecoveryRef.current.attempts,
});
}
liveRecoveryRef.current = initialLiveReceiverRecoveryState();
}
if (!followLive) clearRecordedAdmissionWatchdog();
if (!followLive) setRecordingBufferProgress(1);
recordedSceneAdmitted = true;
@@ -1563,11 +1618,6 @@ export function RerunViewport({
// Rerun 0.34.1 may ingest an SDK gRPC store without forwarding its
// recording_open event to the JavaScript wrapper. The active store
// is the authoritative fallback and avoids hiding a ready canvas.
diagnosticLifecycle.post({
eventCode: "live_receiver_active_store_admitted",
streamId: liveStreamIdRef.current,
backendActivitySequence: liveActivitySequenceRef.current,
});
admitRecording({
application_id: "nodedc_mission_core_spatial",
recording_id: recordingId,
@@ -1716,10 +1766,8 @@ export function RerunViewport({
autoplayWhenReady,
expectedTimelineEndSeconds,
expectedTimelineStartSeconds,
followLive,
liveReceiverBindingIdentity,
initialPlaybackStartSeconds,
liveStreamId,
liveRecoveryAuthorityIdentity,
onPlaybackChange,
onPlaybackControllerChange,
onSelectionChange,
@@ -1729,7 +1777,6 @@ export function RerunViewport({
recordedArtifact?.sourceUrl,
recordedArtifact?.viewerSourceUrl,
retryNonce,
sourceUrl,
]);
useEffect(() => {
@@ -156,7 +156,7 @@ function SpatialWorkspace({
const [showDetections2d, setShowDetections2d] = useState(false);
const [showSegmentation, setShowSegmentation] = useState(false);
const [showCuboids3d, setShowCuboids3d] = useState(false);
const recordedSource = state?.sourceMode === "replay" || /\.rrd(?:$|[?#])/i.test(sourceUrl);
const recordedSource = Boolean(recordedReplay) || /\.rrd(?:$|[?#])/i.test(sourceUrl);
const recordedSessionGate: RecordedAdmissionPhase = recordedSource
? recordedSessionAdmission?.phase ?? "loading"
: "ready";