Coverage for orchestrant/streaming/app.py: 90%
58 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-21 01:08 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-21 01:08 +0000
1"""The camera as a live Flet web page: ``python -m orchestrant.streaming``."""
3from __future__ import annotations
5import asyncio
6import os
7import threading
8from typing import TYPE_CHECKING, Any
10import cv2
11from loguru import logger
13from orchestrant.logging_config import setup_logging
14from orchestrant.pipeline.ui.flet_viewer import require_flet, web_server
15from orchestrant.streaming.capture import FrameCapture
18if TYPE_CHECKING:
19 from collections.abc import Callable
21 import numpy as np
24HOST_ENV = "KATAGLYPHIS_STREAM_HOST"
25DEFAULT_HOST = "127.0.0.1"
26PORT = 5000
27JPEG_QUALITY = 30
28FRAME_TIMEOUT_S = 2.0
29WAIT_ON_EMPTY_S = 0.5
32def jpeg(frame: np.ndarray, quality: int = JPEG_QUALITY) -> bytes | None:
33 """One frame as JPEG bytes; None when encoding fails."""
34 ok, buffer = cv2.imencode(".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, quality])
35 return buffer.tobytes() if ok else None
38def pump(
39 loop: asyncio.AbstractEventLoop,
40 image: Any, # noqa: ANN401 - a flet.RawImage
41 capture: FrameCapture,
42 stop: threading.Event,
43) -> None:
44 """Draw the capture's frames into `image` until the tab closes."""
45 while not stop.is_set():
46 frame = capture.get_frame()
47 if frame is None:
48 stop.wait(WAIT_ON_EMPTY_S)
49 continue
50 data = jpeg(frame)
51 if data is None:
52 logger.warning("Frame encoding failed; skipping frame")
53 continue
54 future = asyncio.run_coroutine_threadsafe(image.render_encoded(data), loop)
55 try:
56 future.result(timeout=FRAME_TIMEOUT_S)
57 except TimeoutError:
58 future.cancel()
59 except Exception as exc: # the tab is gone; its pump ends, the server stays
60 logger.debug("Stream session ended: {}", exc)
61 stop.set()
64def stream_page(capture: FrameCapture) -> Callable[[Any], None]:
65 """The page each browser tab gets: a title and the live frame, fed by its own pump."""
66 flet = require_flet()
68 def main(page: Any) -> None: # noqa: ANN401 - a flet.Page
69 page.title = "Live Streaming"
70 page.theme = flet.Theme(color_scheme_seed=flet.Colors.GREEN_ACCENT)
71 image = flet.RawImage(expand=True, fit=flet.BoxFit.CONTAIN)
72 stop = threading.Event()
73 page.on_close = page.on_disconnect = lambda _event: stop.set()
74 page.add(
75 flet.Text("Live Streaming", size=24, weight=flet.FontWeight.BOLD),
76 flet.Container(content=image, expand=True),
77 )
78 page.run_thread(pump, page.loop, image, capture, stop)
80 return main
83def run() -> None:
84 """Serve the Picamera2 stream on port 5000 until Ctrl+C; $KATAGLYPHIS_STREAM_HOST binds it."""
85 setup_logging()
86 host = os.getenv(HOST_ENV, DEFAULT_HOST)
87 capture = FrameCapture()
88 try:
89 logger.info("Streaming at http://{}:{}", host, PORT)
90 web_server(stream_page(capture), host, PORT).run()
91 finally:
92 capture.stop()
95if __name__ == "__main__":
96 run()