reactor-runtime 3.2.1__tar.gz → 3.2.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/PKG-INFO +2 -2
  2. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/pyproject.toml +2 -2
  3. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/pyproject.toml.orig +2 -2
  4. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/core/values.py +12 -9
  5. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/recording/recorder.py +124 -19
  6. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/runner/runner.py +16 -6
  7. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/transport/webrtc/peer.py +5 -6
  8. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/LICENSE +0 -0
  9. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/NOTICE +0 -0
  10. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/README.md +0 -0
  11. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/__init__.py +0 -0
  12. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/codes.py +0 -0
  13. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/core/__init__.py +0 -0
  14. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/core/fields.py +0 -0
  15. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/core/model.py +0 -0
  16. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/core/naming.py +0 -0
  17. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/core/service.py +0 -0
  18. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/core/session.py +0 -0
  19. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/core/transport.py +0 -0
  20. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/core/typespec.py +0 -0
  21. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/event_stream.py +0 -0
  22. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/http/__init__.py +0 -0
  23. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/http/events.py +0 -0
  24. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/http/routes.py +0 -0
  25. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/http/server.py +0 -0
  26. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/http/spec.py +0 -0
  27. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/interface/__init__.py +0 -0
  28. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/interface/client.py +0 -0
  29. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/interface/events/__init__.py +0 -0
  30. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/interface/events/decorators.py +0 -0
  31. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/interface/events/errors.py +0 -0
  32. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/interface/events/messages.py +0 -0
  33. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/interface/internal/__init__.py +0 -0
  34. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/interface/internal/bridge.py +0 -0
  35. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/interface/internal/input_buffer.py +0 -0
  36. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/interface/internal/reactor_core.py +0 -0
  37. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/interface/model/__init__.py +0 -0
  38. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/interface/model/contract.py +0 -0
  39. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/interface/model/reactor_model.py +0 -0
  40. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/interface/model/schema.py +0 -0
  41. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/interface/pipeline/__init__.py +0 -0
  42. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/interface/pipeline/idle.py +0 -0
  43. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/interface/pipeline/input_state.py +0 -0
  44. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/interface/pipeline/reactor_pipeline.py +0 -0
  45. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/interface/tracks/__init__.py +0 -0
  46. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/interface/tracks/descriptors.py +0 -0
  47. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/interface/tracks/input.py +0 -0
  48. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/interface/tracks/output.py +0 -0
  49. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/log.py +0 -0
  50. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/manifest.py +0 -0
  51. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/message_gateway.py +0 -0
  52. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/metrics.py +0 -0
  53. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/paths.py +0 -0
  54. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/protocol/__init__.py +0 -0
  55. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/protocol/base.py +0 -0
  56. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/protocol/common.py +0 -0
  57. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/protocol/v0/__init__.py +0 -0
  58. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/protocol/v0/codec.py +0 -0
  59. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/protocol/v1/__init__.py +0 -0
  60. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/protocol/v1/codec.py +0 -0
  61. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/py.typed +0 -0
  62. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/recording/__init__.py +0 -0
  63. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/recording/chunk_encoder.py +0 -0
  64. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/recording/markers.py +0 -0
  65. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/runner/__init__.py +0 -0
  66. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/runner/connection_manager.py +0 -0
  67. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/runner/offer_epochs.py +0 -0
  68. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/runner/state_machine.py +0 -0
  69. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/schema.py +0 -0
  70. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/serve.py +0 -0
  71. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/service.py +0 -0
  72. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/transport/__init__.py +0 -0
  73. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/transport/acceptor.py +0 -0
  74. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/transport/router.py +0 -0
  75. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/transport/webrtc/__init__.py +0 -0
  76. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/transport/webrtc/acceptor.py +0 -0
  77. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/transport/webrtc/config.py +0 -0
  78. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/transport/webrtc/connection.py +0 -0
  79. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/transport/webrtc/frames.py +0 -0
  80. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/transport/webrtc/pacer.py +0 -0
  81. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/transport/webrtc/router.py +0 -0
  82. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/transport/webrtc/sdp.py +0 -0
  83. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/transport/webrtc/signaling.py +0 -0
  84. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/transport/webrtc/stats.py +0 -0
  85. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/transport/webrtc/version.py +0 -0
  86. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_runtime/upload_store.py +0 -0
  87. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_wire/v1/common_pb2.py +0 -0
  88. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_wire/v1/common_pb2.pyi +0 -0
  89. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_wire/v1/control_pb2.py +0 -0
  90. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_wire/v1/control_pb2.pyi +0 -0
  91. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_wire/v1/data_pb2.py +0 -0
  92. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_wire/v1/data_pb2.pyi +0 -0
  93. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_wire/v1/model_pb2.py +0 -0
  94. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_wire/v1/model_pb2.pyi +0 -0
  95. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_wire/v1/platform_pb2.py +0 -0
  96. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_wire/v1/platform_pb2.pyi +0 -0
  97. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_wire/v1/track_pb2.py +0 -0
  98. {reactor_runtime-3.2.1 → reactor_runtime-3.2.3}/src/reactor_wire/v1/track_pb2.pyi +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reactor-runtime
