Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,13 +290,13 @@ The frontend uses the following primary ParkTrack API groups:
- `/health`;
- `/version`.

Three modes are supported for `/cameras/{camera_id}/snapshot`:
The interface supports three camera snapshot modes:

- no query parameters: a fresh snapshot from the video stream;
- `last_detection=true`: the snapshot saved at the time of the last detection;
- `annotated=true&fallback_to_raw=true`: recognition visualization with an optional fallback to a raw snapshot.
- `Annotated`: the latest detection's `annotated_snapshot_url`, falling back to `raw_snapshot_url`;
- `Last detection`: the latest detection's `raw_snapshot_url`;
- `Latest snapshot`: a fresh frame from the video stream.

Raw snapshots and last detection snapshots require `cameras.view`. For recognition visualization, the backend additionally checks `admin.monitoring.view`.
Stored artifacts are loaded through `/admin/analytics/cameras/{camera_id}/detections?limit=1` and require `analytics.view`. The live frame is requested through `/admin/cameras/{camera_id}/snapshot` and requires `admin.monitoring.view`. Each mode is cached independently until the page is refreshed, while refresh invalidates only the active variant.

Primary analytics endpoints:

Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -290,13 +290,13 @@ Production-образ собирает приложение на Node.js и от
- `/health`;
- `/version`.

Для `/cameras/{camera_id}/snapshot` поддерживаются три режима:
Интерфейс поддерживает три режима снимков камеры:

- без query-параметровсвежий кадр из видеопотока;
- `last_detection=true` — кадр, сохранённый в момент последней детекции;
- `annotated=true&fallback_to_raw=true` — визуализация распознавания с возможным fallback на обычный кадр.
- `С разметкой``annotated_snapshot_url` последней детекции с fallback на `raw_snapshot_url`;
- `Последнее распознавание` — `raw_snapshot_url` последней детекции;
- `Последний снимок` — свежий кадр из видеопотока.

Обычный кадр и кадр последней детекции требуют `cameras.view`. Для визуализации распознавания backend дополнительно проверяет `admin.monitoring.view`.
Сохранённые артефакты загружаются через `/admin/analytics/cameras/{camera_id}/detections?limit=1` и требуют `analytics.view`. Свежий кадр запрашивается через `/admin/cameras/{camera_id}/snapshot` и требует `admin.monitoring.view`. Каждый режим кешируется отдельно до обновления страницы, а кнопка обновления сбрасывает только активный вариант.

Основные analytics endpoints:

Expand Down
75 changes: 55 additions & 20 deletions src/api/cameras.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { buildQuery, request, requestBlob } from './http';
import { ApiRequestError, buildQuery, request, requestBlob } from './http';

