diff --git a/src/layers/cctv/source.js b/src/layers/cctv/source.js index bf4363128..0dddce1b4 100644 --- a/src/layers/cctv/source.js +++ b/src/layers/cctv/source.js @@ -51,5 +51,18 @@ export function createCctvSource({ }, getFrameUrl: frameUrlFor, getMediaUrl: mediaUrlFor, + /** Read one current frame through the registered frame endpoint. */ + async getFrame(camera, { signal } = {}) { + signal?.throwIfAborted(); + const response = await fetchImpl(frameUrlFor(camera), { + cache: 'no-store', + signal, + }); + if (!response.ok) throw new Error('Camera frame HTTP ' + response.status); + const bytes = new Uint8Array(await response.arrayBuffer()); + signal?.throwIfAborted(); + const type = response.headers.get('content-type') || ''; + return { contentType: type.split(';')[0].trim().toLowerCase(), bytes }; + }, }; } diff --git a/src/layers/cctv/source.test.mjs b/src/layers/cctv/source.test.mjs index f0e3b0cc3..0103c03de 100644 --- a/src/layers/cctv/source.test.mjs +++ b/src/layers/cctv/source.test.mjs @@ -148,3 +148,28 @@ test('camera construction is inert and destruction cancels a pending catalog and assert.equal(a.getStats().count, 0); assert.equal(b.getStats().count, 0); }); + +test('frames are read through the registered frame endpoint', async () => { + const png = Uint8Array.from([0x89, 0x50, 0x4e, 0x47]); + const requested = []; + const source = createCctvSource({ + fetchImpl: async (url, init) => { + requested.push({ url, cache: init.cache }); + return new Response(png, { + headers: { 'Content-Type': 'Image/PNG; charset=binary' }, + }); + }, + }); + const frame = await source.getFrame(camera); + assert.equal(frame.contentType, 'image/png'); + assert.deepEqual([...frame.bytes], [...png]); + assert.equal( + requested[0].url.split('?')[0], + '/api/cctv/frame/pack%2Fcamera%20%3Fx', + ); + assert.equal(requested[0].cache, 'no-store'); + const failing = createCctvSource({ + fetchImpl: async () => new Response(null, { status: 502 }), + }); + await assert.rejects(failing.getFrame(camera), /Camera frame HTTP 502/); +});