feat(k1): add live cameras and reliable spatial following

This commit is contained in:
DCCONSTRUCTIONS
2026-07-17 00:26:03 +03:00
parent a281faf923
commit 2bda1986bd
33 changed files with 2927 additions and 330 deletions
@@ -328,6 +328,7 @@ function SpatialWorkspace({
{sourceUrl.trim() && pointCloudVisible ? (
<RerunViewport
sourceUrl={sourceUrl}
followLive={state?.sourceMode === "live"}
onStatusChange={onStatusChange}
onSelectionChange={onSelectionChange}
/>
@@ -349,6 +350,7 @@ function SpatialWorkspace({
<ObservationSourcePicker
sources={observationSources}
visibleSourceIds={observationLayout.visibleSourceIds}
pendingSourceIds={observationLayout.pendingSourceIds}
onToggle={observationLayout.toggleSource}
/>
{pointCloudSource?.capabilities.fullscreen && pointCloudVisible && sourceUrl.trim() ? (
@@ -434,7 +436,7 @@ function SpatialWorkspace({
onMaximizedChange={(maximized) =>
observationLayout.setFloatingMaximized(source.id, maximized)}
onActivate={() => observationLayout.activateFloatingSource(source.id)}
onClose={() => observationLayout.hideSource(source.id)}
onClose={() => void observationLayout.hideSource(source.id)}
/>
)) : null}
</div>
@@ -454,12 +456,41 @@ function SpatialWorkspace({
function CameraSourceCard({
source,
focused,
visible,
pending,
selectedPeer,
onToggle,
onFocus,
}: {
source: ObservationSourceDescriptor;
focused: boolean;
visible: boolean;
pending: boolean;
selectedPeer: boolean;
onToggle: () => void;
onFocus: () => void;
}) {
const deliveryActive = Boolean(
(source.delivery || source.previewUrl) &&
(!source.activation || source.activation.selected),
);
const restartAvailable = Boolean(
source.activation?.selected &&
source.activation.controllable &&
(source.availability === "error" || !source.delivery),
);
const activationLabel = pending
? "Переключение…"
: restartAvailable
? "Повторить подключение"
: source.activation?.selected && visible
? "Закрыть канал"
: source.activation?.selected
? "Показать канал"
: selectedPeer
? "Переключить"
: "Открыть канал";
return (
<article className="camera-slot" data-focused={focused ? "true" : undefined}>
<header>
@@ -469,7 +500,17 @@ function CameraSourceCard({
<i data-availability={source.availability} aria-hidden="true" />
{observationSourceStatusLabel(source)}
</span>
{source.capabilities.fullscreen ? (
{source.activation ? (
<button
type="button"
className="camera-slot__activation"
disabled={pending || (!source.activation.selected && !source.activation.controllable)}
onClick={onToggle}
>
{activationLabel}
</button>
) : null}
{source.capabilities.fullscreen && deliveryActive ? (
<button
type="button"
aria-label={focused ? `Свернуть ${source.label}` : `Развернуть ${source.label}`}
@@ -510,6 +551,15 @@ function CamerasWorkspace({ definition, state, observationLayout }: WorkspaceRen
key={source.id}
source={source}
focused={focusedSource?.id === source.id}
visible={observationLayout.visibleSourceIds.has(source.id)}
pending={observationLayout.pendingSourceIds.has(source.id)}
selectedPeer={Boolean(
source.activation && sources.some((candidate) =>
candidate.id !== source.id &&
candidate.activation?.groupId === source.activation?.groupId &&
candidate.activation?.selected === true),
)}
onToggle={() => void observationLayout.toggleSource(source.id)}
onFocus={() => observationLayout.setFocusedSourceId(
focusedSource?.id === source.id ? null : source.id,
)}
@@ -523,8 +573,11 @@ function CamerasWorkspace({ definition, state, observationLayout }: WorkspaceRen
)}
</div>
<ObservationTimeline
active={sources.some((source) => source.availability === "streaming")}
sourceCount={sources.length}
active={sources.some((source) =>
source.availability === "streaming" &&
(!source.activation || source.activation.selected))}
sourceCount={sources.filter((source) =>
!source.activation || source.activation.selected).length}
mode={timeline?.mode}
seekable={timeline?.seekable}
synchronization={timeline?.synchronization}