export type Camera = {
camera_id: number;
Expand Down Expand Up @@ -80,24 +80,55 @@ export type CameraSnapshot = {
captured_at?: string;
width?: number;
height?: number;
variant?: 'live' | 'raw' | 'annotated';
detection_run_id?: number | string;
};

export type CameraSnapshotOptions = {
annotated?: boolean;
last_detection?: boolean;
fallback_to_raw?: boolean;
export type CameraSnapshotMode = 'latest' | 'detection' | 'annotated';

type CameraDetectionSnapshot = {
detection_run_id: number | string;
started_at?: string | null;
finished_at?: string | null;
raw_snapshot_url?: string | null;
annotated_snapshot_url?: string | null;
};

export type CameraSnapshotMode = 'latest' | 'detection' | 'annotated';
type CameraDetectionSnapshotList = {
items: CameraDetectionSnapshot[];
};

export function cameraSnapshotOptions(mode: CameraSnapshotMode): CameraSnapshotOptions | undefined {
if (mode === 'detection') {
return { last_detection: true };
async function getStoredCameraSnapshot(
cameraId: number,
mode: Extract<CameraSnapshotMode, 'detection' | 'annotated'>
): Promise<CameraSnapshot> {
const response = await request<CameraDetectionSnapshotList>(
'GET',
`/admin/analytics/cameras/${encodeURIComponent(cameraId)}/detections?limit=1`
);
const detection = response.items[0];

if (!detection) {
throw new ApiRequestError('Для камеры пока нет сохранённых распознаваний.', 404);
}
if (mode === 'annotated') {
return { annotated: true, fallback_to_raw: true };

const annotatedUrl = detection.annotated_snapshot_url?.trim();
const rawUrl = detection.raw_snapshot_url?.trim();
const imageUrl = mode === 'annotated' ? annotatedUrl || rawUrl : rawUrl;

if (!imageUrl) {
const message = mode === 'annotated'
? 'Для последнего распознавания нет размеченного или исходного снимка.'
: 'Для последнего распознавания нет исходного снимка.';
throw new ApiRequestError(message, 404);
}
return undefined;

return {
image_url: imageUrl,
captured_at: detection.started_at || detection.finished_at || undefined,
variant: mode === 'annotated' && annotatedUrl ? 'annotated' : 'raw',
detection_run_id: detection.detection_run_id
};
}

function formatBBox(bbox?: CameraBBox | string) {
Expand Down Expand Up @@ -150,16 +181,20 @@ export const camerasApi = {
return request<CamerasNextResponse>('GET', '/cameras/next');
},

async getSnapshot(cameraId: number, options?: CameraSnapshotOptions): Promise<CameraSnapshot> {
const query = buildQuery({
annotated: options?.annotated,
last_detection: options?.last_detection,
fallback_to_raw: options?.fallback_to_raw
});
const { blob, headers } = await requestBlob(`/cameras/${encodeURIComponent(cameraId)}/snapshot${query}`);
async getSnapshot(cameraId: number, mode: CameraSnapshotMode = 'latest'): Promise<CameraSnapshot> {
if (mode !== 'latest') {
return getStoredCameraSnapshot(cameraId, mode);
}

const { blob, headers } = await requestBlob(
`/admin/cameras/${encodeURIComponent(cameraId)}/snapshot`
);
return {
image_url: URL.createObjectURL(blob),
captured_at: headers.get('X-Captured-At') || undefined
captured_at: headers.get('X-Snapshot-Captured-At')
|| headers.get('X-Captured-At')
|| undefined,
variant: 'live'
};
}
};
6 changes: 3 additions & 3 deletions src/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ export type {
CameraBBox,
CameraListFilters,
CameraMapItem,
CameraSnapshotOptions,
CameraSnapshot,
CameraSnapshotMode,
CamerasNextResponse,
CameraView,
CreateCameraRequest,
Expand Down Expand Up @@ -522,8 +522,8 @@ export const api = {
return camerasApi.getNext();
},

async getSnapshot(cameraId: number, options?: import('./cameras').CameraSnapshotOptions) {
return camerasApi.getSnapshot(cameraId, options);
async getSnapshot(cameraId: number, mode?: import('./cameras').CameraSnapshotMode) {
return camerasApi.getSnapshot(cameraId, mode);
},

// --- System ---
Expand Down
98 changes: 98 additions & 0 deletions src/components/CameraSnapshotModeSelector.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import type { CameraSnapshotMode } from '@/api/cameras';

export const CAMERA_SNAPSHOT_MODE_CONTENT: Record<
CameraSnapshotMode,
{ label: string; title: string; description: string }
> = {
annotated: {
label: 'С разметкой',
title: 'С разметкой',
description: 'Последняя визуализация распознавания'
},
detection: {
label: 'Последнее распознавание',
title: 'Последнее распознавание',
description: 'Кадр, сохранённый в момент последней детекции'
},
latest: {
label: 'Последний снимок',
title: 'Последний снимок',
description: 'Свежий кадр из видеопотока'
}
};

const CAMERA_SNAPSHOT_MODE_ORDER: CameraSnapshotMode[] = [
'annotated',
'detection',
'latest'
];

export type CameraSnapshotAccess = {
canViewStoredSnapshots: boolean;
canViewLiveSnapshot: boolean;
};

export function canViewCameraSnapshotMode(
mode: CameraSnapshotMode,
access: CameraSnapshotAccess
) {
return mode === 'latest'
? access.canViewLiveSnapshot
: access.canViewStoredSnapshots;
}

export function availableCameraSnapshotModes(access: CameraSnapshotAccess) {
return CAMERA_SNAPSHOT_MODE_ORDER.filter(mode => canViewCameraSnapshotMode(mode, access));
}

export function defaultCameraSnapshotMode(access: CameraSnapshotAccess): CameraSnapshotMode {
return availableCameraSnapshotModes(access)[0] ?? 'latest';
}

type CameraSnapshotModeSelectorProps = {
value: CameraSnapshotMode;
canViewStoredSnapshots: boolean;
canViewLiveSnapshot: boolean;
onChange: (mode: CameraSnapshotMode) => void;
ariaLabel?: string;
};

export function CameraSnapshotModeSelector({
value,
canViewStoredSnapshots,
canViewLiveSnapshot,
onChange,
ariaLabel = 'Режим просмотра кадра'
}: CameraSnapshotModeSelectorProps) {
const modes = availableCameraSnapshotModes({
canViewStoredSnapshots,
canViewLiveSnapshot
});

if (modes.length === 0) return null;

return (
<div
className={`snapshot-mode-toggle ${modes.length === 3 ? 'three-options' : ''}`}
role="tablist"
aria-label={ariaLabel}
>
{modes.map(mode => {
const content = CAMERA_SNAPSHOT_MODE_CONTENT[mode];
return (
<button
key={mode}
type="button"
role="tab"
aria-selected={value === mode}
className={`snapshot-mode-option ${value === mode ? 'active' : ''}`}
title={content.label}
onClick={() => onChange(mode)}
>
{content.label}
</button>
);
})}
</div>
);
}
Loading
Loading