refactor(lab): canonicalize recorded spatial replay
This commit is contained in:
@@ -1,8 +1,11 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import {
|
||||
RecordedFmp4Player,
|
||||
type RecordedObservationPlayback,
|
||||
} from "../RecordedFmp4Player";
|
||||
import type { ObservationSourceDescriptor } from "../../core/runtime/contracts";
|
||||
import type { RecordedCameraAdmissionState } from "../../core/observation/recordedSessionAdmission";
|
||||
import {
|
||||
RecordedEvidenceBoxOverlay,
|
||||
type RecordedEvidenceBox,
|
||||
@@ -33,6 +36,7 @@ export function RecordedEvidenceVideoScene({
|
||||
segmentCount,
|
||||
onPlaybackChange,
|
||||
onPlayingRejected,
|
||||
onAdmissionChange,
|
||||
playbackAuthority = "media",
|
||||
playbackTransport = "segmented",
|
||||
}: {
|
||||
@@ -49,9 +53,22 @@ export function RecordedEvidenceVideoScene({
|
||||
segmentCount?: number;
|
||||
onPlaybackChange?: (playback: RecordedObservationPlayback) => void;
|
||||
onPlayingRejected?: () => void;
|
||||
onAdmissionChange?: (state: RecordedCameraAdmissionState) => void;
|
||||
playbackAuthority?: "media" | "host";
|
||||
playbackTransport?: "segmented" | "epoch-stream";
|
||||
}) {
|
||||
const generation = source.delivery?.kind === "recorded-fmp4-manifest"
|
||||
? source.delivery.manifestGenerationSha256
|
||||
: "invalid";
|
||||
const [admissionPhase, setAdmissionPhase] = useState<RecordedCameraAdmissionState["phase"]>(
|
||||
"loading",
|
||||
);
|
||||
useEffect(() => setAdmissionPhase("loading"), [generation, source.id]);
|
||||
const handleAdmissionChange = (next: RecordedCameraAdmissionState) => {
|
||||
setAdmissionPhase(next.phase);
|
||||
onAdmissionChange?.(next);
|
||||
};
|
||||
const sourceReady = admissionPhase === "ready";
|
||||
return (
|
||||
<div className="recorded-evidence-video-scene">
|
||||
<RecordedFmp4Player
|
||||
@@ -63,29 +80,32 @@ export function RecordedEvidenceVideoScene({
|
||||
segmentCount={segmentCount}
|
||||
onPlaybackChange={onPlaybackChange}
|
||||
onPlayingRejected={onPlayingRejected}
|
||||
onAdmissionChange={handleAdmissionChange}
|
||||
playbackAuthority={playbackAuthority}
|
||||
playbackTransport={playbackTransport}
|
||||
/>
|
||||
{semanticOverlay ? (
|
||||
{sourceReady && semanticOverlay ? (
|
||||
<RecordedEvidenceSemanticMaskOverlay
|
||||
{...semanticOverlay}
|
||||
imageWidth={imageWidth}
|
||||
imageHeight={imageHeight}
|
||||
/>
|
||||
) : null}
|
||||
{pointCloudOverlay ? (
|
||||
{sourceReady && pointCloudOverlay ? (
|
||||
<RecordedEvidencePointCloudOverlay
|
||||
imageWidth={imageWidth}
|
||||
imageHeight={imageHeight}
|
||||
overlay={pointCloudOverlay}
|
||||
/>
|
||||
) : null}
|
||||
<RecordedEvidenceBoxOverlay
|
||||
imageWidth={imageWidth}
|
||||
imageHeight={imageHeight}
|
||||
boxes={boxes}
|
||||
ariaLabel={ariaLabel}
|
||||
/>
|
||||
{sourceReady ? (
|
||||
<RecordedEvidenceBoxOverlay
|
||||
imageWidth={imageWidth}
|
||||
imageHeight={imageHeight}
|
||||
boxes={boxes}
|
||||
ariaLabel={ariaLabel}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+116
@@ -0,0 +1,116 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
fetchCanonicalRecordedLabSpatialFrame,
|
||||
type CanonicalRecordedLabSpatialFrame,
|
||||
} from "../../core/laboratory/canonicalRecordedLabSpatial";
|
||||
|
||||
const FRAME_CACHE_LIMIT = 12;
|
||||
|
||||
/**
|
||||
* Shared latest-request-wins scheduler for recorded LAB spatial evidence.
|
||||
*
|
||||
* A feature supplies only the sealed session identity and host-clock time.
|
||||
* Cache ownership, identity fencing and stale-response suppression remain in
|
||||
* the canonical instrument instead of being reimplemented per experiment.
|
||||
*/
|
||||
export function useCanonicalRecordedLabSpatialFrame({
|
||||
sessionId,
|
||||
generationSha256,
|
||||
targetTimeNs,
|
||||
}: {
|
||||
sessionId: string;
|
||||
generationSha256: string | null;
|
||||
targetTimeNs: number;
|
||||
}) {
|
||||
const [frame, setFrame] = useState<CanonicalRecordedLabSpatialFrame | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const desiredRef = useRef<number | null>(null);
|
||||
const runningRef = useRef(false);
|
||||
const mountedRef = useRef(true);
|
||||
const identityRef = useRef("");
|
||||
const cacheRef = useRef(new Map<number, CanonicalRecordedLabSpatialFrame>());
|
||||
const pumpRef = useRef<() => void>(() => undefined);
|
||||
const identity = `${sessionId}:${generationSha256 ?? "unavailable"}`;
|
||||
identityRef.current = identity;
|
||||
|
||||
pumpRef.current = () => {
|
||||
if (runningRef.current || desiredRef.current === null || !generationSha256) return;
|
||||
runningRef.current = true;
|
||||
const requestIdentity = identity;
|
||||
let settledTimeNs: number | null = null;
|
||||
void (async () => {
|
||||
while (
|
||||
mountedRef.current
|
||||
&& identityRef.current === requestIdentity
|
||||
&& desiredRef.current !== null
|
||||
) {
|
||||
const requestedTimeNs = desiredRef.current;
|
||||
const cached = cacheRef.current.get(requestedTimeNs);
|
||||
try {
|
||||
const next = cached ?? await fetchCanonicalRecordedLabSpatialFrame(
|
||||
sessionId,
|
||||
generationSha256,
|
||||
requestedTimeNs,
|
||||
);
|
||||
if (!mountedRef.current || identityRef.current !== requestIdentity) break;
|
||||
if (!cached) {
|
||||
cacheRef.current.set(requestedTimeNs, next);
|
||||
while (cacheRef.current.size > FRAME_CACHE_LIMIT) {
|
||||
const oldest = cacheRef.current.keys().next().value as number | undefined;
|
||||
if (oldest === undefined) break;
|
||||
cacheRef.current.delete(oldest);
|
||||
}
|
||||
}
|
||||
setFrame(next);
|
||||
setError(null);
|
||||
} catch (caught: unknown) {
|
||||
if (!mountedRef.current || identityRef.current !== requestIdentity) break;
|
||||
setError(caught instanceof Error
|
||||
? caught.message
|
||||
: "Spatial-слои записанной LAB недоступны.");
|
||||
}
|
||||
settledTimeNs = requestedTimeNs;
|
||||
if (desiredRef.current === requestedTimeNs) break;
|
||||
}
|
||||
})().finally(() => {
|
||||
runningRef.current = false;
|
||||
if (
|
||||
mountedRef.current
|
||||
&& desiredRef.current !== null
|
||||
&& (identityRef.current !== requestIdentity || desiredRef.current !== settledTimeNs)
|
||||
) {
|
||||
pumpRef.current();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
desiredRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
cacheRef.current.clear();
|
||||
desiredRef.current = null;
|
||||
setFrame(null);
|
||||
setError(null);
|
||||
}, [identity]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!generationSha256) return;
|
||||
desiredRef.current = targetTimeNs;
|
||||
const cached = cacheRef.current.get(targetTimeNs);
|
||||
if (cached) {
|
||||
setFrame(cached);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
pumpRef.current();
|
||||
}, [generationSha256, identity, targetTimeNs]);
|
||||
|
||||
return { frame, error, loading: Boolean(generationSha256) && !frame && !error };
|
||||
}
|
||||
Reference in New Issue
Block a user