cctv: live-video panel card — canvas painted from the active camera's video element, 16:9 wrap, gated against the still-image path

This commit is contained in:
Daniel Slay committed 2026-09-14 20:00:38 +00:00
1 parent e02492cfc2
commit 71aa889133
6 files changed
+64 -1

No files matched your search

+1
View File
@@ -83,6 +83,7 @@ export function createPresentation({
mountHeightM: camera.mountHeightM,
active: isActive,
feedType: camera.feedType,
isVideo: parts.model.isVideoFeedType(camera.feedType),
sourceKind:
health?.sourceKind ||
camera.sourceKind ||
+1
View File
@@ -2618,6 +2618,7 @@ export class StyleManager {
_cctvFocusBtn: this._cctvFocusBtn,
_cctvFrame: this._cctvFrame,
_cctvFrameWrap: this._cctvFrameWrap,
_cctvVideo: this._cctvVideo,
_cctvMeta: this._cctvMeta,
_cctvNearestBtn: this._cctvNearestBtn,
_cctvNextBtn: this._cctvNextBtn,
+42 -1
View File
@@ -164,7 +164,46 @@ export function _renderCctvState(state) {
}
}
if (this._cctvFrame) {
// Live video: for HLS cameras, paint the active camera's decoded <video>
// element onto a canvas each frame (one decoder, two surfaces — the plane
// reads the same element). The loop rides through hls.js pipeline resets
// (a null element or unchanged currentTime is a skipped frame, not an exit)
// and self-stops when the card hides or the feed goes away. The still-image
// path below is suppressed while a live feed is showing.
const liveIntent = enabled && !!activeCamera?.isVideo;
if (this._cctvVideo) {
this._cctvVideo.hidden = !liveIntent;
if (this._cctvFrame) this._cctvFrame.hidden = liveIntent;
this._cctvVideoActive = liveIntent;
if (liveIntent && !this._cctvVideoRaf) {
const canvas = this._cctvVideo;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
let lastTime = -1;
const paint = () => {
if (this.destroyed || !this._cctvVideoActive) {
this._cctvVideoRaf = 0;
return;
}
const v = this.cctv.getActiveVideoElement();
if (v && v.currentTime !== lastTime) {
lastTime = v.currentTime;
if (
canvas.width !== v.videoWidth ||
canvas.height !== v.videoHeight
) {
canvas.width = v.videoWidth;
canvas.height = v.videoHeight;
}
ctx.drawImage(v, 0, 0);
}
this._cctvVideoRaf = requestAnimationFrame(paint);
};
this._cctvVideoRaf = requestAnimationFrame(paint);
}
}
if (this._cctvFrame && !liveIntent) {
const nextSrc = enabled ? activeCamera?.frameUrl : null;
const nextCameraId = enabled ? activeCamera?.id || '' : '';
const cameraChanged = this._cctvFrame.dataset.cameraId !== nextCameraId;
@@ -183,6 +222,8 @@ export function _renderCctvState(state) {
if (!nextSrc) {
this._clearCctvFrame();
}
} else if (liveIntent) {
this._clearCctvFrame();
}
this._syncCctvSourceBadge(activeCamera, enabled);
+1
View File
@@ -142,6 +142,7 @@ export function readShellElements(document = globalThis.document) {
_cctvCalibResetBtn: document.getElementById('cctv-calib-reset-btn'),
_cctvFrame: document.getElementById('cctv-frame'),
_cctvFrameWrap: document.getElementById('cctv-frame-wrap'),
_cctvVideo: document.getElementById('cctv-video'),
_cctvSourceBadge: document.getElementById('cctv-source-badge'),
_cctvMeta: document.getElementById('cctv-meta'),
_cctvSummary: document.getElementById('cctv-summary'),
+18
View File
@@ -234,6 +234,8 @@
image inside is taller — and the wrap is overflow:hidden, which clipped
the bottom of the frame right back off. Never shrink the preview. */
flex-shrink: 0;
aspect-ratio: 16/9;
min-height: 0;
}
/* Lightweight loading shimmer while the active frame is fetching */
@@ -298,10 +300,26 @@
transition: opacity 180ms ease;
}
#cctv-video {
width: 100%;
height: 100%;
display: block;
object-fit: contain;
}
#cctv-frame.active {
opacity: 1;
}
#cctv-video:not([hidden]) {
opacity: 1;
}
#cctv-frame[hidden],
#cctv-video[hidden] {
display: none;
}
#cctv-meta {
font-family: var(--font-mono);
font-size: 9px;
+1
View File
@@ -23,6 +23,7 @@
</div>
<div id="cctv-frame-wrap" class="cctv-frame-wrap">
<img id="cctv-frame" alt="CCTV feed frame" />
<canvas id="cctv-video" class="cctv-frame" hidden></canvas>
</div>
<div id="cctv-source-badge" role="status" aria-live="polite">SOURCE · UNKNOWN</div>
<div id="cctv-meta">Enable CCTV to load camera intersections</div>