feat(lab): present chronological perception evidence

This commit is contained in:
DCCONSTRUCTIONS
2026-08-05 07:49:49 +03:00
parent 12c8a2d74c
commit 7a72a206f1
102 changed files with 20122 additions and 222 deletions
@@ -234,21 +234,27 @@ async function mountRecordedEpochStream(
export function RecordedFmp4Player({
source,
playback,
interactive = false,
prepare = true,
sessionGate = "ready",
admissionKey = null,
onAdmissionChange,
onPlaybackChange,
}: {
source: ObservationSourceDescriptor;
playback?: RecordedObservationPlayback | null;
interactive?: boolean;
prepare?: boolean;
sessionGate?: RecordedAdmissionPhase;
admissionKey?: string | null;
onAdmissionChange?: (state: RecordedCameraAdmissionState) => void;
onPlaybackChange?: (playback: RecordedObservationPlayback) => void;
}) {
const videoRef = useRef<HTMLVideoElement>(null);
const onAdmissionChangeRef = useRef(onAdmissionChange);
onAdmissionChangeRef.current = onAdmissionChange;
const onPlaybackChangeRef = useRef(onPlaybackChange);
onPlaybackChangeRef.current = onPlaybackChange;
const workerRef = useRef<{ admissionKey: string | null; generation: number } | null>(null);
if (!workerRef.current || workerRef.current.admissionKey !== admissionKey) {
recordedMediaWorkerGeneration += 1;
@@ -461,15 +467,49 @@ export function RecordedFmp4Player({
}
}, [archive?.byteLength, bufferRevision, currentSeconds, epoch, playback?.playing, visualState]);
useEffect(() => {
const video = videoRef.current;
if (!interactive || !video || !epoch || visualState !== "ready") return;
let videoFrameRequest: number | null = null;
const emitPlayback = () => {
onPlaybackChangeRef.current?.({
currentSeconds: epoch.timelineStartSeconds + video.currentTime,
playing: !video.paused && !video.ended,
});
};
const scheduleVideoFrame = () => {
if (typeof video.requestVideoFrameCallback !== "function") return;
videoFrameRequest = video.requestVideoFrameCallback(() => {
emitPlayback();
scheduleVideoFrame();
});
};
const events = ["play", "pause", "seeking", "seeked", "timeupdate", "ended"] as const;
for (const event of events) video.addEventListener(event, emitPlayback);
scheduleVideoFrame();
emitPlayback();
return () => {
for (const event of events) video.removeEventListener(event, emitPlayback);
if (
videoFrameRequest !== null &&
typeof video.cancelVideoFrameCallback === "function"
) {
video.cancelVideoFrameCallback(videoFrameRequest);
}
};
}, [epoch, interactive, visualState]);
return (
<div
className="recorded-media-player"
data-state={visualState}
data-interactive={interactive ? "true" : undefined}
aria-busy={visualState === "loading"}
>
<video
ref={videoRef}
className="observation-media__asset"
controls={interactive}
muted
playsInline
preload="auto"
@@ -15,7 +15,10 @@ export interface LaboratoryEvidenceViewerMode<T extends string> {
label: string;
}
export function LaboratoryEvidenceViewer<T extends string>({
export function LaboratoryEvidenceViewer<
T extends string,
U extends string = string,
>({
label,
mode,
modes,
@@ -23,6 +26,7 @@ export function LaboratoryEvidenceViewer<T extends string>({
onModeChange,
onExpandedChange,
actions,
secondaryMode,
overlay,
children,
}: {
@@ -33,6 +37,12 @@ export function LaboratoryEvidenceViewer<T extends string>({
onModeChange: (mode: T) => void;
onExpandedChange: (expanded: boolean) => void;
actions?: ReactNode;
secondaryMode?: {
value: U;
modes: readonly LaboratoryEvidenceViewerMode<U>[];
label: string;
onChange: (mode: U) => void;
};
overlay?: ReactNode;
children: ReactNode;
}) {
@@ -62,6 +72,14 @@ export function LaboratoryEvidenceViewer<T extends string>({
{overlay}
<div className="laboratory-evidence-viewer__controls">
{actions}
{secondaryMode ? (
<SegmentedControl
value={secondaryMode.value}
items={[...secondaryMode.modes]}
label={secondaryMode.label}
onChange={secondaryMode.onChange}
/>
) : null}
<SegmentedControl
value={mode}
items={[...modes]}
@@ -75,6 +75,7 @@ export function LaboratorySelector<T extends string>({
value,
options,
disabled = false,
searchable = false,
onChange,
}: {
eyebrow: string;
@@ -84,6 +85,7 @@ export function LaboratorySelector<T extends string>({
value: T;
options: readonly LaboratoryOption<T>[];
disabled?: boolean;
searchable?: boolean;
onChange: (value: T) => void;
}) {
return (
@@ -105,6 +107,8 @@ export function LaboratorySelector<T extends string>({
variant="split"
menuWidth="anchor"
disabled={disabled}
searchable={searchable}
searchPlaceholder={`Найти: ${label.toLocaleLowerCase("ru-RU")}`}
onChange={(next) => onChange(next)}
/>
</div>
@@ -0,0 +1,26 @@
import { useState, type ReactNode } from "react";
import { Button, Icon } from "@nodedc/ui-react";
import type { LaboratoryAnnotationAction } from "../../workspaces/contracts";
export function useLaboratoryAnnotationHeader(): {
control: ReactNode;
setAction: (action: LaboratoryAnnotationAction | null) => void;
} {
const [action, setAction] = useState<LaboratoryAnnotationAction | null>(null);
return {
setAction,
control: action ? (
<Button
size="compact"
shape="pill"
variant="accent"
icon={<Icon name="edit" size={16} />}
disabled={action.disabled}
onClick={action.onClick}
>
{action.label}
</Button>
) : null,
};
}