3
- Version: 3.2.1
3
+ Version: 3.2.3
4
4
  Summary: A Python framework for building real-time, interactive video models
5
5
  Author: Reactor
6
6
  Author-email: Reactor <team@reactor.inc>
@@ -18,7 +18,7 @@ Requires-Dist: numpy>=2.1
18
18
  Requires-Dist: prometheus-client>=0.26.0
19
19
  Requires-Dist: protobuf>=7.35.1
20
20
  Requires-Dist: pyyaml>=6.0.3
21
- Requires-Dist: reactor-webrtc==0.10.0
21
+ Requires-Dist: reactor-webrtc==0.12.0
22
22
  Requires-Dist: uvicorn>=0.49.0
23
23
  Requires-Python: >=3.12
24
24
  Project-URL: Source, https://github.com/reactor-team/reactor-runtime
@@ -92,7 +92,7 @@ pythonpath = ["."]
92
92
 
93
93
  [project]
94
94
  name = "reactor-runtime"
95
- version = "3.2.1"
95
+ version = "3.2.3"
96
96
  description = "A Python framework for building real-time, interactive video models"
97
97
  readme = "README.md"
98
98
  license = "Apache-2.0"
@@ -115,7 +115,7 @@ dependencies = [
115
115
  "prometheus-client>=0.26.0",
116
116
  "protobuf>=7.35.1",
117
117
  "pyyaml>=6.0.3",
118
- "reactor-webrtc==0.10.0",
118
+ "reactor-webrtc==0.12.0",
119
119
  "uvicorn>=0.49.0",
120
120
  ]
121
121
 
@@ -20,7 +20,7 @@ version = "1.20260814.7"
20
20
 
21
21
  [project]
22
22
  name = "reactor-runtime"
23
- version = "3.2.1"
23
+ version = "3.2.3"
24
24
  description = "A Python framework for building real-time, interactive video models"
25
25
  readme = "README.md"
26
26
  license = "Apache-2.0"
@@ -41,7 +41,7 @@ dependencies = [
41
41
  "prometheus-client>=0.26.0",
42
42
  "protobuf>=7.35.1",
43
43
  "pyyaml>=6.0.3",
44
- "reactor-webrtc==0.10.0",
44
+ "reactor-webrtc==0.12.0",
45
45
  "uvicorn>=0.49.0",
46
46
  ]
47
47
 
@@ -64,15 +64,18 @@ class InputFrame:
64
64
  metadata: What the sender attached to this frame, as the bytes it sent,
65
65
  or ``None`` for a frame that carried nothing. Decoding them is the
66
66
  model's business: the transport treats them as opaque.
