fix(m4): retain compact obstacle evidence
This commit is contained in:
@@ -20,6 +20,7 @@ export function LaboratoryEvidenceViewer<
|
||||
U extends string = string,
|
||||
>({
|
||||
label,
|
||||
className,
|
||||
mode,
|
||||
modes,
|
||||
expanded,
|
||||
@@ -31,6 +32,7 @@ export function LaboratoryEvidenceViewer<
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
className?: string;
|
||||
mode: T;
|
||||
modes: readonly LaboratoryEvidenceViewerMode<T>[];
|
||||
expanded: boolean;
|
||||
@@ -62,7 +64,10 @@ export function LaboratoryEvidenceViewer<
|
||||
|
||||
const viewer = (
|
||||
<section
|
||||
className="laboratory-evidence-viewer"
|
||||
className={[
|
||||
"laboratory-evidence-viewer",
|
||||
className,
|
||||
].filter(Boolean).join(" ")}
|
||||
data-expanded={expanded ? "true" : undefined}
|
||||
aria-label={label}
|
||||
>
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
export type RecordedEvidenceBoxTone =
|
||||
| "accent"
|
||||
| "danger"
|
||||
| "success"
|
||||
| "warning"
|
||||
| "neutral";
|
||||
|
||||
export interface RecordedEvidenceBox {
|
||||
boxXyxy: readonly [number, number, number, number];
|
||||
label: string;
|
||||
tone: RecordedEvidenceBoxTone;
|
||||
dashed?: boolean;
|
||||
}
|
||||
|
||||
function rgba(
|
||||
host: HTMLElement,
|
||||
token: string,
|
||||
fallback: readonly [number, number, number],
|
||||
alpha = 1,
|
||||
): string {
|
||||
const channels = getComputedStyle(host)
|
||||
.getPropertyValue(token)
|
||||
.trim()
|
||||
.match(/[\d.]+/g)
|
||||
?.slice(0, 3)
|
||||
.map(Number);
|
||||
const [red, green, blue] = channels?.length === 3 ? channels : fallback;
|
||||
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
|
||||
}
|
||||
|
||||
function toneColor(host: HTMLElement, tone: RecordedEvidenceBoxTone): string {
|
||||
if (tone === "danger") return rgba(host, "--nodedc-danger-rgb", [255, 104, 112]);
|
||||
if (tone === "success") return rgba(host, "--nodedc-success-rgb", [181, 255, 90]);
|
||||
if (tone === "warning") return rgba(host, "--nodedc-warning-rgb", [255, 197, 92]);
|
||||
if (tone === "neutral") return rgba(host, "--nodedc-foreground-rgb", [245, 245, 245], 0.7);
|
||||
return rgba(host, "--nodedc-accent-rgb", [232, 56, 126]);
|
||||
}
|
||||
|
||||
export function RecordedEvidenceBoxOverlay({
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
boxes,
|
||||
ariaLabel,
|
||||
}: {
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
boxes: readonly RecordedEvidenceBox[];
|
||||
ariaLabel: string;
|
||||
}) {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
const host = canvas?.parentElement;
|
||||
if (!host || !canvas) return;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return;
|
||||
|
||||
const render = () => {
|
||||
const width = Math.max(host.clientWidth, 1);
|
||||
const height = Math.max(host.clientHeight, 1);
|
||||
const pixelRatio = Math.min(window.devicePixelRatio, 1.5);
|
||||
canvas.width = Math.round(width * pixelRatio);
|
||||
canvas.height = Math.round(height * pixelRatio);
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||
context.clearRect(0, 0, width, height);
|
||||
|
||||
const scale = Math.min(width / imageWidth, height / imageHeight);
|
||||
const drawWidth = imageWidth * scale;
|
||||
const drawHeight = imageHeight * scale;
|
||||
const offsetX = (width - drawWidth) / 2;
|
||||
const offsetY = (height - drawHeight) / 2;
|
||||
for (const item of boxes) {
|
||||
const [left, top, right, bottom] = item.boxXyxy;
|
||||
const x = offsetX + left * scale;
|
||||
const y = offsetY + top * scale;
|
||||
const boxWidth = (right - left) * scale;
|
||||
const boxHeight = (bottom - top) * scale;
|
||||
const stroke = toneColor(host, item.tone);
|
||||
context.strokeStyle = stroke;
|
||||
context.lineWidth = Math.max(1.5, 2 * scale);
|
||||
context.setLineDash(item.dashed ? [5, 4] : []);
|
||||
context.strokeRect(x, y, boxWidth, boxHeight);
|
||||
context.setLineDash([]);
|
||||
|
||||
const fontSize = Math.max(9, 10 * scale);
|
||||
context.font = `650 ${fontSize}px Inter, system-ui, sans-serif`;
|
||||
const labelWidth = Math.min(drawWidth, context.measureText(item.label).width + 8);
|
||||
const labelHeight = fontSize + 6;
|
||||
const labelX = Math.min(offsetX + drawWidth - labelWidth, Math.max(offsetX, x));
|
||||
const labelY = Math.max(offsetY, y - labelHeight);
|
||||
context.fillStyle = rgba(host, "--nodedc-canvas-rgb", [5, 5, 6], 0.9);
|
||||
context.fillRect(labelX, labelY, labelWidth, labelHeight);
|
||||
context.fillStyle = stroke;
|
||||
context.fillText(item.label, labelX + 4, labelY + fontSize + 1, labelWidth - 8);
|
||||
}
|
||||
};
|
||||
|
||||
const observer = new ResizeObserver(render);
|
||||
observer.observe(host);
|
||||
render();
|
||||
return () => observer.disconnect();
|
||||
}, [boxes, imageHeight, imageWidth]);
|
||||
|
||||
return <canvas ref={canvasRef} role="img" aria-label={ariaLabel} />;
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Icon } from "@nodedc/ui-react";
|
||||
|
||||
import {
|
||||
RecordedEvidenceBoxOverlay,
|
||||
type RecordedEvidenceBox,
|
||||
} from "./RecordedEvidenceBoxOverlay";
|
||||
|
||||
export function RecordedEvidenceImageScene({
|
||||
src,
|
||||
imageWidth,
|
||||
imageHeight,
|
||||
boxes,
|
||||
ariaLabel,
|
||||
}: {
|
||||
src: string;
|
||||
imageWidth: number;
|
||||
imageHeight: number;
|
||||
boxes: readonly RecordedEvidenceBox[];
|
||||
ariaLabel: string;
|
||||
}) {
|
||||
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
|
||||
|
||||
useEffect(() => setState("loading"), [src]);
|
||||
|
||||
return (
|
||||
<div className="recorded-evidence-image-scene">
|
||||
<img
|
||||
src={src}
|
||||
alt=""
|
||||
draggable={false}
|
||||
onLoad={() => setState("ready")}
|
||||
onError={() => setState("error")}
|
||||
/>
|
||||
{state === "ready" ? (
|
||||
<RecordedEvidenceBoxOverlay
|
||||
imageWidth={imageWidth}
|
||||
imageHeight={imageHeight}
|
||||
boxes={boxes}
|
||||
ariaLabel={ariaLabel}
|
||||
/>
|
||||
) : (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
{state === "loading" ? (
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
) : (
|
||||
<Icon name="alert" size={18} />
|
||||
)}
|
||||
<span>
|
||||
{state === "loading"
|
||||
? "Декодируем один точный CAMERA-кадр"
|
||||
: "Точный CAMERA-кадр недоступен."}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,48 +1,15 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
|
||||
import {
|
||||
RecordedFmp4Player,
|
||||
type RecordedObservationPlayback,
|
||||
} from "../RecordedFmp4Player";
|
||||
import type { ObservationSourceDescriptor } from "../../core/runtime/contracts";
|
||||
import {
|
||||
RecordedEvidenceBoxOverlay,
|
||||
type RecordedEvidenceBox,
|
||||
type RecordedEvidenceBoxTone,
|
||||
} from "./RecordedEvidenceBoxOverlay";
|
||||
|
||||
export type RecordedEvidenceBoxTone =
|
||||
| "accent"
|
||||
| "danger"
|
||||
| "success"
|
||||
| "warning"
|
||||
| "neutral";
|
||||
|
||||
export interface RecordedEvidenceBox {
|
||||
boxXyxy: readonly [number, number, number, number];
|
||||
label: string;
|
||||
tone: RecordedEvidenceBoxTone;
|
||||
dashed?: boolean;
|
||||
}
|
||||
|
||||
function rgba(
|
||||
host: HTMLElement,
|
||||
token: string,
|
||||
fallback: readonly [number, number, number],
|
||||
alpha = 1,
|
||||
): string {
|
||||
const channels = getComputedStyle(host)
|
||||
.getPropertyValue(token)
|
||||
.trim()
|
||||
.match(/[\d.]+/g)
|
||||
?.slice(0, 3)
|
||||
.map(Number);
|
||||
const [red, green, blue] = channels?.length === 3 ? channels : fallback;
|
||||
return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
|
||||
}
|
||||
|
||||
function toneColor(host: HTMLElement, tone: RecordedEvidenceBoxTone): string {
|
||||
if (tone === "danger") return rgba(host, "--nodedc-danger-rgb", [255, 104, 112]);
|
||||
if (tone === "success") return rgba(host, "--nodedc-success-rgb", [181, 255, 90]);
|
||||
if (tone === "warning") return rgba(host, "--nodedc-warning-rgb", [255, 197, 92]);
|
||||
if (tone === "neutral") return rgba(host, "--nodedc-foreground-rgb", [245, 245, 245], 0.7);
|
||||
return rgba(host, "--nodedc-accent-rgb", [232, 56, 126]);
|
||||
}
|
||||
export type { RecordedEvidenceBox, RecordedEvidenceBoxTone };
|
||||
|
||||
export function RecordedEvidenceVideoScene({
|
||||
source,
|
||||
@@ -61,66 +28,8 @@ export function RecordedEvidenceVideoScene({
|
||||
ariaLabel: string;
|
||||
onPlaybackChange: (playback: RecordedObservationPlayback) => void;
|
||||
}) {
|
||||
const hostRef = useRef<HTMLDivElement | null>(null);
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const host = hostRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (!host || !canvas) return;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return;
|
||||
|
||||
const render = () => {
|
||||
const width = Math.max(host.clientWidth, 1);
|
||||
const height = Math.max(host.clientHeight, 1);
|
||||
const pixelRatio = Math.min(window.devicePixelRatio, 1.5);
|
||||
canvas.width = Math.round(width * pixelRatio);
|
||||
canvas.height = Math.round(height * pixelRatio);
|
||||
canvas.style.width = `${width}px`;
|
||||
canvas.style.height = `${height}px`;
|
||||
context.setTransform(pixelRatio, 0, 0, pixelRatio, 0, 0);
|
||||
context.clearRect(0, 0, width, height);
|
||||
|
||||
const scale = Math.min(width / imageWidth, height / imageHeight);
|
||||
const drawWidth = imageWidth * scale;
|
||||
const drawHeight = imageHeight * scale;
|
||||
const offsetX = (width - drawWidth) / 2;
|
||||
const offsetY = (height - drawHeight) / 2;
|
||||
for (const item of boxes) {
|
||||
const [left, top, right, bottom] = item.boxXyxy;
|
||||
const x = offsetX + left * scale;
|
||||
const y = offsetY + top * scale;
|
||||
const boxWidth = (right - left) * scale;
|
||||
const boxHeight = (bottom - top) * scale;
|
||||
const stroke = toneColor(host, item.tone);
|
||||
context.strokeStyle = stroke;
|
||||
context.lineWidth = Math.max(1.5, 2 * scale);
|
||||
context.setLineDash(item.dashed ? [5, 4] : []);
|
||||
context.strokeRect(x, y, boxWidth, boxHeight);
|
||||
context.setLineDash([]);
|
||||
|
||||
const fontSize = Math.max(9, 10 * scale);
|
||||
context.font = `650 ${fontSize}px Inter, system-ui, sans-serif`;
|
||||
const labelWidth = Math.min(drawWidth, context.measureText(item.label).width + 8);
|
||||
const labelHeight = fontSize + 6;
|
||||
const labelX = Math.min(offsetX + drawWidth - labelWidth, Math.max(offsetX, x));
|
||||
const labelY = Math.max(offsetY, y - labelHeight);
|
||||
context.fillStyle = rgba(host, "--nodedc-canvas-rgb", [5, 5, 6], 0.9);
|
||||
context.fillRect(labelX, labelY, labelWidth, labelHeight);
|
||||
context.fillStyle = stroke;
|
||||
context.fillText(item.label, labelX + 4, labelY + fontSize + 1, labelWidth - 8);
|
||||
}
|
||||
};
|
||||
|
||||
const observer = new ResizeObserver(render);
|
||||
observer.observe(host);
|
||||
render();
|
||||
return () => observer.disconnect();
|
||||
}, [boxes, imageHeight, imageWidth]);
|
||||
|
||||
return (
|
||||
<div className="recorded-evidence-video-scene" ref={hostRef}>
|
||||
<div className="recorded-evidence-video-scene">
|
||||
<RecordedFmp4Player
|
||||
source={source}
|
||||
playback={playback}
|
||||
@@ -128,7 +37,12 @@ export function RecordedEvidenceVideoScene({
|
||||
prepare
|
||||
onPlaybackChange={onPlaybackChange}
|
||||
/>
|
||||
<canvas ref={canvasRef} role="img" aria-label={ariaLabel} />
|
||||
<RecordedEvidenceBoxOverlay
|
||||
imageWidth={imageWidth}
|
||||
imageHeight={imageHeight}
|
||||
boxes={boxes}
|
||||
ariaLabel={ariaLabel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ export interface M4ThreatCameraProposal {
|
||||
|
||||
export interface M4ThreatVisualFrame {
|
||||
resultId: string;
|
||||
cameraUrl: string;
|
||||
ordinal: number;
|
||||
sequence: number;
|
||||
frameId: string;
|
||||
@@ -408,6 +409,7 @@ export async function fetchM4ThreatVisual(
|
||||
const corridor = object(item.corridor, "M4.6 visual corridor");
|
||||
return {
|
||||
resultId: result,
|
||||
cameraUrl: text(item.camera_url, "M4.6 camera URL"),
|
||||
ordinal: integer(item.ordinal, "M4.6 ordinal"),
|
||||
sequence: integer(item.sequence, "M4.6 sequence"),
|
||||
frameId: text(item.frame_id, "M4.6 frame id"),
|
||||
|
||||
@@ -46,7 +46,8 @@
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.recorded-evidence-video-scene {
|
||||
.recorded-evidence-video-scene,
|
||||
.recorded-evidence-image-scene {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -55,12 +56,23 @@
|
||||
background: var(--nodedc-canvas);
|
||||
}
|
||||
|
||||
.recorded-evidence-image-scene > img {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.recorded-evidence-video-scene > .recorded-media-player {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.recorded-evidence-video-scene > canvas {
|
||||
.recorded-evidence-video-scene > canvas,
|
||||
.recorded-evidence-image-scene > canvas {
|
||||
position: absolute;
|
||||
z-index: 2;
|
||||
inset: 0;
|
||||
@@ -70,6 +82,18 @@
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.recorded-evidence-image-scene > .l3-visual-audit__state {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.m4-replay-threat-evidence-viewer .laboratory-metric-evidence-scene__toolbar {
|
||||
top: 5.4rem;
|
||||
max-width: calc(100% - 1.2rem);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.e46e-ready-stack-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -26,7 +26,7 @@ export function M4ReplayThreatResultView({
|
||||
<LaboratorySummary
|
||||
title="M4.6 · dual-evidence threat replay"
|
||||
description="Camera и LiDAR дают независимые доказательства, после чего один source-neutral слой оценивает пересечение виртуального коридора, ближайшее сближение и TTC. Ни один сенсор не назначен first."
|
||||
status="4489/4489 · replay-simulated · accepted"
|
||||
status="Replay contract passed · CV quality gate open"
|
||||
statusTone="warning"
|
||||
facts={[
|
||||
{
|
||||
@@ -53,7 +53,7 @@ export function M4ReplayThreatResultView({
|
||||
brief={{
|
||||
question: "Может ли единый слой обнаруживать потенциальное препятствие по двум независимым источникам, не теряя LiDAR-only объекты и не объявляя camera-only наблюдение безопасным?",
|
||||
approach: "Все 4489 кадров RAVNOVES00 повторно пропущены через неизменяемые detector, metric geometry и temporal ledgers. Текущий lio_pcl increment хранится отдельно от bounded rolling map: отсутствие повторной публикации точки не считается свободным пространством. Виртуальный base_footprint привязан к gravity-оси SLAM map и направлению сглаженной траектории, проверенному camera extrinsic.",
|
||||
principalResult: `${metrics.evidence.currentMetric.toLocaleString("ru-RU")} current metric, ${metrics.evidence.rollingMapRetained.toLocaleString("ru-RU")} rolling-map и ${metrics.evidence.cameraOnly.toLocaleString("ru-RU")} camera-only публикаций учтены. Кадр 1880 удерживает обе видимые бетонные полусферы; критические fixtures: ${metrics.fixtures.passed}/${metrics.fixtures.total}, ложных safe: ${metrics.fixtures.criticalFalseNotThreat}.`,
|
||||
principalResult: `${metrics.evidence.currentMetric.toLocaleString("ru-RU")} current metric, ${metrics.evidence.rollingMapRetained.toLocaleString("ru-RU")} rolling-map и ${metrics.evidence.cameraOnly.toLocaleString("ru-RU")} camera-only публикаций учтены. Кадры 1880 и 2584 закрепляют компактные бетонные полусферы как автоматические метрические регрессии; критические fixtures: ${metrics.fixtures.passed}/${metrics.fixtures.total}, ложных safe: ${metrics.fixtures.criticalFalseNotThreat}.`,
|
||||
limitation: "Корпус и коридор пока виртуальные, replay не является live-проходом или физическим collision test. На машине виртуальная привязка должна замениться измеренным rigid T_body_from_sensor; independent object truth остаётся следующим gate.",
|
||||
}}
|
||||
method={{
|
||||
@@ -105,8 +105,8 @@ export function M4ReplayThreatResultView({
|
||||
)}
|
||||
result={(
|
||||
<LaboratoryResultSummary
|
||||
title="Dual-evidence слой готов к следующей CV-итерации на recorded replay"
|
||||
status="Replay gate accepted · physical authority withheld"
|
||||
title="Replay-инфраструктура принята; object recall ещё проверяется"
|
||||
status="Жёлтый: pipeline целостен, независимый object-truth gate не пройден"
|
||||
statusTone="warning"
|
||||
metrics={[
|
||||
{
|
||||
@@ -131,7 +131,7 @@ export function M4ReplayThreatResultView({
|
||||
},
|
||||
]}
|
||||
conclusion={{
|
||||
proved: `На неизменяемом RAVNOVES00 каждый current, rolling-map, stale/held и camera-only объект получил ровно одну консервативную оценку. CURRENT INCREMENT и ROLLING MAP независимо включаются в viewer; кадр 1880 закреплён как регрессия двух бетонных полусфер. ${metrics.bodyFrame.qualified}/${metrics.bodyFrame.available} body frames квалифицированы без переноса handheld roll/pitch на SLAM-мир.`,
|
||||
proved: `На неизменяемом RAVNOVES00 каждый current, rolling-map, stale/held и camera-only объект получил ровно одну консервативную оценку. CURRENT INCREMENT и ROLLING MAP независимо включаются в viewer; кадры 1880 и 2584 закреплены как регрессии компактных бетонных полусфер. ${metrics.bodyFrame.qualified}/${metrics.bodyFrame.available} body frames квалифицированы без переноса handheld roll/pitch на SLAM-мир.`,
|
||||
notProved: "Не доказаны live realtime, измеренный T_body_from_sensor и геометрия физического корпуса, независимая object-level правильность, навигационная или safety-пригодность и выдача команд.",
|
||||
decision: "Сохранить dual-evidence provider как канонический replay seam и переходить к независимому object-centric gate; физическую геометрию и live/actuation authority не смешивать с дальнейшей CV-разработкой.",
|
||||
}}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type LaboratoryMetricSceneMode,
|
||||
} from "../../components/laboratory/LaboratoryMetricEvidenceScene";
|
||||
import { LaboratoryEvidenceViewer } from "../../components/laboratory/LaboratoryEvidenceViewer";
|
||||
import { RecordedEvidenceImageScene } from "../../components/laboratory/RecordedEvidenceImageScene";
|
||||
import {
|
||||
RecordedEvidenceVideoScene,
|
||||
type RecordedEvidenceBox,
|
||||
@@ -108,7 +109,7 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
|
||||
}, [ordinal, resultId]);
|
||||
|
||||
useEffect(() => {
|
||||
if ((mode !== "video" && mode !== "camera") || (videoOverlay && videoSource)) return;
|
||||
if (mode !== "video" || (videoOverlay && videoSource)) return;
|
||||
const controller = new AbortController();
|
||||
setVideoLoading(true);
|
||||
setVideoError(null);
|
||||
@@ -157,14 +158,6 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
|
||||
return () => controller.abort();
|
||||
}, [mode, resultId, videoOverlay, videoSource]);
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== "camera" || !frame) return;
|
||||
setVideoPlayback({
|
||||
currentSeconds: frame.sourceTimeNs / 1_000_000_000,
|
||||
playing: false,
|
||||
});
|
||||
}, [frame, mode]);
|
||||
|
||||
const activeVideoFrame = useMemo(
|
||||
() => videoOverlay
|
||||
? selectM4ThreatVideoFrame(videoOverlay.frames, videoPlayback.currentSeconds)
|
||||
@@ -317,7 +310,7 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
|
||||
) : undefined;
|
||||
|
||||
let content;
|
||||
if (mode === "video" || mode === "camera") {
|
||||
if (mode === "video") {
|
||||
content = videoLoading ? (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
@@ -335,14 +328,30 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
|
||||
imageWidth={videoOverlay.imageWidth}
|
||||
imageHeight={videoOverlay.imageHeight}
|
||||
boxes={activeBoxes}
|
||||
ariaLabel={
|
||||
mode === "camera"
|
||||
? `M4.6 exact camera sample ${ordinal}: ${activeBoxes.length} proposals`
|
||||
: `M4.6 full video frame ${activeVideoFrame?.frameIndex ?? 0}: ${activeBoxes.length} proposals`
|
||||
}
|
||||
ariaLabel={`M4.6 full video frame ${activeVideoFrame?.frameIndex ?? 0}: ${activeBoxes.length} proposals`}
|
||||
onPlaybackChange={setVideoPlayback}
|
||||
/>
|
||||
);
|
||||
} else if (mode === "camera") {
|
||||
content = sampleLoading ? (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
<span className="busy-indicator" aria-hidden="true" />
|
||||
<span>Открываем точный CAMERA-кадр M4.6</span>
|
||||
</div>
|
||||
) : sampleError || !frame ? (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
<Icon name="alert" size={18} />
|
||||
<span>{sampleError ?? "CAMERA-кадр M4.6 недоступен."}</span>
|
||||
</div>
|
||||
) : (
|
||||
<RecordedEvidenceImageScene
|
||||
src={frame.cameraUrl}
|
||||
imageWidth={800}
|
||||
imageHeight={600}
|
||||
boxes={activeBoxes}
|
||||
ariaLabel={`M4.6 exact camera sample ${ordinal}: ${activeBoxes.length} proposals`}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
content = sampleLoading ? (
|
||||
<div className="l3-visual-audit__state" role="status">
|
||||
@@ -376,6 +385,7 @@ export function M4ReplayThreatVisual({ resultId }: { resultId: string }) {
|
||||
<div className="l3-visual-audit m4-replay-threat-visual">
|
||||
<LaboratoryEvidenceViewer
|
||||
label="M4.6 dual-evidence replay: video, camera and metric 3D"
|
||||
className="m4-replay-threat-evidence-viewer"
|
||||
mode={mode}
|
||||
modes={[
|
||||
{ value: "video", label: "VIDEO" },
|
||||
|
||||
Reference in New Issue
Block a user