From 6f40fff09e7cd18becf712463cdbc6c5c27d3451 Mon Sep 17 00:00:00 2001 From: Bilawal Sidhu Date: Tue, 15 Sep 2026 10:54:40 -0500 Subject: [PATCH] style(cctv): format live video integration --- server/providers/cctv.js | 109 +++++++++++--- server/providers/cctv/sources.js | 14 +- server/providers/cctv/stream.js | 190 +++++++++++++++++++------ src/data/cctvHlsStream.test.mjs | 143 ++++++++++++++++--- src/layers/cctv/presentation.js | 4 +- src/layers/cctv/projection.js | 56 +++++--- src/layers/cctv/rendering.js | 7 +- src/layers/cctv/videoPlayback.js | 56 ++++++-- src/layers/cctv/videoPlayback.test.mjs | 35 ++++- src/ui/cctvControls.js | 14 +- src/ui/cctvPresentation.js | 9 +- src/ui/cctvVideo.js | 40 +++++- src/ui/cctvVideo.test.mjs | 31 +++- 13 files changed, 564 insertions(+), 144 deletions(-) diff --git a/server/providers/cctv.js b/server/providers/cctv.js index 1e7a1a938..7cf16d4ec 100644 --- a/server/providers/cctv.js +++ b/server/providers/cctv.js @@ -132,7 +132,6 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) { const installMiddleware = (server) => { server.httpServer?.on('close', () => { puller.shutdown(); - }); server.middlewares.use('/api/cctv', async (req, res) => { try { @@ -201,48 +200,112 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) { } if (url.pathname.startsWith('/media/')) { - const match = /^\/media\/([^/]+)(?:\/(seg_(\d+)\.ts))?$/.exec(url.pathname); - if (!match) { res.writeHead(404); res.end(); return; } + const match = /^\/media\/([^/]+)(?:\/(seg_(\d+)\.ts))?$/.exec( + url.pathname, + ); + if (!match) { + res.writeHead(404); + res.end(); + return; + } const cameraId = decodeURIComponent(match[1]); const source = sourceById.get(cameraId); const mediaUrl = source?.url || ''; const feedType = normalizeFeedType(source?.feedType || 'image'); 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 (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 (!/^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.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; } if (match[2]) { - const body = puller.getSegment(cameraId, 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 body = puller.getSegment( + cameraId, + 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); let entry; - const cancelPending = () => { if (entry) puller.release(cameraId, leaseId); }; + const cancelPending = () => { + if (entry) puller.release(cameraId, leaseId); + }; try { entry = await puller.ensure(cameraId, mediaUrl, leaseId); - if (downstream.closed) { cancelPending(); return; } - 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) { + cancelPending(); + return; + } + 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 (!playlist) throw new Error('Stream unavailable'); - setHealth(cameraId, { 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 }); + setHealth(cameraId, { + 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); } 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) { - 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' })); } } finally { @@ -250,7 +313,11 @@ export function cctvProxy({ sourceRoot = process.cwd() } = {}) { } 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)) { setHealth(cameraId, { diff --git a/server/providers/cctv/sources.js b/server/providers/cctv/sources.js index 49b7f806e..ad8a883da 100644 --- a/server/providers/cctv/sources.js +++ b/server/providers/cctv/sources.js @@ -1626,8 +1626,18 @@ export async function loadDelDOTSourcesFromOpenData() { // Prefer the catalog's HTTPS HLS transport; no subprocess is required. let stream; - try { 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; + try { + 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 id = String(row?.id || '').trim(); diff --git a/server/providers/cctv/stream.js b/server/providers/cctv/stream.js index e3bb85ab6..14d1743bb 100644 --- a/server/providers/cctv/stream.js +++ b/server/providers/cctv/stream.js @@ -15,7 +15,15 @@ export const HLS_LIMITS = Object.freeze({ }); /** 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 abort = () => controller.abort(); if (signal?.aborted) controller.abort(); @@ -25,11 +33,19 @@ export async function fetchHlsBytes(url, { fetchImpl = fetch, signal, maxBytes, let response; try { controller.signal.throwIfAborted(); - response = await fetchImpl(url, { redirect: 'error', 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)) + response = await fetchImpl(url, { + redirect: 'error', + 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'); - 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?.(); if (!reader) throw new Error('HLS response is not streamable'); const chunks = []; @@ -56,14 +72,22 @@ export async function fetchHlsBytes(url, { fetchImpl = fetch, signal, maxBytes, export function sameOriginHlsUrl(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'); return url.href; } /** Only clear MPEG-TS media playlists: encrypted, fMP4 and byte ranges fail closed. */ 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'); let seq = 0; let duration = null; @@ -73,16 +97,20 @@ export function parseHlsMedia(text, base, limit = HLS_LIMITS.segments) { const line = raw.trim(); if (line.startsWith('#EXT-X-MEDIA-SEQUENCE:')) { 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') { discontinuity = true; } else if (line.startsWith('#EXTINF:')) { 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('#')) { - 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); - 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 }); discontinuity = false; duration = null; @@ -91,7 +119,10 @@ export function parseHlsMedia(text, base, limit = HLS_LIMITS.segments) { return segments.slice(-limit); } -export function createHlsPuller({ fetchImpl = fetch, limits = HLS_LIMITS } = {}) { +export function createHlsPuller({ + fetchImpl = fetch, + limits = HLS_LIMITS, +} = {}) { const active = new Map(); let closed = false; const stop = (cameraId, token) => { @@ -118,34 +149,50 @@ export function createHlsPuller({ fetchImpl = fetch, limits = HLS_LIMITS } = {}) const touch = (entry, leaseId, create = false) => { let lease = entry.leases.get(leaseId); 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 = {}; entry.leases.set(leaseId, lease); } clearTimeout(lease.timer); - lease.timer = setTimeout(() => release(entry.cameraId, leaseId), limits.idleMs); + lease.timer = setTimeout( + () => release(entry.cameraId, leaseId), + limits.idleMs, + ); lease.timer.unref?.(); }; - const read = (entry, url, maxBytes) => fetchHlsBytes(url, { - fetchImpl, signal: entry.controller.signal, maxBytes, timeoutMs: limits.timeoutMs, - }); + const read = (entry, url, maxBytes) => + fetchHlsBytes(url, { + fetchImpl, + signal: entry.controller.signal, + maxBytes, + timeoutMs: limits.timeoutMs, + }); const poll = async (entry) => { try { 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:')) { - const variant = text.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'); + const variant = text + .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); text = (await read(entry, base, limits.playlistBytes)).toString('utf8'); } entry.chunklistUrl = base; const segments = parseHlsMedia(text, base, limits.segments); const newest = segments.at(-1)?.seq ?? -1; - const restarted = newest < entry.upstreamNewest || segments.some((segment) => { - const prior = entry.upstream.get(segment.seq); - return prior && prior !== segment.uri; - }); + const restarted = + newest < entry.upstreamNewest || + segments.some((segment) => { + const prior = entry.upstream.get(segment.seq); + return prior && prior !== segment.uri; + }); if (restarted) { entry.upstream.clear(); entry.pendingDiscontinuity = true; @@ -158,29 +205,45 @@ export function createHlsPuller({ fetchImpl = fetch, limits = HLS_LIMITS } = {}) if (entry.stopping) return; // Evict BEFORE retaining the incoming segment. One bounded download may // 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 removed = entry.segments.get(oldest); entry.bytes -= removed.body.length; if (removed.discontinuity) entry.discontinuitiesRemoved++; 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++; - 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.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.failures = 0; } catch { entry.chunklistUrl = null; entry.failures++; - if (entry.failures >= 3) { stop(entry.cameraId, entry.token); return; } + if (entry.failures >= 3) { + stop(entry.cameraId, entry.token); + return; + } } finally { if (!entry.stopping) { - entry.timer = setTimeout(() => { entry.polling = poll(entry); }, limits.pollMs); + entry.timer = setTimeout(() => { + entry.polling = poll(entry); + }, limits.pollMs); entry.timer.unref?.(); } } @@ -189,11 +252,33 @@ export function createHlsPuller({ fetchImpl = fetch, limits = HLS_LIMITS } = {}) if (closed) throw new Error('HLS service closed'); sameOriginHlsUrl(url, url); let entry = active.get(cameraId); - if (entry && entry.url !== url) { stop(cameraId); entry = null; } - if (entry) { touch(entry, leaseId, true); return entry; } + if (entry && entry.url !== url) { + stop(cameraId); + entry = null; + } + if (entry) { + touch(entry, leaseId, true); + return entry; + } // Reserve synchronously, before any await: concurrent creation is singleflight. - if (active.size >= limits.sessions) 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 }; + if (active.size >= limits.sessions) + 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); touch(entry, leaseId, true); 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; touch(entry, leaseId); 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; for (const segment of segments) { - if (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)}`); + if ( + 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; } return lines.join('\n') + '\n'; }; const getSegment = (cameraId, token, seq, leaseId = 'legacy') => { 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; if (body) touch(entry, leaseId); return body || null; @@ -233,6 +332,17 @@ export function createHlsPuller({ fetchImpl = fetch, limits = HLS_LIMITS } = {}) for (const entry of entries) stop(entry.cameraId); await Promise.allSettled(entries.map((entry) => entry.polling)); }; - return { ensure, waitReady, buildPlaylist, getSegment, stop, release, shutdown, - stats: () => ({ sessions: active.size, bytes: [...active.values()].reduce((total, e) => total + e.bytes, 0) }) }; + return { + ensure, + waitReady, + buildPlaylist, + getSegment, + stop, + release, + shutdown, + stats: () => ({ + sessions: active.size, + bytes: [...active.values()].reduce((total, e) => total + e.bytes, 0), + }), + }; } diff --git a/src/data/cctvHlsStream.test.mjs b/src/data/cctvHlsStream.test.mjs index 6d9e08d5d..a087eebdf 100644 --- a/src/data/cctvHlsStream.test.mjs +++ b/src/data/cctvHlsStream.test.mjs @@ -1,25 +1,77 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import { 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'; +import { + 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'; test('playlist parser uses sequence, refuses escaping and unsupported references', () => { - assert.deepEqual(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)); + assert.deepEqual( + 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 () => { 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'); - await assert.rejects(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' } }) })); + await assert.rejects( + 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 () => { - 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 [a, b] = await Promise.all([manager.ensure('a', base), manager.ensure('a', base)]); + 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 [a, b] = await Promise.all([ + manager.ensure('a', base), + manager.ensure('a', base), + ]); assert.equal(a, b); await assert.rejects(manager.ensure('b', base)); 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 () => { 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); await manager.shutdown(); 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 () => { 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 new Promise((r) => setTimeout(r, 50)); 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 () => { 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); await manager.waitReady(entry); 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'); await new Promise((r) => setTimeout(r, 30)); 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(); }); 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[1].discontinuity, true); }); test('a reused agency sequence with changed segment URI cannot remain stale', async () => { 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); await manager.waitReady(entry); current = playlist.replaceAll('.ts', '.ts?generation=2'); 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(); }); test('two consumers share downloads but release and abandoned expiry are independent', async () => { let downloads = 0; - const manager = createHlsPuller({ limits: { ...HLS_LIMITS, pollMs: 100000, leasesPerSession: 2 }, fetchImpl: async (url) => { - downloads++; - 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')]); + const manager = createHlsPuller({ + limits: { ...HLS_LIMITS, pollMs: 100000, leasesPerSession: 2 }, + fetchImpl: async (url) => { + downloads++; + 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); await manager.waitReady(a); 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.getSegment('camera', a.token, 0, 'viewer-a'), null); 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. assert.equal(b.controller.signal.aborted, false); 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 () => { - 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'); await manager.ensure('camera', base, 'active'); await new Promise((r) => setTimeout(r, 50)); diff --git a/src/layers/cctv/presentation.js b/src/layers/cctv/presentation.js index 7daa8c1e5..1ee54df50 100644 --- a/src/layers/cctv/presentation.js +++ b/src/layers/cctv/presentation.js @@ -83,7 +83,9 @@ export function createPresentation({ mountHeightM: camera.mountHeightM, active: isActive, feedType: camera.feedType, - isVideo: parts.model.isVideoFeedType(camera.feedType) && record.projection?.mode !== 'image', + isVideo: + parts.model.isVideoFeedType(camera.feedType) && + record.projection?.mode !== 'image', sourceKind: health?.sourceKind || camera.sourceKind || diff --git a/src/layers/cctv/projection.js b/src/layers/cctv/projection.js index edac3a66a..e32db4fd4 100644 --- a/src/layers/cctv/projection.js +++ b/src/layers/cctv/projection.js @@ -233,20 +233,32 @@ export function createProjection({ video.addEventListener('loadedmetadata', bindVideoTexture); video.addEventListener('resize', bindVideoTexture); runtime.video = video; - runtime.playback = attachCctvVideo(video, parts.frames.mediaUrlFor(record.camera), feedType, { - onFailure: () => { - if (runtime.disposed) return; - runtime.video = null; - runtime.mode = 'image'; - runtime.image = new Image(); - runtime.image.decoding = 'async'; - 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(); + runtime.playback = attachCctvVideo( + video, + parts.frames.mediaUrlFor(record.camera), + feedType, + { + onFailure: () => { + if (runtime.disposed) return; + runtime.video = null; + runtime.mode = 'image'; + runtime.image = new Image(); + runtime.image.decoding = 'async'; + 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 { const img = new Image(); img.decoding = 'async'; @@ -294,7 +306,8 @@ export function createProjection({ function ensureProjectionRuntime(record) { 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); record.projection = runtime; if (runtime) { @@ -314,7 +327,11 @@ export function createProjection({ runtime.disposed = true; runtime.playback?.dispose(); 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) { runtime.video.pause(); runtime.video.removeAttribute('src'); @@ -392,16 +409,15 @@ export function createProjection({ function pauseInactiveProjectionFeeds(activeId) { for (const record of layerState._records) { if (!record.projection?.video) continue; - if ( - record.camera.id === activeId && - layerState._enabled - ) { + if (record.camera.id === activeId && layerState._enabled) { record.projection.video.play().catch(() => {}); } else { const runtime = record.projection; destroyProjectionRuntime(runtime); record.projection = null; - layerState._projectionEntities = layerState._projectionEntities.filter((entry) => entry !== runtime); + layerState._projectionEntities = layerState._projectionEntities.filter( + (entry) => entry !== runtime, + ); } } } diff --git a/src/layers/cctv/rendering.js b/src/layers/cctv/rendering.js index 13bb2de22..faac69eb8 100644 --- a/src/layers/cctv/rendering.js +++ b/src/layers/cctv/rendering.js @@ -215,7 +215,12 @@ export function createRendering({ // (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); } // One live plane in the world at a time (§2c): only the active camera's diff --git a/src/layers/cctv/videoPlayback.js b/src/layers/cctv/videoPlayback.js index a43f2dc2e..305d8e7fd 100644 --- a/src/layers/cctv/videoPlayback.js +++ b/src/layers/cctv/videoPlayback.js @@ -1,20 +1,30 @@ /** One decoder per active camera; both surfaces consume this video element. */ -export function attachCctvVideo(video, url, feedType, { - loadHls = () => import('hls.js'), - onFailure = () => {}, - fetchImpl = globalThis.fetch, -} = {}) { +export function attachCctvVideo( + video, + url, + feedType, + { + loadHls = () => import('hls.js'), + onFailure = () => {}, + fetchImpl = globalThis.fetch, + } = {}, +) { let disposed = false; let hls = 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 startup = null; let governor = null; let retries = 0; const release = () => { if (feedType === 'hls') { - void fetchImpl?.(mediaUrl, { method: 'DELETE', keepalive: true }).catch(() => {}); + void fetchImpl?.(mediaUrl, { method: 'DELETE', keepalive: true }).catch( + () => {}, + ); } }; const dispose = () => { @@ -46,19 +56,33 @@ export function attachCctvVideo(video, url, feedType, { video.addEventListener('error', fail); startup = setTimeout(fail, 30000); const ready = (async () => { - if (feedType !== 'hls') { video.src = mediaUrl; return; } + if (feedType !== 'hls') { + video.src = mediaUrl; + return; + } try { const { default: Hls } = await loadHls(); if (disposed) return; 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(); 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) => { if (disposed || !data?.fatal) return; - if (++retries > 2) { fail(); return; } + if (++retries > 2) { + fail(); + return; + } clearTimeout(timer); timer = setTimeout(() => { if (disposed) return; @@ -68,14 +92,18 @@ export function attachCctvVideo(video, url, feedType, { }); governor = setInterval(() => { 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 // 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); hls.attachMedia(video); hls.loadSource(mediaUrl); - } catch { fail(); } + } catch { + fail(); + } })(); return { dispose, ready }; } diff --git a/src/layers/cctv/videoPlayback.test.mjs b/src/layers/cctv/videoPlayback.test.mjs index 6a8d41c4a..af6f2b5ac 100644 --- a/src/layers/cctv/videoPlayback.test.mjs +++ b/src/layers/cctv/videoPlayback.test.mjs @@ -3,15 +3,33 @@ import assert from 'node:assert/strict'; import { attachCctvVideo } from './videoPlayback.js'; function video() { 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; } test('switch during lazy import never starts the stale decoder', async () => { let resolve; let constructed = 0; - class Hls { constructor() { constructed++; } static isSupported() { return true; } } + class Hls { + constructor() { + constructed++; + } + static isSupported() { + return true; + } + } 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(); resolve({ default: Hls }); 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 () => { 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; playback.dispose(); assert.equal(failures, 1); @@ -32,7 +55,9 @@ test('native HLS releases its client lease without response-header access', asyn const releases = []; const playback = attachCctvVideo(source, '/api/cctv/media/a', 'hls', { loadHls: async () => ({ default: { isSupported: () => false } }), - fetchImpl: async (url, init) => { releases.push({ url, init }); }, + fetchImpl: async (url, init) => { + releases.push({ url, init }); + }, }); await playback.ready; assert.match(source.src, /\/api\/cctv\/media\/a\?lease=[a-f0-9-]{36}$/); diff --git a/src/ui/cctvControls.js b/src/ui/cctvControls.js index 10594ea76..2626fb719 100644 --- a/src/ui/cctvControls.js +++ b/src/ui/cctvControls.js @@ -39,9 +39,17 @@ export class CctvControls { this._actionGeneration = 0; this._initCctvPanel(); if (this._cctvVideo && typeof MutationObserver !== 'undefined') { - this._videoVisibilityObserver = new MutationObserver(() => 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)); + this._videoVisibilityObserver = new MutationObserver(() => + 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 = {}) { diff --git a/src/ui/cctvPresentation.js b/src/ui/cctvPresentation.js index 01fc3b1f6..fef358099 100644 --- a/src/ui/cctvPresentation.js +++ b/src/ui/cctvPresentation.js @@ -169,14 +169,19 @@ export function _renderCctvState(state) { if (this._cctvVideo) { this._cctvVideo.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) { this._cctvVideoSurface?.stop(); this._cctvVideoSurface = null; } this._cctvVideoCameraId = activeId; if (visible && !this._cctvVideoSurface) { - this._cctvVideoSurface = createCctvVideoSurface(this._cctvVideo, () => this.cctv.getActiveVideoElement?.()); + this._cctvVideoSurface = createCctvVideoSurface(this._cctvVideo, () => + this.cctv.getActiveVideoElement?.(), + ); } } diff --git a/src/ui/cctvVideo.js b/src/ui/cctvVideo.js index c5adc26f4..59658f5f6 100644 --- a/src/ui/cctvVideo.js +++ b/src/ui/cctvVideo.js @@ -1,5 +1,12 @@ /** 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'); let handle = 0; let stopped = false; @@ -14,18 +21,39 @@ export function createCctvVideoSurface(canvas, getVideo, { requestFrame = reques previous = video; 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 height = Math.max(1, Math.round(width * video.videoHeight / video.videoWidth)); - if (canvas.width !== width || canvas.height !== height) { canvas.width = width; canvas.height = height; } + const height = Math.max( + 1, + Math.round((width * video.videoHeight) / video.videoWidth), + ); + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + } try { ctx.drawImage(video, 0, 0, width, height); previousTime = video.currentTime; 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); - 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); + }, + }; } diff --git a/src/ui/cctvVideo.test.mjs b/src/ui/cctvVideo.test.mjs index 559fc6285..51af2b40d 100644 --- a/src/ui/cctvVideo.test.mjs +++ b/src/ui/cctvVideo.test.mjs @@ -6,16 +6,35 @@ test('second surface uses shared decoder, caps draws, clears switch and cancels let draws = 0; let clears = 0; let cancelled = 0; - const canvas = { width: 1, height: 1, 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); + const canvas = { + width: 1, + height: 1, + 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(canvas.width, 640); - v = { ...v }; callback(160); + v = { ...v }; + callback(160); assert.equal(draws, 2); assert.equal(clears, 2); - surface.stop(); callback(200); + surface.stop(); + callback(200); assert.equal(cancelled, 1); assert.equal(draws, 2); });