mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 19:04:02 +00:00
style(cctv): format live video integration
This commit is contained in:
1 parent
3571284782
commit
6f40fff09e
13 files changed
+564
-144
No files matched your search
+88
-21
@@ -132,7 +132,6 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
|||||||
const installMiddleware = (server) => {
|
const installMiddleware = (server) => {
|
||||||
server.httpServer?.on('close', () => {
|
server.httpServer?.on('close', () => {
|
||||||
puller.shutdown();
|
puller.shutdown();
|
||||||
|
|
||||||
});
|
});
|
||||||
server.middlewares.use('/api/cctv', async (req, res) => {
|
server.middlewares.use('/api/cctv', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -201,48 +200,112 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname.startsWith('/media/')) {
|
if (url.pathname.startsWith('/media/')) {
|
||||||
const match = /^\/media\/([^/]+)(?:\/(seg_(\d+)\.ts))?$/.exec(url.pathname);
|
const match = /^\/media\/([^/]+)(?:\/(seg_(\d+)\.ts))?$/.exec(
|
||||||
if (!match) { res.writeHead(404); res.end(); return; }
|
url.pathname,
|
||||||
|
);
|
||||||
|
if (!match) {
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const cameraId = decodeURIComponent(match[1]);
|
const cameraId = decodeURIComponent(match[1]);
|
||||||
const source = sourceById.get(cameraId);
|
const source = sourceById.get(cameraId);
|
||||||
const mediaUrl = source?.url || '';
|
const mediaUrl = source?.url || '';
|
||||||
const feedType = normalizeFeedType(source?.feedType || 'image');
|
const feedType = normalizeFeedType(source?.feedType || 'image');
|
||||||
const leaseId = url.searchParams.get('lease');
|
const leaseId = url.searchParams.get('lease');
|
||||||
if (feedType === 'hls' && !/^[a-f0-9-]{36}$/i.test(leaseId || '')) { res.writeHead(400); res.end(); return; }
|
if (feedType === 'hls' && !/^[a-f0-9-]{36}$/i.test(leaseId || '')) {
|
||||||
|
res.writeHead(400);
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (req.method === 'DELETE') {
|
if (req.method === 'DELETE') {
|
||||||
if (leaseId) puller.release(cameraId, leaseId);
|
if (leaseId) puller.release(cameraId, leaseId);
|
||||||
res.writeHead(204); res.end(); return;
|
res.writeHead(204);
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.method !== 'GET') {
|
||||||
|
res.writeHead(405);
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
if (req.method !== 'GET') { res.writeHead(405); res.end(); return; }
|
|
||||||
if (feedType === 'hls') {
|
if (feedType === 'hls') {
|
||||||
if (!/^https?:\/\//i.test(mediaUrl) || !/\.m3u8(?:\?|$)/i.test(mediaUrl)) {
|
if (
|
||||||
|
!/^https?:\/\//i.test(mediaUrl) ||
|
||||||
|
!/\.m3u8(?:\?|$)/i.test(mediaUrl)
|
||||||
|
) {
|
||||||
res.writeHead(503, { 'Content-Type': 'application/json' });
|
res.writeHead(503, { 'Content-Type': 'application/json' });
|
||||||
res.end(JSON.stringify({ error: 'This stream requires an unsupported transport; use the frame fallback' }));
|
res.end(
|
||||||
|
JSON.stringify({
|
||||||
|
error:
|
||||||
|
'This stream requires an unsupported transport; use the frame fallback',
|
||||||
|
}),
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (match[2]) {
|
if (match[2]) {
|
||||||
const body = puller.getSegment(cameraId, url.searchParams.get('session'), Number(match[3]), leaseId);
|
const body = puller.getSegment(
|
||||||
res.writeHead(body ? 200 : 404, { 'Content-Type': 'video/mp2t', 'Cache-Control': 'no-store' });
|
cameraId,
|
||||||
res.end(body || undefined); return;
|
url.searchParams.get('session'),
|
||||||
|
Number(match[3]),
|
||||||
|
leaseId,
|
||||||
|
);
|
||||||
|
res.writeHead(body ? 200 : 404, {
|
||||||
|
'Content-Type': 'video/mp2t',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
});
|
||||||
|
res.end(body || undefined);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
const downstream = watchDownstreamClose(res);
|
const downstream = watchDownstreamClose(res);
|
||||||
let entry;
|
let entry;
|
||||||
const cancelPending = () => { if (entry) puller.release(cameraId, leaseId); };
|
const cancelPending = () => {
|
||||||
|
if (entry) puller.release(cameraId, leaseId);
|
||||||
|
};
|
||||||
try {
|
try {
|
||||||
entry = await puller.ensure(cameraId, mediaUrl, leaseId);
|
entry = await puller.ensure(cameraId, mediaUrl, leaseId);
|
||||||
if (downstream.closed) { cancelPending(); return; }
|
if (downstream.closed) {
|
||||||
downstream.signal.addEventListener('abort', cancelPending, { once: true });
|
cancelPending();
|
||||||
if (!(await puller.waitReady(entry, downstream.signal))) throw new Error('Stream unavailable');
|
return;
|
||||||
const playlist = await puller.buildPlaylist(entry, cameraId, leaseId);
|
}
|
||||||
|
downstream.signal.addEventListener('abort', cancelPending, {
|
||||||
|
once: true,
|
||||||
|
});
|
||||||
|
if (!(await puller.waitReady(entry, downstream.signal)))
|
||||||
|
throw new Error('Stream unavailable');
|
||||||
|
const playlist = await puller.buildPlaylist(
|
||||||
|
entry,
|
||||||
|
cameraId,
|
||||||
|
leaseId,
|
||||||
|
);
|
||||||
if (downstream.closed) return;
|
if (downstream.closed) return;
|
||||||
if (!playlist) throw new Error('Stream unavailable');
|
if (!playlist) throw new Error('Stream unavailable');
|
||||||
setHealth(cameraId, { status: 'ok', sourceKind: 'live', label: source?.provider || 'Configured source', message: 'Live HLS connected' });
|
setHealth(cameraId, {
|
||||||
res.writeHead(200, { 'Content-Type': 'application/vnd.apple.mpegurl', 'Cache-Control': 'no-store', 'X-CCTV-Source': 'hls-pull', 'X-CCTV-Session': entry.token });
|
status: 'ok',
|
||||||
|
sourceKind: 'live',
|
||||||
|
label: source?.provider || 'Configured source',
|
||||||
|
message: 'Live HLS connected',
|
||||||
|
});
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'application/vnd.apple.mpegurl',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
'X-CCTV-Source': 'hls-pull',
|
||||||
|
'X-CCTV-Session': entry.token,
|
||||||
|
});
|
||||||
res.end(playlist);
|
res.end(playlist);
|
||||||
} catch {
|
} catch {
|
||||||
setHealth(cameraId, { status: 'degraded', sourceKind: 'fallback', label: source?.provider || 'Configured source', message: 'Live HLS unavailable' });
|
setHealth(cameraId, {
|
||||||
|
status: 'degraded',
|
||||||
|
sourceKind: 'fallback',
|
||||||
|
label: source?.provider || 'Configured source',
|
||||||
|
message: 'Live HLS unavailable',
|
||||||
|
});
|
||||||
if (!downstream.closed) {
|
if (!downstream.closed) {
|
||||||
res.writeHead(503, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store', 'Retry-After': '2' });
|
res.writeHead(503, {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
'Retry-After': '2',
|
||||||
|
});
|
||||||
res.end(JSON.stringify({ error: 'Live stream unavailable' }));
|
res.end(JSON.stringify({ error: 'Live stream unavailable' }));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -250,7 +313,11 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (match[2] || req.method !== 'GET') { res.writeHead(404); res.end(); return; }
|
if (match[2] || req.method !== 'GET') {
|
||||||
|
res.writeHead(404);
|
||||||
|
res.end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!mediaUrl || !/^https?:\/\//i.test(mediaUrl)) {
|
if (!mediaUrl || !/^https?:\/\//i.test(mediaUrl)) {
|
||||||
setHealth(cameraId, {
|
setHealth(cameraId, {
|
||||||
|
|||||||
@@ -1626,8 +1626,18 @@ export async function loadDelDOTSourcesFromOpenData() {
|
|||||||
|
|
||||||
// Prefer the catalog's HTTPS HLS transport; no subprocess is required.
|
// Prefer the catalog's HTTPS HLS transport; no subprocess is required.
|
||||||
let stream;
|
let stream;
|
||||||
try { stream = new URL(String(row?.urls?.m3u8s || '')); } catch { continue; }
|
try {
|
||||||
if (stream.origin !== 'https://video.deldot.gov' || stream.username || stream.password || !/^\/live\/[A-Za-z0-9_.-]+\/playlist\.m3u8$/.test(stream.pathname)) continue;
|
stream = new URL(String(row?.urls?.m3u8s || ''));
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
stream.origin !== 'https://video.deldot.gov' ||
|
||||||
|
stream.username ||
|
||||||
|
stream.password ||
|
||||||
|
!/^\/live\/[A-Za-z0-9_.-]+\/playlist\.m3u8$/.test(stream.pathname)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
const streamUrl = stream.href;
|
const streamUrl = stream.href;
|
||||||
|
|
||||||
const id = String(row?.id || '').trim();
|
const id = String(row?.id || '').trim();
|
||||||
|
|||||||
+150
-40
@@ -15,7 +15,15 @@ export const HLS_LIMITS = Object.freeze({
|
|||||||
});
|
});
|
||||||
|
|
||||||
/** Refuse redirects; count actual streamed bytes, including chunked responses. */
|
/** Refuse redirects; count actual streamed bytes, including chunked responses. */
|
||||||
export async function fetchHlsBytes(url, { fetchImpl = fetch, signal, maxBytes, timeoutMs = HLS_LIMITS.timeoutMs } = {}) {
|
export async function fetchHlsBytes(
|
||||||
|
url,
|
||||||
|
{
|
||||||
|
fetchImpl = fetch,
|
||||||
|
signal,
|
||||||
|
maxBytes,
|
||||||
|
timeoutMs = HLS_LIMITS.timeoutMs,
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
const abort = () => controller.abort();
|
const abort = () => controller.abort();
|
||||||
if (signal?.aborted) controller.abort();
|
if (signal?.aborted) controller.abort();
|
||||||
@@ -25,11 +33,19 @@ export async function fetchHlsBytes(url, { fetchImpl = fetch, signal, maxBytes,
|
|||||||
let response;
|
let response;
|
||||||
try {
|
try {
|
||||||
controller.signal.throwIfAborted();
|
controller.signal.throwIfAborted();
|
||||||
response = await fetchImpl(url, { redirect: 'error', signal: controller.signal,
|
response = await fetchImpl(url, {
|
||||||
headers: { 'User-Agent': 'gods-eye-view-cctv-proxy/1.0' } });
|
redirect: 'error',
|
||||||
if (!response.ok || response.redirected || (response.url && new URL(response.url).href !== new URL(url).href))
|
signal: controller.signal,
|
||||||
|
headers: { 'User-Agent': 'gods-eye-view-cctv-proxy/1.0' },
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
!response.ok ||
|
||||||
|
response.redirected ||
|
||||||
|
(response.url && new URL(response.url).href !== new URL(url).href)
|
||||||
|
)
|
||||||
throw new Error('HLS upstream refused');
|
throw new Error('HLS upstream refused');
|
||||||
if (Number(response.headers.get('content-length')) > maxBytes) throw new Error('HLS response too large');
|
if (Number(response.headers.get('content-length')) > maxBytes)
|
||||||
|
throw new Error('HLS response too large');
|
||||||
reader = response.body?.getReader?.();
|
reader = response.body?.getReader?.();
|
||||||
if (!reader) throw new Error('HLS response is not streamable');
|
if (!reader) throw new Error('HLS response is not streamable');
|
||||||
const chunks = [];
|
const chunks = [];
|
||||||
@@ -56,14 +72,22 @@ export async function fetchHlsBytes(url, { fetchImpl = fetch, signal, maxBytes,
|
|||||||
|
|
||||||
export function sameOriginHlsUrl(value, base) {
|
export function sameOriginHlsUrl(value, base) {
|
||||||
const url = new URL(value, base);
|
const url = new URL(value, base);
|
||||||
if (!['https:', 'http:'].includes(url.protocol) || url.origin !== new URL(base).origin || url.username || url.password)
|
if (
|
||||||
|
!['https:', 'http:'].includes(url.protocol) ||
|
||||||
|
url.origin !== new URL(base).origin ||
|
||||||
|
url.username ||
|
||||||
|
url.password
|
||||||
|
)
|
||||||
throw new Error('HLS reference escapes registered origin');
|
throw new Error('HLS reference escapes registered origin');
|
||||||
return url.href;
|
return url.href;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Only clear MPEG-TS media playlists: encrypted, fMP4 and byte ranges fail closed. */
|
/** Only clear MPEG-TS media playlists: encrypted, fMP4 and byte ranges fail closed. */
|
||||||
export function parseHlsMedia(text, base, limit = HLS_LIMITS.segments) {
|
export function parseHlsMedia(text, base, limit = HLS_LIMITS.segments) {
|
||||||
if (!text.startsWith('#EXTM3U') || /#EXT-X-(?:KEY|MAP|BYTERANGE|I-FRAMES-ONLY)/.test(text))
|
if (
|
||||||
|
!text.startsWith('#EXTM3U') ||
|
||||||
|
/#EXT-X-(?:KEY|MAP|BYTERANGE|I-FRAMES-ONLY)/.test(text)
|
||||||
|
)
|
||||||
throw new Error('Unsupported HLS playlist');
|
throw new Error('Unsupported HLS playlist');
|
||||||
let seq = 0;
|
let seq = 0;
|
||||||
let duration = null;
|
let duration = null;
|
||||||
@@ -73,16 +97,20 @@ export function parseHlsMedia(text, base, limit = HLS_LIMITS.segments) {
|
|||||||
const line = raw.trim();
|
const line = raw.trim();
|
||||||
if (line.startsWith('#EXT-X-MEDIA-SEQUENCE:')) {
|
if (line.startsWith('#EXT-X-MEDIA-SEQUENCE:')) {
|
||||||
seq = Number(line.slice(22));
|
seq = Number(line.slice(22));
|
||||||
if (!Number.isSafeInteger(seq) || seq < 0) throw new Error('Invalid media sequence');
|
if (!Number.isSafeInteger(seq) || seq < 0)
|
||||||
|
throw new Error('Invalid media sequence');
|
||||||
} else if (line === '#EXT-X-DISCONTINUITY') {
|
} else if (line === '#EXT-X-DISCONTINUITY') {
|
||||||
discontinuity = true;
|
discontinuity = true;
|
||||||
} else if (line.startsWith('#EXTINF:')) {
|
} else if (line.startsWith('#EXTINF:')) {
|
||||||
duration = Number.parseFloat(line.slice(8));
|
duration = Number.parseFloat(line.slice(8));
|
||||||
if (!Number.isFinite(duration) || duration <= 0 || duration > 60) throw new Error('Invalid segment duration');
|
if (!Number.isFinite(duration) || duration <= 0 || duration > 60)
|
||||||
|
throw new Error('Invalid segment duration');
|
||||||
} else if (line && !line.startsWith('#')) {
|
} else if (line && !line.startsWith('#')) {
|
||||||
if (duration === null || !Number.isSafeInteger(seq)) throw new Error('Invalid HLS segment');
|
if (duration === null || !Number.isSafeInteger(seq))
|
||||||
|
throw new Error('Invalid HLS segment');
|
||||||
const uri = sameOriginHlsUrl(line, base);
|
const uri = sameOriginHlsUrl(line, base);
|
||||||
if (!new URL(uri).pathname.endsWith('.ts')) throw new Error('Only MPEG-TS segments are supported');
|
if (!new URL(uri).pathname.endsWith('.ts'))
|
||||||
|
throw new Error('Only MPEG-TS segments are supported');
|
||||||
segments.push({ seq: seq++, duration, uri, discontinuity });
|
segments.push({ seq: seq++, duration, uri, discontinuity });
|
||||||
discontinuity = false;
|
discontinuity = false;
|
||||||
duration = null;
|
duration = null;
|
||||||
@@ -91,7 +119,10 @@ export function parseHlsMedia(text, base, limit = HLS_LIMITS.segments) {
|
|||||||
return segments.slice(-limit);
|
return segments.slice(-limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createHlsPuller({ fetchImpl = fetch, limits = HLS_LIMITS } = {}) {
|
export function createHlsPuller({
|
||||||
|
fetchImpl = fetch,
|
||||||
|
limits = HLS_LIMITS,
|
||||||
|
} = {}) {
|
||||||
const active = new Map();
|
const active = new Map();
|
||||||
let closed = false;
|
let closed = false;
|
||||||
const stop = (cameraId, token) => {
|
const stop = (cameraId, token) => {
|
||||||
@@ -118,34 +149,50 @@ export function createHlsPuller({ fetchImpl = fetch, limits = HLS_LIMITS } = {})
|
|||||||
const touch = (entry, leaseId, create = false) => {
|
const touch = (entry, leaseId, create = false) => {
|
||||||
let lease = entry.leases.get(leaseId);
|
let lease = entry.leases.get(leaseId);
|
||||||
if (!lease) {
|
if (!lease) {
|
||||||
if (!create || entry.leases.size >= limits.leasesPerSession) throw new Error('HLS lease capacity reached');
|
if (!create || entry.leases.size >= limits.leasesPerSession)
|
||||||
|
throw new Error('HLS lease capacity reached');
|
||||||
lease = {};
|
lease = {};
|
||||||
entry.leases.set(leaseId, lease);
|
entry.leases.set(leaseId, lease);
|
||||||
}
|
}
|
||||||
clearTimeout(lease.timer);
|
clearTimeout(lease.timer);
|
||||||
lease.timer = setTimeout(() => release(entry.cameraId, leaseId), limits.idleMs);
|
lease.timer = setTimeout(
|
||||||
|
() => release(entry.cameraId, leaseId),
|
||||||
|
limits.idleMs,
|
||||||
|
);
|
||||||
lease.timer.unref?.();
|
lease.timer.unref?.();
|
||||||
};
|
};
|
||||||
const read = (entry, url, maxBytes) => fetchHlsBytes(url, {
|
const read = (entry, url, maxBytes) =>
|
||||||
fetchImpl, signal: entry.controller.signal, maxBytes, timeoutMs: limits.timeoutMs,
|
fetchHlsBytes(url, {
|
||||||
});
|
fetchImpl,
|
||||||
|
signal: entry.controller.signal,
|
||||||
|
maxBytes,
|
||||||
|
timeoutMs: limits.timeoutMs,
|
||||||
|
});
|
||||||
const poll = async (entry) => {
|
const poll = async (entry) => {
|
||||||
try {
|
try {
|
||||||
let base = entry.chunklistUrl || entry.url;
|
let base = entry.chunklistUrl || entry.url;
|
||||||
let text = (await read(entry, base, limits.playlistBytes)).toString('utf8');
|
let text = (await read(entry, base, limits.playlistBytes)).toString(
|
||||||
|
'utf8',
|
||||||
|
);
|
||||||
if (!text.includes('#EXTINF:')) {
|
if (!text.includes('#EXTINF:')) {
|
||||||
const variant = text.split('\n').map((line) => line.trim()).find((line) => line && !line.startsWith('#'));
|
const variant = text
|
||||||
if (!variant || !text.includes('#EXT-X-STREAM-INF:')) throw new Error('Missing HLS variant');
|
.split('\n')
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.find((line) => line && !line.startsWith('#'));
|
||||||
|
if (!variant || !text.includes('#EXT-X-STREAM-INF:'))
|
||||||
|
throw new Error('Missing HLS variant');
|
||||||
base = sameOriginHlsUrl(variant, entry.url);
|
base = sameOriginHlsUrl(variant, entry.url);
|
||||||
text = (await read(entry, base, limits.playlistBytes)).toString('utf8');
|
text = (await read(entry, base, limits.playlistBytes)).toString('utf8');
|
||||||
}
|
}
|
||||||
entry.chunklistUrl = base;
|
entry.chunklistUrl = base;
|
||||||
const segments = parseHlsMedia(text, base, limits.segments);
|
const segments = parseHlsMedia(text, base, limits.segments);
|
||||||
const newest = segments.at(-1)?.seq ?? -1;
|
const newest = segments.at(-1)?.seq ?? -1;
|
||||||
const restarted = newest < entry.upstreamNewest || segments.some((segment) => {
|
const restarted =
|
||||||
const prior = entry.upstream.get(segment.seq);
|
newest < entry.upstreamNewest ||
|
||||||
return prior && prior !== segment.uri;
|
segments.some((segment) => {
|
||||||
});
|
const prior = entry.upstream.get(segment.seq);
|
||||||
|
return prior && prior !== segment.uri;
|
||||||
|
});
|
||||||
if (restarted) {
|
if (restarted) {
|
||||||
entry.upstream.clear();
|
entry.upstream.clear();
|
||||||
entry.pendingDiscontinuity = true;
|
entry.pendingDiscontinuity = true;
|
||||||
@@ -158,29 +205,45 @@ export function createHlsPuller({ fetchImpl = fetch, limits = HLS_LIMITS } = {})
|
|||||||
if (entry.stopping) return;
|
if (entry.stopping) return;
|
||||||
// Evict BEFORE retaining the incoming segment. One bounded download may
|
// Evict BEFORE retaining the incoming segment. One bounded download may
|
||||||
// exist in addition to the cache; no unbounded disk output is possible.
|
// exist in addition to the cache; no unbounded disk output is possible.
|
||||||
while (entry.segments.size && (entry.bytes + body.length > limits.sessionBytes || entry.segments.size >= limits.segments)) {
|
while (
|
||||||
|
entry.segments.size &&
|
||||||
|
(entry.bytes + body.length > limits.sessionBytes ||
|
||||||
|
entry.segments.size >= limits.segments)
|
||||||
|
) {
|
||||||
const oldest = entry.segments.keys().next().value;
|
const oldest = entry.segments.keys().next().value;
|
||||||
const removed = entry.segments.get(oldest);
|
const removed = entry.segments.get(oldest);
|
||||||
entry.bytes -= removed.body.length;
|
entry.bytes -= removed.body.length;
|
||||||
if (removed.discontinuity) entry.discontinuitiesRemoved++;
|
if (removed.discontinuity) entry.discontinuitiesRemoved++;
|
||||||
entry.segments.delete(oldest);
|
entry.segments.delete(oldest);
|
||||||
}
|
}
|
||||||
if (body.length > limits.sessionBytes) throw new Error('Segment exceeds session budget');
|
if (body.length > limits.sessionBytes)
|
||||||
|
throw new Error('Segment exceeds session budget');
|
||||||
const seq = entry.nextSeq++;
|
const seq = entry.nextSeq++;
|
||||||
entry.segments.set(seq, { ...segment, seq, body, discontinuity: segment.discontinuity || entry.pendingDiscontinuity });
|
entry.segments.set(seq, {
|
||||||
|
...segment,
|
||||||
|
seq,
|
||||||
|
body,
|
||||||
|
discontinuity: segment.discontinuity || entry.pendingDiscontinuity,
|
||||||
|
});
|
||||||
entry.pendingDiscontinuity = false;
|
entry.pendingDiscontinuity = false;
|
||||||
entry.upstream.set(segment.seq, segment.uri);
|
entry.upstream.set(segment.seq, segment.uri);
|
||||||
while (entry.upstream.size > limits.segments * 2) entry.upstream.delete(entry.upstream.keys().next().value);
|
while (entry.upstream.size > limits.segments * 2)
|
||||||
|
entry.upstream.delete(entry.upstream.keys().next().value);
|
||||||
entry.bytes += body.length;
|
entry.bytes += body.length;
|
||||||
}
|
}
|
||||||
entry.failures = 0;
|
entry.failures = 0;
|
||||||
} catch {
|
} catch {
|
||||||
entry.chunklistUrl = null;
|
entry.chunklistUrl = null;
|
||||||
entry.failures++;
|
entry.failures++;
|
||||||
if (entry.failures >= 3) { stop(entry.cameraId, entry.token); return; }
|
if (entry.failures >= 3) {
|
||||||
|
stop(entry.cameraId, entry.token);
|
||||||
|
return;
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (!entry.stopping) {
|
if (!entry.stopping) {
|
||||||
entry.timer = setTimeout(() => { entry.polling = poll(entry); }, limits.pollMs);
|
entry.timer = setTimeout(() => {
|
||||||
|
entry.polling = poll(entry);
|
||||||
|
}, limits.pollMs);
|
||||||
entry.timer.unref?.();
|
entry.timer.unref?.();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -189,11 +252,33 @@ export function createHlsPuller({ fetchImpl = fetch, limits = HLS_LIMITS } = {})
|
|||||||
if (closed) throw new Error('HLS service closed');
|
if (closed) throw new Error('HLS service closed');
|
||||||
sameOriginHlsUrl(url, url);
|
sameOriginHlsUrl(url, url);
|
||||||
let entry = active.get(cameraId);
|
let entry = active.get(cameraId);
|
||||||
if (entry && entry.url !== url) { stop(cameraId); entry = null; }
|
if (entry && entry.url !== url) {
|
||||||
if (entry) { touch(entry, leaseId, true); return entry; }
|
stop(cameraId);
|
||||||
|
entry = null;
|
||||||
|
}
|
||||||
|
if (entry) {
|
||||||
|
touch(entry, leaseId, true);
|
||||||
|
return entry;
|
||||||
|
}
|
||||||
// Reserve synchronously, before any await: concurrent creation is singleflight.
|
// Reserve synchronously, before any await: concurrent creation is singleflight.
|
||||||
if (active.size >= limits.sessions) throw new Error('HLS session capacity reached');
|
if (active.size >= limits.sessions)
|
||||||
entry = { cameraId, url, token: randomUUID(), controller: new AbortController(), segments: new Map(), bytes: 0, stopping: false, failures: 0, nextSeq: 0, upstream: new Map(), leases: new Map(), upstreamNewest: -1, pendingDiscontinuity: false, discontinuitiesRemoved: 0 };
|
throw new Error('HLS session capacity reached');
|
||||||
|
entry = {
|
||||||
|
cameraId,
|
||||||
|
url,
|
||||||
|
token: randomUUID(),
|
||||||
|
controller: new AbortController(),
|
||||||
|
segments: new Map(),
|
||||||
|
bytes: 0,
|
||||||
|
stopping: false,
|
||||||
|
failures: 0,
|
||||||
|
nextSeq: 0,
|
||||||
|
upstream: new Map(),
|
||||||
|
leases: new Map(),
|
||||||
|
upstreamNewest: -1,
|
||||||
|
pendingDiscontinuity: false,
|
||||||
|
discontinuitiesRemoved: 0,
|
||||||
|
};
|
||||||
active.set(cameraId, entry);
|
active.set(cameraId, entry);
|
||||||
touch(entry, leaseId, true);
|
touch(entry, leaseId, true);
|
||||||
entry.polling = poll(entry);
|
entry.polling = poll(entry);
|
||||||
@@ -211,18 +296,32 @@ export function createHlsPuller({ fetchImpl = fetch, limits = HLS_LIMITS } = {})
|
|||||||
if (entry.stopping || entry.segments.size < 2) return null;
|
if (entry.stopping || entry.segments.size < 2) return null;
|
||||||
touch(entry, leaseId);
|
touch(entry, leaseId);
|
||||||
const segments = [...entry.segments.values()].sort((a, b) => a.seq - b.seq);
|
const segments = [...entry.segments.values()].sort((a, b) => a.seq - b.seq);
|
||||||
const lines = ['#EXTM3U', '#EXT-X-VERSION:3', `#EXT-X-TARGETDURATION:${Math.ceil(Math.max(...segments.map((s) => s.duration)))}`, `#EXT-X-MEDIA-SEQUENCE:${segments[0].seq}`, `#EXT-X-DISCONTINUITY-SEQUENCE:${entry.discontinuitiesRemoved}`];
|
const lines = [
|
||||||
|
'#EXTM3U',
|
||||||
|
'#EXT-X-VERSION:3',
|
||||||
|
`#EXT-X-TARGETDURATION:${Math.ceil(Math.max(...segments.map((s) => s.duration)))}`,
|
||||||
|
`#EXT-X-MEDIA-SEQUENCE:${segments[0].seq}`,
|
||||||
|
`#EXT-X-DISCONTINUITY-SEQUENCE:${entry.discontinuitiesRemoved}`,
|
||||||
|
];
|
||||||
let previous;
|
let previous;
|
||||||
for (const segment of segments) {
|
for (const segment of segments) {
|
||||||
if (segment.discontinuity || (previous !== undefined && segment.seq !== previous + 1)) lines.push('#EXT-X-DISCONTINUITY');
|
if (
|
||||||
lines.push(`#EXTINF:${segment.duration.toFixed(3)},`, `/api/cctv/media/${encodeURIComponent(cameraId)}/seg_${segment.seq}.ts?session=${entry.token}&lease=${encodeURIComponent(leaseId)}`);
|
segment.discontinuity ||
|
||||||
|
(previous !== undefined && segment.seq !== previous + 1)
|
||||||
|
)
|
||||||
|
lines.push('#EXT-X-DISCONTINUITY');
|
||||||
|
lines.push(
|
||||||
|
`#EXTINF:${segment.duration.toFixed(3)},`,
|
||||||
|
`/api/cctv/media/${encodeURIComponent(cameraId)}/seg_${segment.seq}.ts?session=${entry.token}&lease=${encodeURIComponent(leaseId)}`,
|
||||||
|
);
|
||||||
previous = segment.seq;
|
previous = segment.seq;
|
||||||
}
|
}
|
||||||
return lines.join('\n') + '\n';
|
return lines.join('\n') + '\n';
|
||||||
};
|
};
|
||||||
const getSegment = (cameraId, token, seq, leaseId = 'legacy') => {
|
const getSegment = (cameraId, token, seq, leaseId = 'legacy') => {
|
||||||
const entry = active.get(cameraId);
|
const entry = active.get(cameraId);
|
||||||
if (!entry || entry.token !== token || !entry.leases.has(leaseId)) return null;
|
if (!entry || entry.token !== token || !entry.leases.has(leaseId))
|
||||||
|
return null;
|
||||||
const body = entry.segments.get(seq)?.body;
|
const body = entry.segments.get(seq)?.body;
|
||||||
if (body) touch(entry, leaseId);
|
if (body) touch(entry, leaseId);
|
||||||
return body || null;
|
return body || null;
|
||||||
@@ -233,6 +332,17 @@ export function createHlsPuller({ fetchImpl = fetch, limits = HLS_LIMITS } = {})
|
|||||||
for (const entry of entries) stop(entry.cameraId);
|
for (const entry of entries) stop(entry.cameraId);
|
||||||
await Promise.allSettled(entries.map((entry) => entry.polling));
|
await Promise.allSettled(entries.map((entry) => entry.polling));
|
||||||
};
|
};
|
||||||
return { ensure, waitReady, buildPlaylist, getSegment, stop, release, shutdown,
|
return {
|
||||||
stats: () => ({ sessions: active.size, bytes: [...active.values()].reduce((total, e) => total + e.bytes, 0) }) };
|
ensure,
|
||||||
|
waitReady,
|
||||||
|
buildPlaylist,
|
||||||
|
getSegment,
|
||||||
|
stop,
|
||||||
|
release,
|
||||||
|
shutdown,
|
||||||
|
stats: () => ({
|
||||||
|
sessions: active.size,
|
||||||
|
bytes: [...active.values()].reduce((total, e) => total + e.bytes, 0),
|
||||||
|
}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
+120
-23
@@ -1,25 +1,77 @@
|
|||||||
import test from 'node:test';
|
import test from 'node:test';
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { createHlsPuller, fetchHlsBytes, parseHlsMedia, HLS_LIMITS } from '../../server/providers/cctv/stream.js';
|
import {
|
||||||
const playlist = '#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:12\n#EXTINF:2,\na.ts\n#EXTINF:2,\nb.ts\n#EXTINF:2,\nc.ts\n';
|
createHlsPuller,
|
||||||
|
fetchHlsBytes,
|
||||||
|
parseHlsMedia,
|
||||||
|
HLS_LIMITS,
|
||||||
|
} from '../../server/providers/cctv/stream.js';
|
||||||
|
const playlist =
|
||||||
|
'#EXTM3U\n#EXT-X-MEDIA-SEQUENCE:12\n#EXTINF:2,\na.ts\n#EXTINF:2,\nb.ts\n#EXTINF:2,\nc.ts\n';
|
||||||
const base = 'https://camera.example/live/list.m3u8';
|
const base = 'https://camera.example/live/list.m3u8';
|
||||||
|
|
||||||
test('playlist parser uses sequence, refuses escaping and unsupported references', () => {
|
test('playlist parser uses sequence, refuses escaping and unsupported references', () => {
|
||||||
assert.deepEqual(parseHlsMedia(playlist, base).map((s) => s.seq), [12, 13, 14]);
|
assert.deepEqual(
|
||||||
for (const bad of [playlist.replace('a.ts', 'https://evil.example/a.ts'), playlist.replace('a.ts', '//evil.example/a.ts'), '#EXTM3U\n#EXT-X-KEY:METHOD=AES-128\n' + playlist, playlist.replace('12', '-1'), playlist.replace('2,', 'Infinity,')]) assert.throws(() => parseHlsMedia(bad, base));
|
parseHlsMedia(playlist, base).map((s) => s.seq),
|
||||||
|
[12, 13, 14],
|
||||||
|
);
|
||||||
|
for (const bad of [
|
||||||
|
playlist.replace('a.ts', 'https://evil.example/a.ts'),
|
||||||
|
playlist.replace('a.ts', '//evil.example/a.ts'),
|
||||||
|
'#EXTM3U\n#EXT-X-KEY:METHOD=AES-128\n' + playlist,
|
||||||
|
playlist.replace('12', '-1'),
|
||||||
|
playlist.replace('2,', 'Infinity,'),
|
||||||
|
])
|
||||||
|
assert.throws(() => parseHlsMedia(bad, base));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('downloads reject redirect responses, oversized declared and chunked bodies', async () => {
|
test('downloads reject redirect responses, oversized declared and chunked bodies', async () => {
|
||||||
let init;
|
let init;
|
||||||
await assert.rejects(fetchHlsBytes(base, { maxBytes: 5, fetchImpl: async (_url, options) => { init = options; return new Response('', { status: 302, headers: { Location: 'https://evil.example' } }); } }));
|
await assert.rejects(
|
||||||
|
fetchHlsBytes(base, {
|
||||||
|
maxBytes: 5,
|
||||||
|
fetchImpl: async (_url, options) => {
|
||||||
|
init = options;
|
||||||
|
return new Response('', {
|
||||||
|
status: 302,
|
||||||
|
headers: { Location: 'https://evil.example' },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
assert.equal(init.redirect, 'error');
|
assert.equal(init.redirect, 'error');
|
||||||
await assert.rejects(fetchHlsBytes(base, { maxBytes: 5, fetchImpl: async () => new Response('abcdef') }));
|
await assert.rejects(
|
||||||
await assert.rejects(fetchHlsBytes(base, { maxBytes: 5, fetchImpl: async () => new Response('a', { headers: { 'content-length': '100' } }) }));
|
fetchHlsBytes(base, {
|
||||||
|
maxBytes: 5,
|
||||||
|
fetchImpl: async () => new Response('abcdef'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await assert.rejects(
|
||||||
|
fetchHlsBytes(base, {
|
||||||
|
maxBytes: 5,
|
||||||
|
fetchImpl: async () =>
|
||||||
|
new Response('a', { headers: { 'content-length': '100' } }),
|
||||||
|
}),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('one session is reserved before await; disk-free cache and capacity remain bounded', async () => {
|
test('one session is reserved before await; disk-free cache and capacity remain bounded', async () => {
|
||||||
const manager = createHlsPuller({ limits: { ...HLS_LIMITS, sessions: 1, segmentBytes: 4, sessionBytes: 6, segments: 2, pollMs: 100000 }, fetchImpl: async (url) => new Response(url.endsWith('.m3u8') ? playlist : 'abc') });
|
const manager = createHlsPuller({
|
||||||
const [a, b] = await Promise.all([manager.ensure('a', base), manager.ensure('a', base)]);
|
limits: {
|
||||||
|
...HLS_LIMITS,
|
||||||
|
sessions: 1,
|
||||||
|
segmentBytes: 4,
|
||||||
|
sessionBytes: 6,
|
||||||
|
segments: 2,
|
||||||
|
pollMs: 100000,
|
||||||
|
},
|
||||||
|
fetchImpl: async (url) =>
|
||||||
|
new Response(url.endsWith('.m3u8') ? playlist : 'abc'),
|
||||||
|
});
|
||||||
|
const [a, b] = await Promise.all([
|
||||||
|
manager.ensure('a', base),
|
||||||
|
manager.ensure('a', base),
|
||||||
|
]);
|
||||||
assert.equal(a, b);
|
assert.equal(a, b);
|
||||||
await assert.rejects(manager.ensure('b', base));
|
await assert.rejects(manager.ensure('b', base));
|
||||||
assert.equal(await manager.waitReady(a), true);
|
assert.equal(await manager.waitReady(a), true);
|
||||||
@@ -37,7 +89,16 @@ test('one session is reserved before await; disk-free cache and capacity remain
|
|||||||
|
|
||||||
test('shutdown cancels in-flight downloads and late responses cannot refill cache', async () => {
|
test('shutdown cancels in-flight downloads and late responses cannot refill cache', async () => {
|
||||||
let observed;
|
let observed;
|
||||||
const manager = createHlsPuller({ fetchImpl: async (_url, { signal }) => { observed = signal; return new Promise((resolve, reject) => signal.addEventListener('abort', () => reject(new Error('cancelled')), { once: true })); } });
|
const manager = createHlsPuller({
|
||||||
|
fetchImpl: async (_url, { signal }) => {
|
||||||
|
observed = signal;
|
||||||
|
return new Promise((resolve, reject) =>
|
||||||
|
signal.addEventListener('abort', () => reject(new Error('cancelled')), {
|
||||||
|
once: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
const entry = await manager.ensure('a', base);
|
const entry = await manager.ensure('a', base);
|
||||||
await manager.shutdown();
|
await manager.shutdown();
|
||||||
assert.equal(observed.aborted, true);
|
assert.equal(observed.aborted, true);
|
||||||
@@ -48,7 +109,13 @@ test('shutdown cancels in-flight downloads and late responses cannot refill cach
|
|||||||
|
|
||||||
test('idle cleanup stops all polling without a background sweep', async () => {
|
test('idle cleanup stops all polling without a background sweep', async () => {
|
||||||
let calls = 0;
|
let calls = 0;
|
||||||
const manager = createHlsPuller({ limits: { ...HLS_LIMITS, idleMs: 15, pollMs: 100000 }, fetchImpl: async (url) => { calls++; return new Response(url.endsWith('.m3u8') ? playlist : 'abc'); } });
|
const manager = createHlsPuller({
|
||||||
|
limits: { ...HLS_LIMITS, idleMs: 15, pollMs: 100000 },
|
||||||
|
fetchImpl: async (url) => {
|
||||||
|
calls++;
|
||||||
|
return new Response(url.endsWith('.m3u8') ? playlist : 'abc');
|
||||||
|
},
|
||||||
|
});
|
||||||
await manager.ensure('a', base);
|
await manager.ensure('a', base);
|
||||||
await new Promise((r) => setTimeout(r, 50));
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
assert.deepEqual(manager.stats(), { sessions: 0, bytes: 0 });
|
assert.deepEqual(manager.stats(), { sessions: 0, bytes: 0 });
|
||||||
@@ -60,7 +127,11 @@ test('idle cleanup stops all polling without a background sweep', async () => {
|
|||||||
|
|
||||||
test('agency sequence rollback creates a monotonic local discontinuity', async () => {
|
test('agency sequence rollback creates a monotonic local discontinuity', async () => {
|
||||||
let current = playlist;
|
let current = playlist;
|
||||||
const manager = createHlsPuller({ limits: { ...HLS_LIMITS, pollMs: 5 }, fetchImpl: async (url) => new Response(url.endsWith('.m3u8') ? current : 'abc') });
|
const manager = createHlsPuller({
|
||||||
|
limits: { ...HLS_LIMITS, pollMs: 5 },
|
||||||
|
fetchImpl: async (url) =>
|
||||||
|
new Response(url.endsWith('.m3u8') ? current : 'abc'),
|
||||||
|
});
|
||||||
const entry = await manager.ensure('a', base);
|
const entry = await manager.ensure('a', base);
|
||||||
await manager.waitReady(entry);
|
await manager.waitReady(entry);
|
||||||
const before = await manager.buildPlaylist(entry, 'a');
|
const before = await manager.buildPlaylist(entry, 'a');
|
||||||
@@ -68,34 +139,53 @@ test('agency sequence rollback creates a monotonic local discontinuity', async (
|
|||||||
current = playlist.replace('SEQUENCE:12', 'SEQUENCE:0');
|
current = playlist.replace('SEQUENCE:12', 'SEQUENCE:0');
|
||||||
await new Promise((r) => setTimeout(r, 30));
|
await new Promise((r) => setTimeout(r, 30));
|
||||||
const after = await manager.buildPlaylist(entry, 'a');
|
const after = await manager.buildPlaylist(entry, 'a');
|
||||||
assert.match(after, /#EXT-X-DISCONTINUITY\n#EXTINF:2\.000,\n\/api\/cctv\/media\/a\/seg_3\.ts/);
|
assert.match(
|
||||||
|
after,
|
||||||
|
/#EXT-X-DISCONTINUITY\n#EXTINF:2\.000,\n\/api\/cctv\/media\/a\/seg_3\.ts/,
|
||||||
|
);
|
||||||
await manager.shutdown();
|
await manager.shutdown();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('upstream discontinuity tags survive the media parser', () => {
|
test('upstream discontinuity tags survive the media parser', () => {
|
||||||
const parsed = parseHlsMedia(playlist.replace('a.ts', 'a.ts\n#EXT-X-DISCONTINUITY'), base);
|
const parsed = parseHlsMedia(
|
||||||
|
playlist.replace('a.ts', 'a.ts\n#EXT-X-DISCONTINUITY'),
|
||||||
|
base,
|
||||||
|
);
|
||||||
assert.equal(parsed[0].discontinuity, false);
|
assert.equal(parsed[0].discontinuity, false);
|
||||||
assert.equal(parsed[1].discontinuity, true);
|
assert.equal(parsed[1].discontinuity, true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('a reused agency sequence with changed segment URI cannot remain stale', async () => {
|
test('a reused agency sequence with changed segment URI cannot remain stale', async () => {
|
||||||
let current = playlist;
|
let current = playlist;
|
||||||
const manager = createHlsPuller({ limits: { ...HLS_LIMITS, pollMs: 5 }, fetchImpl: async (url) => new Response(url.endsWith('.m3u8') ? current : 'abc') });
|
const manager = createHlsPuller({
|
||||||
|
limits: { ...HLS_LIMITS, pollMs: 5 },
|
||||||
|
fetchImpl: async (url) =>
|
||||||
|
new Response(url.endsWith('.m3u8') ? current : 'abc'),
|
||||||
|
});
|
||||||
const entry = await manager.ensure('a', base);
|
const entry = await manager.ensure('a', base);
|
||||||
await manager.waitReady(entry);
|
await manager.waitReady(entry);
|
||||||
current = playlist.replaceAll('.ts', '.ts?generation=2');
|
current = playlist.replaceAll('.ts', '.ts?generation=2');
|
||||||
await new Promise((r) => setTimeout(r, 30));
|
await new Promise((r) => setTimeout(r, 30));
|
||||||
assert.match(await manager.buildPlaylist(entry, 'a'), /#EXT-X-DISCONTINUITY\n#EXTINF:2\.000,\n\/api\/cctv\/media\/a\/seg_3\.ts/);
|
assert.match(
|
||||||
|
await manager.buildPlaylist(entry, 'a'),
|
||||||
|
/#EXT-X-DISCONTINUITY\n#EXTINF:2\.000,\n\/api\/cctv\/media\/a\/seg_3\.ts/,
|
||||||
|
);
|
||||||
await manager.shutdown();
|
await manager.shutdown();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('two consumers share downloads but release and abandoned expiry are independent', async () => {
|
test('two consumers share downloads but release and abandoned expiry are independent', async () => {
|
||||||
let downloads = 0;
|
let downloads = 0;
|
||||||
const manager = createHlsPuller({ limits: { ...HLS_LIMITS, pollMs: 100000, leasesPerSession: 2 }, fetchImpl: async (url) => {
|
const manager = createHlsPuller({
|
||||||
downloads++;
|
limits: { ...HLS_LIMITS, pollMs: 100000, leasesPerSession: 2 },
|
||||||
return new Response(url.endsWith('.m3u8') ? playlist : 'abc');
|
fetchImpl: async (url) => {
|
||||||
} });
|
downloads++;
|
||||||
const [a, b] = await Promise.all([manager.ensure('camera', base, 'viewer-a'), manager.ensure('camera', base, 'viewer-b')]);
|
return new Response(url.endsWith('.m3u8') ? playlist : 'abc');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const [a, b] = await Promise.all([
|
||||||
|
manager.ensure('camera', base, 'viewer-a'),
|
||||||
|
manager.ensure('camera', base, 'viewer-b'),
|
||||||
|
]);
|
||||||
assert.equal(a, b);
|
assert.equal(a, b);
|
||||||
await manager.waitReady(a);
|
await manager.waitReady(a);
|
||||||
assert.equal(downloads, 4); // One manifest and three segments, not per consumer.
|
assert.equal(downloads, 4); // One manifest and three segments, not per consumer.
|
||||||
@@ -105,7 +195,10 @@ test('two consumers share downloads but release and abandoned expiry are indepen
|
|||||||
assert.equal(manager.stats().sessions, 1);
|
assert.equal(manager.stats().sessions, 1);
|
||||||
assert.equal(manager.getSegment('camera', a.token, 0, 'viewer-a'), null);
|
assert.equal(manager.getSegment('camera', a.token, 0, 'viewer-a'), null);
|
||||||
assert.equal(manager.getSegment('camera', a.token, 0, 'viewer-b').length, 3);
|
assert.equal(manager.getSegment('camera', a.token, 0, 'viewer-b').length, 3);
|
||||||
assert.match(await manager.buildPlaylist(b, 'camera', 'viewer-b'), /lease=viewer-b/);
|
assert.match(
|
||||||
|
await manager.buildPlaylist(b, 'camera', 'viewer-b'),
|
||||||
|
/lease=viewer-b/,
|
||||||
|
);
|
||||||
manager.release('camera', 'viewer-a'); // Duplicate/late release cannot stop B.
|
manager.release('camera', 'viewer-a'); // Duplicate/late release cannot stop B.
|
||||||
assert.equal(b.controller.signal.aborted, false);
|
assert.equal(b.controller.signal.aborted, false);
|
||||||
manager.release('camera', 'viewer-b');
|
manager.release('camera', 'viewer-b');
|
||||||
@@ -115,7 +208,11 @@ test('two consumers share downloads but release and abandoned expiry are indepen
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('an abandoned consumer expires while a renewed consumer keeps the session', async () => {
|
test('an abandoned consumer expires while a renewed consumer keeps the session', async () => {
|
||||||
const manager = createHlsPuller({ limits: { ...HLS_LIMITS, idleMs: 80, pollMs: 100000 }, fetchImpl: async (url) => new Response(url.endsWith('.m3u8') ? playlist : 'abc') });
|
const manager = createHlsPuller({
|
||||||
|
limits: { ...HLS_LIMITS, idleMs: 80, pollMs: 100000 },
|
||||||
|
fetchImpl: async (url) =>
|
||||||
|
new Response(url.endsWith('.m3u8') ? playlist : 'abc'),
|
||||||
|
});
|
||||||
const entry = await manager.ensure('camera', base, 'abandoned');
|
const entry = await manager.ensure('camera', base, 'abandoned');
|
||||||
await manager.ensure('camera', base, 'active');
|
await manager.ensure('camera', base, 'active');
|
||||||
await new Promise((r) => setTimeout(r, 50));
|
await new Promise((r) => setTimeout(r, 50));
|
||||||
|
|||||||
@@ -83,7 +83,9 @@ export function createPresentation({
|
|||||||
mountHeightM: camera.mountHeightM,
|
mountHeightM: camera.mountHeightM,
|
||||||
active: isActive,
|
active: isActive,
|
||||||
feedType: camera.feedType,
|
feedType: camera.feedType,
|
||||||
isVideo: parts.model.isVideoFeedType(camera.feedType) && record.projection?.mode !== 'image',
|
isVideo:
|
||||||
|
parts.model.isVideoFeedType(camera.feedType) &&
|
||||||
|
record.projection?.mode !== 'image',
|
||||||
sourceKind:
|
sourceKind:
|
||||||
health?.sourceKind ||
|
health?.sourceKind ||
|
||||||
camera.sourceKind ||
|
camera.sourceKind ||
|
||||||
|
|||||||
@@ -233,20 +233,32 @@ export function createProjection({
|
|||||||
video.addEventListener('loadedmetadata', bindVideoTexture);
|
video.addEventListener('loadedmetadata', bindVideoTexture);
|
||||||
video.addEventListener('resize', bindVideoTexture);
|
video.addEventListener('resize', bindVideoTexture);
|
||||||
runtime.video = video;
|
runtime.video = video;
|
||||||
runtime.playback = attachCctvVideo(video, parts.frames.mediaUrlFor(record.camera), feedType, {
|
runtime.playback = attachCctvVideo(
|
||||||
onFailure: () => {
|
video,
|
||||||
if (runtime.disposed) return;
|
parts.frames.mediaUrlFor(record.camera),
|
||||||
runtime.video = null;
|
feedType,
|
||||||
runtime.mode = 'image';
|
{
|
||||||
runtime.image = new Image();
|
onFailure: () => {
|
||||||
runtime.image.decoding = 'async';
|
if (runtime.disposed) return;
|
||||||
runtime.image.onload = () => { runtime.imageLoading = false; runtime.imageReady = true; runtime.imageStamp = Date.now(); };
|
runtime.video = null;
|
||||||
runtime.image.onerror = () => { runtime.imageLoading = false; runtime.imageReady = false; };
|
runtime.mode = 'image';
|
||||||
runtime.planeMaterial.image = runtime.canvas;
|
runtime.image = new Image();
|
||||||
parts.frames.refreshProjectionImage(record, true);
|
runtime.image.decoding = 'async';
|
||||||
parts.presentation.notifyListeners();
|
runtime.image.onload = () => {
|
||||||
|
runtime.imageLoading = false;
|
||||||
|
runtime.imageReady = true;
|
||||||
|
runtime.imageStamp = Date.now();
|
||||||
|
};
|
||||||
|
runtime.image.onerror = () => {
|
||||||
|
runtime.imageLoading = false;
|
||||||
|
runtime.imageReady = false;
|
||||||
|
};
|
||||||
|
runtime.planeMaterial.image = runtime.canvas;
|
||||||
|
parts.frames.refreshProjectionImage(record, true);
|
||||||
|
parts.presentation.notifyListeners();
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
);
|
||||||
} else {
|
} else {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
img.decoding = 'async';
|
img.decoding = 'async';
|
||||||
@@ -294,7 +306,8 @@ export function createProjection({
|
|||||||
|
|
||||||
function ensureProjectionRuntime(record) {
|
function ensureProjectionRuntime(record) {
|
||||||
if (!record) return null;
|
if (!record) return null;
|
||||||
if (record.projection && !record.projection.disposed) return record.projection;
|
if (record.projection && !record.projection.disposed)
|
||||||
|
return record.projection;
|
||||||
const runtime = createProjectionRuntime(record);
|
const runtime = createProjectionRuntime(record);
|
||||||
record.projection = runtime;
|
record.projection = runtime;
|
||||||
if (runtime) {
|
if (runtime) {
|
||||||
@@ -314,7 +327,11 @@ export function createProjection({
|
|||||||
runtime.disposed = true;
|
runtime.disposed = true;
|
||||||
runtime.playback?.dispose();
|
runtime.playback?.dispose();
|
||||||
runtime.playback = null;
|
runtime.playback = null;
|
||||||
if (runtime.image) { runtime.image.onload = null; runtime.image.onerror = null; runtime.image.src = ''; }
|
if (runtime.image) {
|
||||||
|
runtime.image.onload = null;
|
||||||
|
runtime.image.onerror = null;
|
||||||
|
runtime.image.src = '';
|
||||||
|
}
|
||||||
if (runtime.video) {
|
if (runtime.video) {
|
||||||
runtime.video.pause();
|
runtime.video.pause();
|
||||||
runtime.video.removeAttribute('src');
|
runtime.video.removeAttribute('src');
|
||||||
@@ -392,16 +409,15 @@ export function createProjection({
|
|||||||
function pauseInactiveProjectionFeeds(activeId) {
|
function pauseInactiveProjectionFeeds(activeId) {
|
||||||
for (const record of layerState._records) {
|
for (const record of layerState._records) {
|
||||||
if (!record.projection?.video) continue;
|
if (!record.projection?.video) continue;
|
||||||
if (
|
if (record.camera.id === activeId && layerState._enabled) {
|
||||||
record.camera.id === activeId &&
|
|
||||||
layerState._enabled
|
|
||||||
) {
|
|
||||||
record.projection.video.play().catch(() => {});
|
record.projection.video.play().catch(() => {});
|
||||||
} else {
|
} else {
|
||||||
const runtime = record.projection;
|
const runtime = record.projection;
|
||||||
destroyProjectionRuntime(runtime);
|
destroyProjectionRuntime(runtime);
|
||||||
record.projection = null;
|
record.projection = null;
|
||||||
layerState._projectionEntities = layerState._projectionEntities.filter((entry) => entry !== runtime);
|
layerState._projectionEntities = layerState._projectionEntities.filter(
|
||||||
|
(entry) => entry !== runtime,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -215,7 +215,12 @@ export function createRendering({
|
|||||||
// (set at creation) — see the field-test far-zoom submerge fix there.
|
// (set at creation) — see the field-test far-zoom submerge fix there.
|
||||||
}
|
}
|
||||||
|
|
||||||
if (layerState._enabled && isActive && (layerState._showProjection || parts.model.isVideoFeedType(record.camera.feedType))) {
|
if (
|
||||||
|
layerState._enabled &&
|
||||||
|
isActive &&
|
||||||
|
(layerState._showProjection ||
|
||||||
|
parts.model.isVideoFeedType(record.camera.feedType))
|
||||||
|
) {
|
||||||
parts.projection.ensureProjectionRuntime(record);
|
parts.projection.ensureProjectionRuntime(record);
|
||||||
}
|
}
|
||||||
// One live plane in the world at a time (§2c): only the active camera's
|
// One live plane in the world at a time (§2c): only the active camera's
|
||||||
|
|||||||
@@ -1,20 +1,30 @@
|
|||||||
/** One decoder per active camera; both surfaces consume this video element. */
|
/** One decoder per active camera; both surfaces consume this video element. */
|
||||||
export function attachCctvVideo(video, url, feedType, {
|
export function attachCctvVideo(
|
||||||
loadHls = () => import('hls.js'),
|
video,
|
||||||
onFailure = () => {},
|
url,
|
||||||
fetchImpl = globalThis.fetch,
|
feedType,
|
||||||
} = {}) {
|
{
|
||||||
|
loadHls = () => import('hls.js'),
|
||||||
|
onFailure = () => {},
|
||||||
|
fetchImpl = globalThis.fetch,
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
let disposed = false;
|
let disposed = false;
|
||||||
let hls = null;
|
let hls = null;
|
||||||
const leaseId = feedType === 'hls' ? globalThis.crypto.randomUUID() : null;
|
const leaseId = feedType === 'hls' ? globalThis.crypto.randomUUID() : null;
|
||||||
const mediaUrl = feedType === 'hls' ? `${url}${url.includes('?') ? '&' : '?'}lease=${leaseId}` : url;
|
const mediaUrl =
|
||||||
|
feedType === 'hls'
|
||||||
|
? `${url}${url.includes('?') ? '&' : '?'}lease=${leaseId}`
|
||||||
|
: url;
|
||||||
let timer = null;
|
let timer = null;
|
||||||
let startup = null;
|
let startup = null;
|
||||||
let governor = null;
|
let governor = null;
|
||||||
let retries = 0;
|
let retries = 0;
|
||||||
const release = () => {
|
const release = () => {
|
||||||
if (feedType === 'hls') {
|
if (feedType === 'hls') {
|
||||||
void fetchImpl?.(mediaUrl, { method: 'DELETE', keepalive: true }).catch(() => {});
|
void fetchImpl?.(mediaUrl, { method: 'DELETE', keepalive: true }).catch(
|
||||||
|
() => {},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const dispose = () => {
|
const dispose = () => {
|
||||||
@@ -46,19 +56,33 @@ export function attachCctvVideo(video, url, feedType, {
|
|||||||
video.addEventListener('error', fail);
|
video.addEventListener('error', fail);
|
||||||
startup = setTimeout(fail, 30000);
|
startup = setTimeout(fail, 30000);
|
||||||
const ready = (async () => {
|
const ready = (async () => {
|
||||||
if (feedType !== 'hls') { video.src = mediaUrl; return; }
|
if (feedType !== 'hls') {
|
||||||
|
video.src = mediaUrl;
|
||||||
|
return;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const { default: Hls } = await loadHls();
|
const { default: Hls } = await loadHls();
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
if (!Hls.isSupported()) {
|
if (!Hls.isSupported()) {
|
||||||
if (video.canPlayType('application/vnd.apple.mpegurl')) video.src = mediaUrl;
|
if (video.canPlayType('application/vnd.apple.mpegurl'))
|
||||||
|
video.src = mediaUrl;
|
||||||
else fail();
|
else fail();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
hls = new Hls({ enableWorker: true, maxBufferLength: 24, maxMaxBufferLength: 30, backBufferLength: 0, maxBufferSize: 16 * 1024 * 1024, liveSyncDurationCount: 3 });
|
hls = new Hls({
|
||||||
|
enableWorker: true,
|
||||||
|
maxBufferLength: 24,
|
||||||
|
maxMaxBufferLength: 30,
|
||||||
|
backBufferLength: 0,
|
||||||
|
maxBufferSize: 16 * 1024 * 1024,
|
||||||
|
liveSyncDurationCount: 3,
|
||||||
|
});
|
||||||
hls.on(Hls.Events.ERROR, (_event, data) => {
|
hls.on(Hls.Events.ERROR, (_event, data) => {
|
||||||
if (disposed || !data?.fatal) return;
|
if (disposed || !data?.fatal) return;
|
||||||
if (++retries > 2) { fail(); return; }
|
if (++retries > 2) {
|
||||||
|
fail();
|
||||||
|
return;
|
||||||
|
}
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
timer = setTimeout(() => {
|
timer = setTimeout(() => {
|
||||||
if (disposed) return;
|
if (disposed) return;
|
||||||
@@ -68,14 +92,18 @@ export function attachCctvVideo(video, url, feedType, {
|
|||||||
});
|
});
|
||||||
governor = setInterval(() => {
|
governor = setInterval(() => {
|
||||||
if (disposed || !video.buffered?.length) return;
|
if (disposed || !video.buffered?.length) return;
|
||||||
const ahead = video.buffered.end(video.buffered.length - 1) - video.currentTime;
|
const ahead =
|
||||||
|
video.buffered.end(video.buffered.length - 1) - video.currentTime;
|
||||||
// Some agency encoders publish less media time than wall time. Bound
|
// Some agency encoders publish less media time than wall time. Bound
|
||||||
// correction rather than repeatedly draining the live buffer at 1x.
|
// correction rather than repeatedly draining the live buffer at 1x.
|
||||||
video.playbackRate = ahead < 6 ? 0.8 : ahead < 12 ? 0.9 : ahead > 24 ? 1.05 : 1;
|
video.playbackRate =
|
||||||
|
ahead < 6 ? 0.8 : ahead < 12 ? 0.9 : ahead > 24 ? 1.05 : 1;
|
||||||
}, 1000);
|
}, 1000);
|
||||||
hls.attachMedia(video);
|
hls.attachMedia(video);
|
||||||
hls.loadSource(mediaUrl);
|
hls.loadSource(mediaUrl);
|
||||||
} catch { fail(); }
|
} catch {
|
||||||
|
fail();
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
return { dispose, ready };
|
return { dispose, ready };
|
||||||
}
|
}
|
||||||
@@ -3,15 +3,33 @@ import assert from 'node:assert/strict';
|
|||||||
import { attachCctvVideo } from './videoPlayback.js';
|
import { attachCctvVideo } from './videoPlayback.js';
|
||||||
function video() {
|
function video() {
|
||||||
const v = new EventTarget();
|
const v = new EventTarget();
|
||||||
Object.assign(v, { src: '', pause() {}, load() {}, removeAttribute() {}, play: () => Promise.resolve() });
|
Object.assign(v, {
|
||||||
|
src: '',
|
||||||
|
pause() {},
|
||||||
|
load() {},
|
||||||
|
removeAttribute() {},
|
||||||
|
play: () => Promise.resolve(),
|
||||||
|
});
|
||||||
return v;
|
return v;
|
||||||
}
|
}
|
||||||
test('switch during lazy import never starts the stale decoder', async () => {
|
test('switch during lazy import never starts the stale decoder', async () => {
|
||||||
let resolve;
|
let resolve;
|
||||||
let constructed = 0;
|
let constructed = 0;
|
||||||
class Hls { constructor() { constructed++; } static isSupported() { return true; } }
|
class Hls {
|
||||||
|
constructor() {
|
||||||
|
constructed++;
|
||||||
|
}
|
||||||
|
static isSupported() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
const source = video();
|
const source = video();
|
||||||
const playback = attachCctvVideo(source, '/api/cctv/media/a', 'hls', { loadHls: () => new Promise((r) => { resolve = r; }) });
|
const playback = attachCctvVideo(source, '/api/cctv/media/a', 'hls', {
|
||||||
|
loadHls: () =>
|
||||||
|
new Promise((r) => {
|
||||||
|
resolve = r;
|
||||||
|
}),
|
||||||
|
});
|
||||||
playback.dispose();
|
playback.dispose();
|
||||||
resolve({ default: Hls });
|
resolve({ default: Hls });
|
||||||
await playback.ready;
|
await playback.ready;
|
||||||
@@ -20,7 +38,12 @@ test('switch during lazy import never starts the stale decoder', async () => {
|
|||||||
});
|
});
|
||||||
test('missing decoder uses honest failure fallback once', async () => {
|
test('missing decoder uses honest failure fallback once', async () => {
|
||||||
let failures = 0;
|
let failures = 0;
|
||||||
const playback = attachCctvVideo(video(), '/api/cctv/media/a', 'hls', { loadHls: async () => { throw new Error('unavailable'); }, onFailure: () => failures++ });
|
const playback = attachCctvVideo(video(), '/api/cctv/media/a', 'hls', {
|
||||||
|
loadHls: async () => {
|
||||||
|
throw new Error('unavailable');
|
||||||
|
},
|
||||||
|
onFailure: () => failures++,
|
||||||
|
});
|
||||||
await playback.ready;
|
await playback.ready;
|
||||||
playback.dispose();
|
playback.dispose();
|
||||||
assert.equal(failures, 1);
|
assert.equal(failures, 1);
|
||||||
@@ -32,7 +55,9 @@ test('native HLS releases its client lease without response-header access', asyn
|
|||||||
const releases = [];
|
const releases = [];
|
||||||
const playback = attachCctvVideo(source, '/api/cctv/media/a', 'hls', {
|
const playback = attachCctvVideo(source, '/api/cctv/media/a', 'hls', {
|
||||||
loadHls: async () => ({ default: { isSupported: () => false } }),
|
loadHls: async () => ({ default: { isSupported: () => false } }),
|
||||||
fetchImpl: async (url, init) => { releases.push({ url, init }); },
|
fetchImpl: async (url, init) => {
|
||||||
|
releases.push({ url, init });
|
||||||
|
},
|
||||||
});
|
});
|
||||||
await playback.ready;
|
await playback.ready;
|
||||||
assert.match(source.src, /\/api\/cctv\/media\/a\?lease=[a-f0-9-]{36}$/);
|
assert.match(source.src, /\/api\/cctv\/media\/a\?lease=[a-f0-9-]{36}$/);
|
||||||
|
|||||||
+11
-3
@@ -39,9 +39,17 @@ export class CctvControls {
|
|||||||
this._actionGeneration = 0;
|
this._actionGeneration = 0;
|
||||||
this._initCctvPanel();
|
this._initCctvPanel();
|
||||||
if (this._cctvVideo && typeof MutationObserver !== 'undefined') {
|
if (this._cctvVideo && typeof MutationObserver !== 'undefined') {
|
||||||
this._videoVisibilityObserver = new MutationObserver(() => this._renderCctvState(this._cctvState));
|
this._videoVisibilityObserver = new MutationObserver(() =>
|
||||||
if (this._cctvPanel) this._videoVisibilityObserver.observe(this._cctvPanel, { attributes: true, attributeFilter: ['class', 'hidden'] });
|
this._renderCctvState(this._cctvState),
|
||||||
this.listen(document, 'visibilitychange', () => this._renderCctvState(this._cctvState));
|
);
|
||||||
|
if (this._cctvPanel)
|
||||||
|
this._videoVisibilityObserver.observe(this._cctvPanel, {
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['class', 'hidden'],
|
||||||
|
});
|
||||||
|
this.listen(document, 'visibilitychange', () =>
|
||||||
|
this._renderCctvState(this._cctvState),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
listen(target, type, handler, options = {}) {
|
listen(target, type, handler, options = {}) {
|
||||||
|
|||||||
@@ -169,14 +169,19 @@ export function _renderCctvState(state) {
|
|||||||
if (this._cctvVideo) {
|
if (this._cctvVideo) {
|
||||||
this._cctvVideo.hidden = !liveIntent;
|
this._cctvVideo.hidden = !liveIntent;
|
||||||
if (this._cctvFrame) this._cctvFrame.hidden = liveIntent;
|
if (this._cctvFrame) this._cctvFrame.hidden = liveIntent;
|
||||||
const visible = liveIntent && !document.hidden && !this._cctvPanel?.classList.contains('collapsed');
|
const visible =
|
||||||
|
liveIntent &&
|
||||||
|
!document.hidden &&
|
||||||
|
!this._cctvPanel?.classList.contains('collapsed');
|
||||||
if (!visible || this._cctvVideoCameraId !== activeId) {
|
if (!visible || this._cctvVideoCameraId !== activeId) {
|
||||||
this._cctvVideoSurface?.stop();
|
this._cctvVideoSurface?.stop();
|
||||||
this._cctvVideoSurface = null;
|
this._cctvVideoSurface = null;
|
||||||
}
|
}
|
||||||
this._cctvVideoCameraId = activeId;
|
this._cctvVideoCameraId = activeId;
|
||||||
if (visible && !this._cctvVideoSurface) {
|
if (visible && !this._cctvVideoSurface) {
|
||||||
this._cctvVideoSurface = createCctvVideoSurface(this._cctvVideo, () => this.cctv.getActiveVideoElement?.());
|
this._cctvVideoSurface = createCctvVideoSurface(this._cctvVideo, () =>
|
||||||
|
this.cctv.getActiveVideoElement?.(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+34
-6
@@ -1,5 +1,12 @@
|
|||||||
/** Paint a second surface from the projection decoder, bounded to 640px/15 fps. */
|
/** Paint a second surface from the projection decoder, bounded to 640px/15 fps. */
|
||||||
export function createCctvVideoSurface(canvas, getVideo, { requestFrame = requestAnimationFrame, cancelFrame = cancelAnimationFrame } = {}) {
|
export function createCctvVideoSurface(
|
||||||
|
canvas,
|
||||||
|
getVideo,
|
||||||
|
{
|
||||||
|
requestFrame = requestAnimationFrame,
|
||||||
|
cancelFrame = cancelAnimationFrame,
|
||||||
|
} = {},
|
||||||
|
) {
|
||||||
const ctx = canvas.getContext('2d');
|
const ctx = canvas.getContext('2d');
|
||||||
let handle = 0;
|
let handle = 0;
|
||||||
let stopped = false;
|
let stopped = false;
|
||||||
@@ -14,18 +21,39 @@ export function createCctvVideoSurface(canvas, getVideo, { requestFrame = reques
|
|||||||
previous = video;
|
previous = video;
|
||||||
previousTime = -1;
|
previousTime = -1;
|
||||||
}
|
}
|
||||||
if (ctx && video?.readyState >= 2 && video.videoWidth > 0 && video.videoHeight > 0 && now - paintedAt >= 1000 / 15 && video.currentTime !== previousTime) {
|
if (
|
||||||
|
ctx &&
|
||||||
|
video?.readyState >= 2 &&
|
||||||
|
video.videoWidth > 0 &&
|
||||||
|
video.videoHeight > 0 &&
|
||||||
|
now - paintedAt >= 1000 / 15 &&
|
||||||
|
video.currentTime !== previousTime
|
||||||
|
) {
|
||||||
const width = Math.min(640, video.videoWidth);
|
const width = Math.min(640, video.videoWidth);
|
||||||
const height = Math.max(1, Math.round(width * video.videoHeight / video.videoWidth));
|
const height = Math.max(
|
||||||
if (canvas.width !== width || canvas.height !== height) { canvas.width = width; canvas.height = height; }
|
1,
|
||||||
|
Math.round((width * video.videoHeight) / video.videoWidth),
|
||||||
|
);
|
||||||
|
if (canvas.width !== width || canvas.height !== height) {
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
ctx.drawImage(video, 0, 0, width, height);
|
ctx.drawImage(video, 0, 0, width, height);
|
||||||
previousTime = video.currentTime;
|
previousTime = video.currentTime;
|
||||||
paintedAt = now;
|
paintedAt = now;
|
||||||
} catch { /* A resolution/decode transition retries on the next frame. */ }
|
} catch {
|
||||||
|
/* A resolution/decode transition retries on the next frame. */
|
||||||
|
}
|
||||||
}
|
}
|
||||||
handle = requestFrame(paint);
|
handle = requestFrame(paint);
|
||||||
};
|
};
|
||||||
handle = requestFrame(paint);
|
handle = requestFrame(paint);
|
||||||
return { stop() { stopped = true; cancelFrame(handle); ctx?.clearRect(0, 0, canvas.width, canvas.height); } };
|
return {
|
||||||
|
stop() {
|
||||||
|
stopped = true;
|
||||||
|
cancelFrame(handle);
|
||||||
|
ctx?.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
@@ -6,16 +6,35 @@ test('second surface uses shared decoder, caps draws, clears switch and cancels
|
|||||||
let draws = 0;
|
let draws = 0;
|
||||||
let clears = 0;
|
let clears = 0;
|
||||||
let cancelled = 0;
|
let cancelled = 0;
|
||||||
const canvas = { width: 1, height: 1, getContext: () => ({ drawImage: () => draws++, clearRect: () => clears++ }) };
|
const canvas = {
|
||||||
let v = { readyState: 2, videoWidth: 1920, videoHeight: 1080, currentTime: 1 };
|
width: 1,
|
||||||
const surface = createCctvVideoSurface(canvas, () => v, { requestFrame: (fn) => { callback = fn; return 1; }, cancelFrame: () => cancelled++ });
|
height: 1,
|
||||||
callback(0); callback(20); callback(80);
|
getContext: () => ({ drawImage: () => draws++, clearRect: () => clears++ }),
|
||||||
|
};
|
||||||
|
let v = {
|
||||||
|
readyState: 2,
|
||||||
|
videoWidth: 1920,
|
||||||
|
videoHeight: 1080,
|
||||||
|
currentTime: 1,
|
||||||
|
};
|
||||||
|
const surface = createCctvVideoSurface(canvas, () => v, {
|
||||||
|
requestFrame: (fn) => {
|
||||||
|
callback = fn;
|
||||||
|
return 1;
|
||||||
|
},
|
||||||
|
cancelFrame: () => cancelled++,
|
||||||
|
});
|
||||||
|
callback(0);
|
||||||
|
callback(20);
|
||||||
|
callback(80);
|
||||||
assert.equal(draws, 1);
|
assert.equal(draws, 1);
|
||||||
assert.equal(canvas.width, 640);
|
assert.equal(canvas.width, 640);
|
||||||
v = { ...v }; callback(160);
|
v = { ...v };
|
||||||
|
callback(160);
|
||||||
assert.equal(draws, 2);
|
assert.equal(draws, 2);
|
||||||
assert.equal(clears, 2);
|
assert.equal(clears, 2);
|
||||||
surface.stop(); callback(200);
|
surface.stop();
|
||||||
|
callback(200);
|
||||||
assert.equal(cancelled, 1);
|
assert.equal(cancelled, 1);
|
||||||
assert.equal(draws, 2);
|
assert.equal(draws, 2);
|
||||||
});
|
});
|
||||||
Reference in new issue
Block a user