10.4. Спільний цикл захоплення для кількох глядачів#

Кожен підключений клієнт, який незалежно викликає csi0.snapshot(), є марнотратством, а коли одночасно відкрито два потоки, ситуація погіршується: датчик доставляє кадри з власною швидкістю, і кожне дубльоване захоплення сповільнює всіх. Правильний підхід — одна корутина захоплення, яка публікує «останній кадр» у спільний слот, а також ітератори для кожного клієнта, які читають із цього слота.

One capture task writes JPEG bytes to a single latest_jpeg slot; three stream-client iterators read from the slot and each wait on the shared new_frame event.

10.4.1. Завдання захоплення#

Фонова корутина захоплює кадри так швидко, як їх доставляє датчик, стискає кожен у JPEG у спільний bytes і генерує подію, щоб усі очікуючі клієнти прокинулись:

latest_jpeg = None
new_frame = asyncio.Event()

async def capture_loop():
    global latest_jpeg
    while True:
        img = await csi0.snapshot()
        latest_jpeg = bytes(img.compress(quality=85).bytearray())
        new_frame.set()
        new_frame.clear()

Пара set() / clear() — це шаблон імпульсу. set() одночасно розблоковує всі корутини, що наразі очікують на подію; clear() негайно скидає подію, щоб наступний wait() знову заблокувався. За наявності кількох споживачів (один глядач, інший глядач, будь-яка інша корутина, яка повинна реагувати на новий кадр), жоден окремий споживач не відповідає за скидання події, і ніхто не «краде» пробудження в іншого.

Примітка

Обгортка bytes(...) навколо JPEG тут є принципово важливою. bytearray() повертає вигляд у кадровий буфер камери; наступний виклик snapshot() одразу перезаписує цей буфер наступним кадром. latest_jpeg живе довше, ніж локальна img, тому без копіювання кожен читач бачив би, як слот змінюється під ним при кожному захопленні.

10.4.2. Ітератори для кожного клієнта читають із слота#

Обробник потоку MJPEG більше не викликає csi0.snapshot() самостійно. Натомість кожен екземпляр FrameStream очікує на спільну подію і читає зі спільних байтів:

class FrameStream:
    # One instance per connected client. Each one independently
    # waits on the shared new_frame pulse; the capture loop is
    # responsible for resetting the event between frames.

    def __aiter__(self):
        return self

    async def __anext__(self):
        await new_frame.wait()
        if latest_jpeg is None:
            return b''
        return (b'--' + BOUNDARY + b'\r\n'
                b'Content-Type: image/jpeg\r\n\r\n'
                + latest_jpeg + b'\r\n')

Маршрут snapshot теж змінюється: він більше не ініціює захоплення, а повертає те, що зараз зберігається в latest_jpeg:

@app.get('/snapshot.jpg')
async def snapshot(request):
    if latest_jpeg is None:
        return 'no frame yet', 503
    return Response(
        body=latest_jpeg,
        headers={'Content-Type': 'image/jpeg'},
    )

Кортеж (body, status) — це скорочений синтаксис microdot для встановлення HTTP-коду статусу без конструювання microdot.Response. 503 означає я тут, але не готовий — правильний код для «спробуйте ще раз за мить».

10.4.3. Запуск захоплення разом із сервером#

main тепер має дві верхньорівневі корутини: цикл захоплення та HTTP-сервер. asyncio.gather() запускає їх обидві, і якщо одна з них завершиться з помилкою, інша скасовується:

async def main():
    await asyncio.gather(
        capture_loop(),
        app.start_server(host='0.0.0.0', port=80),
    )

asyncio.run(main())

Тепер датчик зчитує один кадр за цикл незалежно від кількості підключених глядачів. Перший браузер, що підключається до /stream.jpg, бачить кадри; так само другий, третій, десятий — усі вони використовують одне й те саме захоплення, і камера залишається так само чуйною на інших маршрутах.