67
- capture_time_us: The microsecond the sender stamped this frame as it went
68
- to the wire — its capture instant for a source that sends as it
69
- captures — or ``None`` for a frame that carried no stamp. The wire
70
- value, unrounded, in the unit the sender's own SDK works in. It rides
71
- with the metadata, so a sender or a transport that carries neither
72
- leaves both unset. Unlike ``pts``, it is a reading of another
73
- machine's clock: differences between stamps from one sender are that
74
- source's own timing, while anything subtracted from a local clock is
75
- mostly the offset between two clocks that drift independently.
67
+ capture_time_us: When the sender says this frame was captured, in
68
+ microseconds, or ``None`` for a frame that carried no stamp. The value
69
+ the sender declared, unrounded — a client that stamps several tracks
70
+ from one clock reading therefore delivers that one value on all of
71
+ them, which is what makes a multi-camera capture readable as a single
72
+ moment. A sender that declares nothing has its transport read a clock
73
+ for it, so a stamped frame is the normal case. It rides with the
74
+ metadata, so a sender or a transport that carries neither leaves both
75
+ unset. Unlike ``pts``, it is a reading of another machine's clock:
76
+ differences between stamps from one sender are that source's own
77
+ timing, while anything subtracted from a local clock is mostly the
78
+ offset between two clocks that drift independently.
76
79
  """
77
80
 
78
81
  data: npt.NDArray[Any]
@@ -15,7 +15,6 @@ director learns a clip is genuinely fetchable rather than merely requested.
15
15
 
16
16
  from __future__ import annotations
17
17
 
18
- import contextlib
19
18
  import math
20
19
  import queue
21
20
  import re
@@ -25,6 +24,7 @@ import threading
25
24
  import time
26
25
  from collections.abc import Callable
27
26
  from dataclasses import dataclass
27
+ from enum import Enum
28
28
  from pathlib import Path
29
29
  from typing import Any
30
30
  from urllib.parse import urlencode
@@ -45,6 +45,21 @@ logger = get_logger(__name__)
45
45
  # model that runs slower or faster than real time still records at true duration.
46
46
  RECORDING_FPS = 30
47
47
 
48
+ # How many grid frames may sit queued between the model thread and the encoder.
49
+ # Never applied below one emission's worth, so a model that batches always fits
50
+ # a whole emission: the queue absorbs a burst and drains it between emissions,
51
+ # rather than gating the burst at its own depth.
52
+ _FEED_DEPTH = 4
53
+ # How long one emission may wait, in total, for the encoder to make room. Bounds
54
+ # the producer's exposure to a wedged encoder: past the deadline the rest of the
55
+ # emission is counted and abandoned, so a stalled recording costs the recording
56
+ # rather than the session.
57
+ _FEED_WAIT_SECONDS = 1.0
58
+ # How often the feed reports dropped frames. An encoder that stays behind loses
59
+ # frames on every emission, so the count is carried on every recording's summary
60
+ # and only the periodic warning is rate-limited.
61
+ _DROP_LOG_INTERVAL_SECONDS = 5.0
62
+
48
63
  _INIT_FILENAME = "init.mp4"
49
64
  # Written into a recording's directory once it is finished, so its final segment
50
65
  # (which has no successor to prove it closed) is recognised as fetchable.
@@ -72,6 +87,23 @@ _AudioArray = npt.NDArray[Any]
72
87
  _FeedItem = tuple[npt.NDArray[Any], _AudioArray | None]
73
88
 
74
89
 
90
+ class _Slot(Enum):
91
+ """The answer to asking the feed queue for room for one grid frame.
92
+
93
+ Separates the two reasons room is refused, because only one of them is a
94
+ fact about the recording's health: frames abandoned because the encoder is
95
+ behind are the recording losing media, while frames abandoned because the
96
+ recording is stopping are teardown and say nothing about the encoder.
97
+ """
98
+
99
+ TAKEN = "taken"
100
+ """There is room; the frame can be queued."""
101
+ FULL = "full"
102
+ """The encoder is behind, and any wait the chunk asked for has run out."""
103
+ WINDING_DOWN = "winding_down"
104
+ """The recording is stopping, so the rest of the emission is moot."""
105
+
106
+
75
107
  class RecorderError(Exception):
76
108
  """Base for a clip request the recorder cannot serve."""
77
109
 
@@ -205,7 +237,13 @@ class Recorder:
205
237
  self._started = False
206
238
  self._disabled = False
207
239
 
208
- self._feed_queue: queue.Queue[_FeedItem | None] = queue.Queue(maxsize=4)
240
+ # The queue is unbounded in itself; the depth is the bound, checked as
241
+ # each frame is queued. The effective capacity never sits below one
242
+ # emission, so a batching model always fits a whole emission.
243
+ self._feed_queue: queue.Queue[_FeedItem | None] = queue.Queue()
244
+ # Signalled by the feed worker after each dequeue so a producer waiting
245
+ # for room sleeps until the encoder takes a frame instead of polling.
246
+ self._feed_room = threading.Condition()
209
247
  self._feed_thread: threading.Thread | None = None
210
248
  self._feed_stop = threading.Event()
211
249
  self._watch_thread: threading.Thread | None = None
@@ -227,6 +265,9 @@ class Recorder:
227
265
  self._chunk_lock = threading.Lock()
228
266
 
229
267
  self._dropped_frames = 0
268
+ # When the feed last warned about dropped frames. Zero until it has, so
269
+ # the first loss of a session is always reported.
270
+ self._dropped_logged_at = 0.0
230
271
  # Fractional grid frames carried between chunks so resampling a chunk's
231
272
  # own rate onto the fixed recording grid accumulates no rounding drift.
232
273
  self._grid_debt = 0.0
@@ -311,8 +352,11 @@ class Recorder:
311
352
  return
312
353
  self._disabled = True
313
354
  self._feed_stop.set()
314
- with contextlib.suppress(queue.Full):
315
- self._feed_queue.put_nowait(None)
355
+ self._feed_queue.put_nowait(None)
356
+ # Release a producer parked on a full queue, so teardown cannot wait
357
+ # behind an encoder that has already stopped draining.
358
+ with self._feed_room:
359
+ self._feed_room.notify_all()
316
360
  feed_thread = self._feed_thread
317
361
  self._feed_thread = None
318
362
  if self._encoder is not None:
@@ -410,6 +454,7 @@ class Recorder:
410
454
  self._audio_sample_rate = 48_000
411
455
  self._has_audio = False
412
456
  self._dropped_frames = 0
457
+ self._dropped_logged_at = 0.0
413
458
  self._grid_debt = 0.0
414
459
  self._audio_jitter_buf = []
415
460
  self._audio_buffered_samples = 0
@@ -423,7 +468,7 @@ class Recorder:
423
468
  # -- media fan-out tap ----------------------------------------------------
424
469
 
425
470
  def on_chunk(self, chunk: MediaChunk) -> None:
426
- """Feed one emitted media chunk to the recording; non-blocking.
471
+ """Feed one emitted media chunk to the recording.
427
472
 
428
473
  Called on the model thread by the runner's media fan-out. The chunk's
429
474
  frames are resampled from the chunk's own rate onto the fixed recording
@@ -433,8 +478,17 @@ class Recorder:
433
478
  duration. Fractional grid frames carry across chunks so the resampling
434
479
  accumulates no drift. The timeline advances by the media actually fed,
435
480
  not by wall-clock, so a pause simply stops advancing rather than
436
- recording dead air. A frame that does not fit the feed queue is dropped
437
- to keep the model thread unblocked.
481
+ recording dead air.
482
+
483
+ The feed queue holds at least a whole emission, so handing a batch over
484
+ costs the model thread nothing while the encoder keeps up. Once the
485
+ queue is full the chunk decides, exactly as it does at the wire: a chunk
486
+ that asks for backpressure (``chunk.wait``) makes this call wait for
487
+ room, bounded by :data:`_FEED_WAIT_SECONDS` across the emission, and one
488
+ that prefers skipping has its overflow dropped and counted. Only the
489
+ encoder falling behind is counted: an emission cut short because the
490
+ recording is stopping is teardown, and reporting it would put phantom
491
+ losses on the summary :meth:`stop` logs.
438
492
  """
