Add FPS throttling to capture, processing, and send loops

All three frame pipeline loops were running unthrottled, consuming
excessive CPU. Now each sleeps for the remaining frame budget after
completing work.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-18 01:39:18 +03:00
parent cc91ccd75a
commit c4955bcb34
2 changed files with 23 additions and 15 deletions

View File

@@ -141,8 +141,11 @@ class ScreenCaptureLiveStream(LiveStream):
except Exception as e: except Exception as e:
logger.error(f"Capture error (display={self._capture_stream.display_index}): {e}") logger.error(f"Capture error (display={self._capture_stream.display_index}): {e}")
# No FPS throttling - capture as fast as frames are available # Throttle to target FPS
# But sleep briefly during idle periods to avoid burning CPU elapsed = time.time() - loop_start
remaining = frame_time - elapsed
if remaining > 0:
time.sleep(remaining)
class ProcessedLiveStream(LiveStream): class ProcessedLiveStream(LiveStream):
@@ -209,18 +212,17 @@ class ProcessedLiveStream(LiveStream):
# processed by a consumer), so the 3rd slot is always safe to reuse. # processed by a consumer), so the 3rd slot is always safe to reuse.
_ring: List[Optional[np.ndarray]] = [None, None, None] _ring: List[Optional[np.ndarray]] = [None, None, None]
_ring_idx = 0 _ring_idx = 0
frame_time = 1.0 / self._source.target_fps if self._source.target_fps > 0 else 1.0
while self._running: while self._running:
source_frame = self._source.get_latest_frame() loop_start = time.time()
if source_frame is None:
# Small sleep when waiting for frames to avoid CPU spinning
time.sleep(0.001)
continue
# Identity cache: Skip processing duplicate frames to save CPU source_frame = self._source.get_latest_frame()
# (Compare object identity to detect when capture engine returns same frame) if source_frame is None or source_frame is cached_source_frame:
if source_frame is cached_source_frame: # Sleep until next frame is expected
time.sleep(0.001) elapsed = time.time() - loop_start
remaining = frame_time - elapsed
time.sleep(max(remaining, 0.001))
continue continue
cached_source_frame = source_frame cached_source_frame = source_frame

View File

@@ -859,8 +859,11 @@ class ProcessorManager:
state.metrics.last_error = str(e) state.metrics.last_error = str(e)
logger.error(f"Processing error for target {target_id}: {e}", exc_info=True) logger.error(f"Processing error for target {target_id}: {e}", exc_info=True)
# No FPS control - process frames as fast as they arrive (match test behavior) # Throttle to target FPS
pass elapsed = time.time() - loop_start
remaining = frame_time - elapsed
if remaining > 0:
await asyncio.sleep(remaining)
except asyncio.CancelledError: except asyncio.CancelledError:
logger.info(f"Processing loop cancelled for target {target_id}") logger.info(f"Processing loop cancelled for target {target_id}")
@@ -1503,8 +1506,11 @@ class ProcessorManager:
state.metrics.last_error = str(e) state.metrics.last_error = str(e)
logger.error(f"KC processing error for {target_id}: {e}", exc_info=True) logger.error(f"KC processing error for {target_id}: {e}", exc_info=True)
# No FPS control - process frames as fast as they arrive (match test behavior) # Throttle to target FPS
pass elapsed = time.time() - loop_start
remaining = frame_time - elapsed
if remaining > 0:
await asyncio.sleep(remaining)
except asyncio.CancelledError: except asyncio.CancelledError:
logger.info(f"KC processing loop cancelled for target {target_id}") logger.info(f"KC processing loop cancelled for target {target_id}")