439
493
  if self.disabled:
440
494
  return
@@ -456,24 +510,69 @@ class Recorder:
456
510
  grid_frames = int(self._grid_debt)
457
511
  self._grid_debt -= grid_frames
458
512
  audio_target = round(self._audio_sample_rate / RECORDING_FPS) if self._has_audio else 0
513
+ # One authoritative bound, never below the emission being queued, so the
514
+ # burst a batching model hands over always fits.
515
+ capacity = max(_FEED_DEPTH, grid_frames)
516
+ deadline = time.monotonic() + _FEED_WAIT_SECONDS
459
517
  fed = 0
518
+ outcome = _Slot.TAKEN
460
519
  for i in range(grid_frames):
520
+ outcome = self._claim_slot(capacity, wait=chunk.wait, deadline=deadline)
521
+ if outcome is not _Slot.TAKEN:
522
+ break
461
523
  video_data = frames[i * len(frames) // grid_frames]
462
524
  audio_data = self._take_audio(audio_target) if self._has_audio else None
463
- try:
464
- self._feed_queue.put_nowait((video_data, audio_data))
465
- except queue.Full:
466
- self._dropped_frames += 1
467
- if self._dropped_frames == 1 or self._dropped_frames % 300 == 0:
468
- logger.warning(
469
- "recorder feed queue full; dropping a frame to keep the model unblocked",
470
- dropped_total=self._dropped_frames,
471
- )
472
- break
525
+ self._feed_queue.put_nowait((video_data, audio_data))
473
526
  fed += 1
527
+ if outcome is _Slot.FULL:
528
+ self._count_dropped(grid_frames - fed, grid_frames)
474
529
  if fed:
475
530
  markers.advance(fed / RECORDING_FPS)
476
531
 
532
+ def _claim_slot(self, capacity: int, *, wait: bool, deadline: float) -> _Slot:
533
+ """Ask the feed queue for room for one more grid frame.
534
+
535
+ A chunk that asks for backpressure waits for the encoder to take a
536
+ frame, until *deadline* passes; one that does not reports the full queue
537
+ immediately so its caller can drop. A recording that is stopping answers
538
+ :attr:`_Slot.WINDING_DOWN` on either path, so teardown is never counted
539
+ against the encoder.
540
+ """
541
+ if self._feed_queue.qsize() < capacity:
542
+ return _Slot.TAKEN
543
+ if self._feed_stop.is_set() or self._disabled:
544
+ return _Slot.WINDING_DOWN
545
+ if not wait:
546
+ return _Slot.FULL
547
+ with self._feed_room:
548
+ while self._feed_queue.qsize() >= capacity:
549
+ if self._feed_stop.is_set() or self._disabled:
550
+ return _Slot.WINDING_DOWN
551
+ remaining = deadline - time.monotonic()
552
+ if remaining <= 0:
553
+ return _Slot.FULL
554
+ self._feed_room.wait(timeout=min(remaining, 0.1))
555
+ return _Slot.TAKEN
556
+
557
+ def _count_dropped(self, dropped: int, offered: int) -> None:
558
+ """Record the frames an emission could not hand over, and say so.
559
+
560
+ Every dropped frame reaches the count, which the recording reports when
561
+ it stops; the warning itself is rate-limited so an encoder that stays
562
+ behind reports the loss periodically rather than once per emission.
563
+ """
564
+ self._dropped_frames += dropped
565
+ now = time.monotonic()
566
+ if now - self._dropped_logged_at < _DROP_LOG_INTERVAL_SECONDS:
567
+ return
568
+ self._dropped_logged_at = now
569
+ logger.warning(
570
+ "recorder feed queue full; dropping frames from an emission",
571
+ dropped=dropped,
572
+ offered=offered,
573
+ dropped_total=self._dropped_frames,
574
+ )
575
+
477
576
  def _video_frames(self, bundle: MediaBundle) -> list[npt.NDArray[Any]]:
478
577
  """Split the recorded video track into single ``(H, W, 3)`` frames.
479
578
 
@@ -537,6 +636,10 @@ class Recorder:
537
636
  item = self._feed_queue.get(timeout=0.1)
538
637
  except queue.Empty:
539
638
  continue
639
+ # Room opened the moment the frame left the queue, so a producer
640
+ # waiting on capacity is released before the encode, not after it.
641
+ with self._feed_room:
642
+ self._feed_room.notify_all()
540
643
  if item is None:
541
644
  return
542
645
  encoder = self._encoder
@@ -598,12 +701,14 @@ class Recorder:
598
701
  return out.reshape(1, -1)
599
702
 
600
703
  def _drain_feed_queue(self) -> None:
601
- """Discard every queued feed item."""
704
+ """Discard every queued feed item, releasing anyone waiting for room."""
602
705
  while True:
603
706
  try:
604
707
  self._feed_queue.get_nowait()
605
708
  except queue.Empty:
606
- return
709
+ break
710
+ with self._feed_room:
711
+ self._feed_room.notify_all()
607
712
 
608
713
  # -- clip / recording requests --------------------------------------------
609
714
 
@@ -888,17 +888,27 @@ class Runner(ServiceComponent, ConnectionSink):
888
888
  def _emit_media(self, chunk: MediaChunk) -> None:
889
889
  """Fan one emitted media chunk out to the recorder and the connections.
890
890
 
891
- Called off the model loop (emit dispatches to a worker thread). The
892
- recorder is fed first and always queues without blocking, so a
893
- backpressure wait in a connection's pacer (``chunk.wait``) delays the
894
- producer, never the recording. A chunk emitted with ``drop=True``
895
- keeps every consumer non-blocking.
891
+ Called off the model loop (emit dispatches to a worker thread). Both
892
+ consumers bound their queue the same way — never below the emission
893
+ being handed over — so a whole chunk fits each of them and the fan-out
894
+ costs the producer nothing while they keep up. A consumer that falls
895
+ behind honours ``chunk.wait``, and a chunk emitted with ``drop=True``
896
+ leaves every consumer non-blocking.
897
+
898
+ The connections are served first so the archive is never in front of
899
+ the session. A pacer that makes the producer wait is throttling it to
900
+ the playout rate it asked for, and drains on its own thread meanwhile;
901
+ the recorder's wait is bounded instead, because an encoder can stall
902
+ outright. Feeding the recorder second keeps that bounded stall off the
903
+ live path, and leaves its queue the whole broadcast to drain into.
896
904
  """
897
905
  for track in chunk.bundle.tracks:
898
906
  self._model_metrics.emitted(track, chunk.n_frames)
899
- self._recorder.on_chunk(chunk)
900
907
  generation = self._media_generation
901
908
  self._connections.broadcast_media(chunk, abort=lambda: self._media_generation != generation)
909
+ # The archive takes the whole chunk even when a flush cut the broadcast
910
+ # short: a playout cut is not an archive boundary.
911
+ self._recorder.on_chunk(chunk)
902
912
 
903
913
  def _flush_media(self) -> None:
904
914
  """Drop queued media in every connection and cut playout to black.
@@ -583,8 +583,8 @@ class WebRTCPeer:
583
583
 
584
584
  The fourth argument is the trailer the sender attached, when it was there
585
585
  to read and the receiver transform is in place to strip it. It carries the
586
- sender's own bytes and, beside them, the microsecond the sender stamped
587
- the frame at, which reaches the model as that frame's capture time.
586
+ sender's own bytes and, beside them, the capture time the sender declared
587
+ for the frame, which reaches the model as that frame's capture time.
588
588
  """
589
589
 
590
590
  def sink(
@@ -593,10 +593,9 @@ class WebRTCPeer:
593
593
  if self._stop_event.is_set():
594
594
  return
595
595
  metadata = bytes(meta.user_data) if meta is not None else b""
596
- # The trailer leaves the stamp zero when the sender set none. Zero is
597
- # not a clock reading, so it is the absence of a stamp rather than a
598
- # frame captured at the epoch.
599
- capture_us = meta.timestamp if meta is not None else 0
596
+ # Zero is the trailer's "unset", and it is not a clock reading: a frame
597
+ # captured at the epoch is not what a sender means by it.
598
+ capture_us = meta.capture_time_us if meta is not None else 0
600
599
  frame = InputFrame(
601
600
  data=bgra_to_rgb(bgra, width, height),
602
601
  # An empty trailer is a frame the sender attached nothing to.
File without changes
File without changes