reactor-runtime 2.7.5__tar.gz → 2.7.7__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 (139) hide show
  1. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/PKG-INFO +1 -1
  2. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/pyproject.toml +1 -1
  3. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/internal/output_buffer.py +73 -41
  4. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/recording/chunk_encoder.py +88 -11
  5. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/recording/config.py +18 -0
  6. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/recording/markers.py +10 -0
  7. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/recording/session_recorder.py +14 -2
  8. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/runtime_api.py +10 -13
  9. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/runtimes/headless/headless_runtime.py +35 -17
  10. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime.egg-info/PKG-INFO +1 -1
  11. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/README.md +0 -0
  12. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/setup.cfg +0 -0
  13. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/api/__init__.py +0 -0
  14. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/__init__.py +0 -0
  15. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/config.py +0 -0
  16. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/experiment/__init__.py +0 -0
  17. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/experiment/session.py +0 -0
  18. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/__init__.py +0 -0
  19. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/defaults.py +0 -0
  20. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/driver/__init__.py +0 -0
  21. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/driver/pipeline_executor.py +0 -0
  22. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/driver/step_result.py +0 -0
  23. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/events/__init__.py +0 -0
  24. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/events/connected.py +0 -0
  25. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/events/event.py +0 -0
  26. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/events/messages.py +0 -0
  27. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/events/upload.py +0 -0
  28. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/internal/__init__.py +0 -0
  29. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/internal/input_buffer.py +0 -0
  30. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/internal/reactor_core.py +0 -0
  31. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/model/__init__.py +0 -0
  32. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/model/decorators.py +0 -0
  33. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/model/handlers.py +0 -0
  34. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/model/reactor_model.py +0 -0
  35. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/pipeline/__init__.py +0 -0
  36. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/pipeline/idle.py +0 -0
  37. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/pipeline/input_state.py +0 -0
  38. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/pipeline/reactor_pipeline.py +0 -0
  39. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/tracks/__init__.py +0 -0
  40. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/tracks/descriptors.py +0 -0
  41. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/tracks/input.py +0 -0
  42. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/tracks/output.py +0 -0
  43. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/interface/upload.py +0 -0
  44. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/model_state.py +0 -0
  45. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/profiling/__init__.py +0 -0
  46. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/profiling/backends/__init__.py +0 -0
  47. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/profiling/backends/base.py +0 -0
  48. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/profiling/backends/file.py +0 -0
  49. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/profiling/backends/otlp.py +0 -0
  50. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/profiling/helpers.py +0 -0
  51. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/profiling/nvml_sampler.py +0 -0
  52. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/profiling/plotting/__init__.py +0 -0
  53. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/profiling/plotting/plot_profiling.py +0 -0
  54. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/profiling/profiler.py +0 -0
  55. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/profiling/singleton.py +0 -0
  56. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/profiling/torch_chunk_profiler.py +0 -0
  57. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/recording/__init__.py +0 -0
  58. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/recording/chunk_uploader.py +0 -0
  59. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/recording/sinks.py +0 -0
  60. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/recording/track_resolver.py +0 -0
  61. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/runtimes/headless/config.py +0 -0
  62. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/runtimes/headless/input_feeder.py +0 -0
  63. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/runtimes/http/config.py +0 -0
  64. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/runtimes/http/http_runtime.py +0 -0
  65. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/runtimes/http/types.py +0 -0
  66. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/schema.py +0 -0
  67. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/schema_validator.py +0 -0
  68. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/serve/__init__.py +0 -0
  69. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/serve/__main__.py +0 -0
  70. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/serve/commands/__init__.py +0 -0
  71. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/serve/commands/run.py +0 -0
  72. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/serve/commands/schema.py +0 -0
  73. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/serve/main.py +0 -0
  74. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/serve/utils/__init__.py +0 -0
  75. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/serve/utils/config.py +0 -0
  76. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/serve/utils/runtime.py +0 -0
  77. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/__init__.py +0 -0
  78. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/aiortc/__init__.py +0 -0
  79. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/aiortc/audio_track.py +0 -0
  80. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/aiortc/client.py +0 -0
  81. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/aiortc/frame_conversion.py +0 -0
  82. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/aiortc/ice_connection.py +0 -0
  83. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/aiortc/video_track.py +0 -0
  84. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/config.py +0 -0
  85. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/events.py +0 -0
  86. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/__init__.py +0 -0
  87. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/client.py +0 -0
  88. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/decoders/__init__.py +0 -0
  89. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/decoders/av1.py +0 -0
  90. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/decoders/base.py +0 -0
  91. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/decoders/factory.py +0 -0
  92. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/decoders/h264.py +0 -0
  93. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/decoders/h265.py +0 -0
  94. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/decoders/opus.py +0 -0
  95. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/decoders/vp8.py +0 -0
  96. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/decoders/vp9.py +0 -0
  97. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/encoders/__init__.py +0 -0
  98. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/encoders/av1.py +0 -0
  99. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/encoders/base.py +0 -0
  100. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/encoders/factory.py +0 -0
  101. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/encoders/h264.py +0 -0
  102. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/encoders/h265.py +0 -0
  103. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/encoders/opus.py +0 -0
  104. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/encoders/vp8.py +0 -0
  105. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/encoders/vp9.py +0 -0
  106. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/gst.py +0 -0
  107. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/gst_helpers.py +0 -0
  108. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/probes/__init__.py +0 -0
  109. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/probes/fps_probe.py +0 -0
  110. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/receiver/__init__.py +0 -0
  111. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/receiver/audio.py +0 -0
  112. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/receiver/base.py +0 -0
  113. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/receiver/video.py +0 -0
  114. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/sdp/__init__.py +0 -0
  115. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/sdp/bundle.py +0 -0
  116. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/sdp/codec.py +0 -0
  117. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/sdp/extmap.py +0 -0
  118. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/sdp/ice.py +0 -0
  119. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/sender/__init__.py +0 -0
  120. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/sender/audio.py +0 -0
  121. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/sender/base.py +0 -0
  122. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/sender/video.py +0 -0
  123. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/settings.py +0 -0
  124. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/gstreamer/signals.py +0 -0
  125. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/ice_uris.py +0 -0
  126. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/interface.py +0 -0
  127. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/media.py +0 -0
  128. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/transports/types.py +0 -0
  129. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/utils/launch.py +0 -0
  130. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/utils/loader.py +0 -0
  131. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/utils/log.py +0 -0
  132. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/utils/messages.py +0 -0
  133. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/utils/paths.py +0 -0
  134. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/utils/ports.py +0 -0
  135. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime/utils/typing.py +0 -0
  136. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime.egg-info/SOURCES.txt +0 -0
  137. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime.egg-info/dependency_links.txt +0 -0
  138. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime.egg-info/requires.txt +0 -0
  139. {reactor_runtime-2.7.5 → reactor_runtime-2.7.7}/src/reactor_runtime.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reactor_runtime
3
- Version: 2.7.5
3
+ Version: 2.7.7
4
4
  Summary: Reactor runtime with public model API
5
5
  Author-email: Reactor <team@reactor.inc>
6
6
  Requires-Python: >=3.9
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "reactor_runtime"
7
- version = "2.7.5"
7
+ version = "2.7.7"
8
8
  description = "Reactor runtime with public model API"
9
9
  authors = [
10
10
  { name = "Reactor", email = "team@reactor.inc" }
@@ -102,6 +102,18 @@ def split_batch(bundle: MediaBundle) -> List[MediaBundle]:
102
102
  return result
103
103
 
104
104
 
105
+ class _FlushMarker:
106
+ """Sentinel placed in :class:`OutputBuffer`'s queue by :meth:`flush`.
107
+
108
+ Consumed by the emission thread, which resets ``_last_emitted`` and
109
+ dispatches a fresh black bundle with ``is_fresh_black=True``. Doing
110
+ the reset in-thread keeps it atomic with the per-tick state read.
111
+ """
112
+
113
+
114
+ _FLUSH_MARKER: _FlushMarker = _FlushMarker()
115
+
116
+
105
117
  class OutputBuffer:
106
118
  """Rate-controlled emission buffer.
107
119
 
@@ -124,10 +136,12 @@ class OutputBuffer:
124
136
  # Ordered list of per-tick observers. Callbacks fire in
125
137
  # registration order on every emission tick; an exception in
126
138
  # one callback is logged and does not block the others.
127
- self._callbacks: List[Callable[[MediaBundle, bool], None]] = []
139
+ self._callbacks: List[Callable[[MediaBundle, bool, bool], None]] = []
128
140
  self._callbacks_lock: threading.Lock = threading.Lock()
129
141
 
130
- self._q: queue.Queue[MediaBundle] = queue.Queue(maxsize=queue_depth)
142
+ # Holds MediaBundle plus the _FlushMarker sentinel; the
143
+ # emission loop discriminates by isinstance.
144
+ self._q: queue.Queue[object] = queue.Queue(maxsize=queue_depth)
131
145
 
132
146
  # FPS control — store both rate and period to avoid 1/fps on every tick
133
147
  self._fixed_fps: float = 0.0
@@ -150,29 +164,18 @@ class OutputBuffer:
150
164
  # Callbacks
151
165
  # ------------------------------------------------------------------
152
166
 
153
- def add_callback(self, fn: Callable[[MediaBundle, bool], None]) -> None:
154
- """Register a per-tick callback.
167
+ def add_callback(self, fn: Callable[[MediaBundle, bool, bool], None]) -> None:
168
+ """Register a per-tick callback ``(bundle, duplicate, is_fresh_black)``.
155
169
 
156
- Idempotent: registering the same ``fn`` twice leaves the
157
- callback list with a single entry. This mirrors
158
- :meth:`remove_callback` which has always been a no-op on
159
- already-unregistered functions — callers shouldn't have to
160
- track registration state.
161
-
162
- The callback receives ``(bundle, duplicate)`` on every emission
163
- tick. Callbacks fire in registration order and are isolated
164
- from each other: an exception raised by one is logged and the
165
- remaining callbacks still fire.
170
+ Idempotent — re-registering the same ``fn`` is a no-op.
171
+ Callbacks fire in registration order with exceptions isolated.
166
172
  """
167
173
  with self._callbacks_lock:
168
174
  if fn not in self._callbacks:
169
175
  self._callbacks.append(fn)
170
176
 
171
- def remove_callback(self, fn: Callable[[MediaBundle, bool], None]) -> None:
172
- """Deregister a previously added callback.
173
-
174
- No-op if *fn* was never registered.
175
- """
177
+ def remove_callback(self, fn: Callable[[MediaBundle, bool, bool], None]) -> None:
178
+ """Deregister a previously added callback (no-op if not registered)."""
176
179
  with self._callbacks_lock:
177
180
  try:
178
181
  self._callbacks.remove(fn)
@@ -288,27 +291,30 @@ class OutputBuffer:
288
291
  # Stage 3: Emission loop
289
292
  # ------------------------------------------------------------------
290
293
 
291
- def _dispatch(self, bundle: MediaBundle, duplicate: bool) -> None:
294
+ def _dispatch(
295
+ self,
296
+ bundle: MediaBundle,
297
+ duplicate: bool,
298
+ *,
299
+ is_fresh_black: bool = False,
300
+ ) -> None:
292
301
  """Fire every registered callback in order with error isolation.
293
302
 
294
- We snapshot the callback list under the lock so callbacks that
295
- register/deregister siblings during dispatch don't perturb the
296
- current tick. Each callback is wrapped so an exception in one
297
- does not block the others.
303
+ Callbacks receive ``(bundle, duplicate, is_fresh_black)``.
304
+ ``is_fresh_black`` is ``True`` only on the synthesised black
305
+ the sentinel path emits at session boundaries; callbacks
306
+ choose how to interpret that separately from ``duplicate``.
307
+ Exceptions in one callback are logged and don't block the
308
+ rest.
298
309
  """
299
310
  with self._callbacks_lock:
300
311
  callbacks = list(self._callbacks)
301
312
  for fn in callbacks:
302
313
  try:
303
- fn(bundle, duplicate)
314
+ fn(bundle, duplicate, is_fresh_black)
304
315
  except Exception as ex:
305
- # ``logger.exception`` already attaches sys.exc_info()'s
306
- # traceback; we also surface the type + message as
307
- # structured fields so log queries on ``exc_type`` /
308
- # ``exc_msg`` / ``callback`` work without traceback
309
- # parsing. Without these explicit fields a silent
310
- # malfunction in a downstream callback looks like
311
- # "feature X didn't fire" with no obvious cause.
316
+ # Structured fields so log queries on exc_type /
317
+ # exc_msg / callback work without traceback parsing.
312
318
  logger.exception(
313
319
  "OutputBuffer callback raised; continuing with remaining callbacks",
314
320
  callback=repr(fn),
@@ -322,13 +328,31 @@ class OutputBuffer:
322
328
  while not self._emission_stop.is_set():
323
329
  interval = self._interval
324
330
 
325
- bundle: Optional[MediaBundle] = None
331
+ item: object
326
332
  try:
327
- bundle = self._q.get_nowait()
333
+ item = self._q.get_nowait()
328
334
  except queue.Empty:
329
- pass
335
+ item = None
330
336
 
331
- if bundle is not None:
337
+ bundle: Optional[MediaBundle] = None
338
+ sentinel_consumed = False
339
+ if isinstance(item, _FlushMarker):
340
+ self._last_emitted = None
341
+ sentinel_consumed = True
342
+ elif isinstance(item, MediaBundle):
343
+ bundle = item
344
+
345
+ if sentinel_consumed:
346
+ # Session-boundary black: tagged so the wire
347
+ # forwards it (otherwise the client would stay
348
+ # frozen on the previous frame), the recorder
349
+ # ignores it.
350
+ self._dispatch(
351
+ self._create_black_bundle(),
352
+ True,
353
+ is_fresh_black=True,
354
+ )
355
+ elif bundle is not None:
332
356
  vtracks = bundle.get_tracks_by_kind(TrackKind.VIDEO)
333
357
  if vtracks:
334
358
  vd = vtracks[0].data
@@ -464,11 +488,19 @@ class OutputBuffer:
464
488
  self._drain_queue()
465
489
 
466
490
  def flush(self) -> None:
467
- """Empty the queue and insert a black frame."""
491
+ """Drop pending bundles and request a session-boundary reset.
492
+
493
+ The emission thread performs the actual reset of
494
+ ``_last_emitted`` when it dequeues the sentinel. Then the
495
+ next tick emits a fresh black bundle tagged ``is_fresh_black``
496
+ — forwarded by the wire, ignored by the recorder.
497
+ """
468
498
  self._drain_queue()
469
- self._last_emitted = None
470
- black = self._create_black_bundle()
471
499
  try:
472
- self._q.put_nowait(black)
500
+ self._q.put_nowait(_FLUSH_MARKER)
473
501
  except queue.Full:
474
- pass
502
+ # Same thread as submit(), so unreachable in practice.
503
+ logger.warning(
504
+ "OutputBuffer.flush: queue full immediately after drain; "
505
+ "reset sentinel dropped"
506
+ )
@@ -61,6 +61,31 @@ def _build_argv(
61
61
  is fed at exactly ``sample_rate * dt`` samples per video frame
62
62
  (see ``SessionRecorder._next_audio_chunk``) so its byte-derived
63
63
  DTS still tracks wall-clock 1:1 with video's PTS.
64
+
65
+ Social-media compatibility (REA-2482):
66
+
67
+ * ``-pix_fmt yuv420p`` + ``-profile:v main`` on the *output*. With
68
+ rgb24 input and no output pix_fmt, libx264 auto-selects yuv444p
69
+ (Hi444PP profile), which macOS VideoToolbox cannot decode and
70
+ every major social uploader rejects. Forcing yuv420p+Main is
71
+ the universally-compatible H.264 sub-profile.
72
+ * ``-vf setpts=PTS-STARTPTS`` re-anchors the wallclock-stamped
73
+ video PTS to session-relative. Without it every segment's
74
+ ``tfdt baseMediaDecodeTime`` carries the Unix-epoch-derived
75
+ value (~1.78e9), which some players treat as the playback
76
+ start time and some uploaders refuse outright. Subtracting
77
+ ``STARTPTS`` preserves *relative* frame spacing (so the
78
+ dynamic-FPS comment above still holds) while shifting chunk 0
79
+ to PTS=0. Note: mid-session clips still carry a non-zero
80
+ ``tfdt`` (e.g. 32 s in for a clip starting at session t=32) —
81
+ that's the SDK's concern (REA-2558), not ours.
82
+ * Silent AAC stub for video-only models: ``-f lavfi -i anullsrc``
83
+ synthesises an infinite silent mono 48 kHz stream inside
84
+ libavfilter — no extra subprocess, no Python audio thread, no
85
+ pipe. Paired with ``-shortest`` so the encoder terminates on
86
+ the video pipe's EOF. Required by Instagram and broadens
87
+ compatibility on every other platform; encoding silence costs
88
+ <1% of a core and ~16 kbps × duration on disk.
64
89
  """
65
90
  # ``-probesize 32 -analyzeduration 0`` on every input: rawvideo /
66
91
  # s16le are fully described by ``-f`` + format flags, so ffmpeg's
@@ -117,16 +142,61 @@ def _build_argv(
117
142
  "1024",
118
143
  "-i",
119
144
  f"pipe:{audio_read_fd}",
120
- "-map",
121
- "0:v",
122
- "-map",
123
- "1:a",
124
145
  ]
146
+ else:
147
+ # Synthetic silent audio (REA-2482). ``lavfi anullsrc`` is a
148
+ # virtual generator inside libavfilter — no extra subprocess
149
+ # spawned, no audio pipe opened on our side.
150
+ #
151
+ # ``-re`` paces the lavfi input at native rate (one
152
+ # wallclock-second of silence per real second). Without it,
153
+ # lavfi is an unpaced infinite generator: the input thread
154
+ # produces silent samples as fast as ffmpeg's filter graph
155
+ # asks, racing ahead of the wallclock-stamped video on idle
156
+ # sessions and risking thread-queue exhaustion / stalled
157
+ # teardown. With ``-re``, the silent stream tracks wallclock
158
+ # the same way the real pipe-fed audio path does (which
159
+ # writes exactly ``sample_rate * dt`` samples per video frame
160
+ # in ``SessionRecorder._feed_loop``).
161
+ #
162
+ # We pair this with ``-shortest`` below so the encoder
163
+ # terminates with the video pipe's EOF (anullsrc is infinite
164
+ # by itself).
165
+ argv += [
166
+ "-re",
167
+ "-f",
168
+ "lavfi",
169
+ "-i",
170
+ "anullsrc=cl=mono:r=48000",
171
+ ]
172
+ # Explicit stream mappings: now that there is always a second
173
+ # input (either the real audio pipe or the lavfi silent stub),
174
+ # both branches funnel through the same -map pair.
175
+ argv += [
176
+ "-map",
177
+ "0:v",
178
+ "-map",
179
+ "1:a",
180
+ ]
125
181
  # video encoder
126
182
  vcodec = "libx264" if config.video.codec == "h264" else "libx265"
127
183
  argv += [
128
184
  "-c:v",
129
185
  vcodec,
186
+ # Social-media-compat output format (see docstring). The first
187
+ # -pix_fmt earlier in argv is the *input* (rgb24); this second
188
+ # -pix_fmt applies to the encoder output because it sits after
189
+ # all -i flags.
190
+ "-pix_fmt",
191
+ "yuv420p",
192
+ "-profile:v",
193
+ "main",
194
+ # Re-anchor wallclock-stamped PTS to session-relative; see
195
+ # docstring. Single arithmetic op per frame inside the
196
+ # existing filter graph — no extra encode, no decode (input
197
+ # is rawvideo).
198
+ "-vf",
199
+ "setpts=PTS-STARTPTS",
130
200
  "-preset",
131
201
  config.video.preset,
132
202
  "-crf",
@@ -141,13 +211,20 @@ def _build_argv(
141
211
  "-force_key_frames",
142
212
  f"expr:gte(t,n_forced*{config.chunk_seconds})",
143
213
  ]
144
- if has_audio:
145
- argv += [
146
- "-c:a",
147
- config.audio.codec,
148
- "-b:a",
149
- f"{config.audio.bitrate_kbps}k",
150
- ]
214
+ # Audio encoder runs unconditionally now: real audio (when
215
+ # has_audio=True) or the lavfi silent stub (when False) — either
216
+ # way we want an AAC track in every output segment.
217
+ argv += [
218
+ "-c:a",
219
+ config.audio.codec,
220
+ "-b:a",
221
+ f"{config.audio.bitrate_kbps}k",
222
+ ]
223
+ if not has_audio:
224
+ # ``lavfi anullsrc`` produces samples forever; without
225
+ # -shortest the encoder would keep writing silent audio
226
+ # frames after the video pipe EOFs, never terminating.
227
+ argv += ["-shortest"]
151
228
  # HLS+fMP4 muxer (was ``-f dash``: dash's segment-rename trips
152
229
  # ENOENT on macOS, killing the encoder ~10s in). Same byte-level
153
230
  # output, more robust segment-close, 0-indexed by default — which
@@ -96,6 +96,24 @@ class RecordingConfig:
96
96
  # that frame. Clip requests before any real frame fail with
97
97
  # ``no media generated yet``. Set to ``False`` to record from
98
98
  # session arm time and include the leading pre-roll in clips.
99
+ #
100
+ # **Timeline note (REA-2482).** The encoder applies
101
+ # ``setpts=PTS-STARTPTS`` on the video output to keep segment PTS
102
+ # session-relative rather than Unix-epoch-relative. That filter
103
+ # zeroes media time at the *first packet fed* to ffmpeg. In the
104
+ # default ``skip_leading_black=True`` mode, that is also where
105
+ # :class:`~reactor_runtime.recording.markers.MarkerBookkeeper`
106
+ # anchors its timeline (via ``mark_first_real_frame``), so markers
107
+ # and media bytes share the same origin and clip ranges resolve
108
+ # exactly. In the legacy ``skip_leading_black=False`` mode the
109
+ # marker bookkeeper keeps its origin at recorder arm time
110
+ # (``session_start``), so the two timelines diverge by however
111
+ # long the recorder waited for the first emission tick — usually
112
+ # one frame interval (<33 ms) but worst-case longer if the
113
+ # OutputBuffer hasn't started emitting yet at recorder arm. Clips
114
+ # served from that early window cover slightly *later* content
115
+ # than their marker range advertises; the clip length itself is
116
+ # unchanged.
99
117
  skip_leading_black: bool = True
100
118
  video_track: Optional[str] = None
101
119
  audio_track: Optional[str] = None
@@ -5,6 +5,16 @@ Markers are wall-clock seconds aligned with ffmpeg's
5
5
  ``-use_wallclock_as_timestamps`` input. When ``anchor_at_first_frame``
6
6
  is set (``RecordingConfig.skip_leading_black``), ``t=0`` is the first
7
7
  real frame fed to the encoder, not recorder arm time.
8
+
9
+ The encoder additionally applies ``setpts=PTS-STARTPTS`` on the video
10
+ output (REA-2482), which zeroes media PTS at the *first packet fed* to
11
+ ffmpeg. With ``anchor_at_first_frame=True`` that's the same instant
12
+ the bookkeeper anchors on, so markers and media share an origin. With
13
+ ``anchor_at_first_frame=False`` the bookkeeper's origin stays at
14
+ recorder arm time while the media origin slips to the first emission
15
+ tick; the gap is typically a single frame interval but can be longer
16
+ if the OutputBuffer hadn't started emitting yet at arm time. See
17
+ ``RecordingConfig.skip_leading_black`` for the user-facing contract.
8
18
  """
9
19
 
10
20
  from __future__ import annotations
@@ -250,10 +250,22 @@ class SessionRecorder:
250
250
  # OutputBuffer integration
251
251
  # ------------------------------------------------------------------
252
252
 
253
- def _on_bundle_sync(self, bundle: MediaBundle, duplicate: bool) -> None:
254
- """Enqueue a bundle for the feed worker; non-blocking."""
253
+ def _on_bundle_sync(
254
+ self,
255
+ bundle: MediaBundle,
256
+ duplicate: bool,
257
+ is_fresh_black: bool = False,
258
+ ) -> None:
259
+ """Enqueue a bundle for the feed worker; non-blocking.
260
+
261
+ Session-boundary synthesised black (``is_fresh_black``) is not
262
+ real model output and is invisible to the recording timeline:
263
+ no feed-queue write, no ``mark_first_real_frame`` consideration.
264
+ """
255
265
  if self._disabled or not self._started:
256
266
  return
267
+ if is_fresh_black:
268
+ return
257
269
  now = time.monotonic()
258
270
  if duplicate:
259
271
  if self._config.skip_leading_black and not self._markers.recording_started:
@@ -350,22 +350,19 @@ class Runtime(ModelStateMachine, ABC):
350
350
  logger.warning("Failed to send app message", error=e)
351
351
 
352
352
  def _send_out_app_bundle_sync(
353
- self, media_bundle: MediaBundle, duplicate: bool
353
+ self,
354
+ media_bundle: MediaBundle,
355
+ duplicate: bool,
356
+ is_fresh_black: bool = False,
354
357
  ) -> None:
355
- """Callback from the :class:`FrameBuffer` emission loop.
356
-
357
- Silently fails if the event loop is closed (expected during shutdown).
358
-
359
- When *duplicate* is ``True`` the bundle is a re-emission of the
360
- last frame (queue was empty, video-only). We skip forwarding
361
- entirely: the video track already serves the last pushed frame,
362
- and pushing the same frame again only wastes event-loop time.
358
+ """OutputBuffer callback forward real frames + session-boundary black.
363
359
 
364
- Args:
365
- media_bundle: The :class:`MediaBundle` to send.
366
- duplicate: True if this is a re-emission of the last frame.
360
+ Drops keepalive duplicates (the wire track already shows that
361
+ frame) but forwards ``is_fresh_black`` bundles so the client
362
+ transitions off the previous session's last frame on flush.
363
+ Silently fails if the event loop is closed (shutdown).
367
364
  """
368
- if duplicate:
365
+ if duplicate and not is_fresh_black:
369
366
  return
370
367
  if self.loop.is_closed():
371
368
  return
@@ -70,19 +70,26 @@ class HeadlessRuntime(Runtime):
70
70
  # Runtime API implementation - MODEL -> CLIENT (stdout)
71
71
  # ===============================
72
72
 
73
- def _send_out_app_bundle_sync(self, media_bundle, duplicate: bool) -> None:
74
- """Override base-class sync wrapper to forward duplicates.
75
-
76
- The base :class:`Runtime` drops duplicates because re-sending
77
- the same frame over WebRTC is wasteful. The headless runtime
78
- needs to see them so that ``send_out_app_bundle`` can count
79
- skipped duplicates for the ``ignore_duplicates`` feature.
73
+ def _send_out_app_bundle_sync(
74
+ self,
75
+ media_bundle,
76
+ duplicate: bool,
77
+ is_fresh_black: bool = False,
78
+ ) -> None:
79
+ """Forward every bundle to :meth:`send_out_app_bundle`.
80
+
81
+ Unlike the base class, headless does not drop duplicates here
82
+ — the async path counts them for the ``ignore_duplicates``
83
+ feature. Silently fails if the event loop is closed.
80
84
  """
81
85
  if self.loop.is_closed():
82
86
  return
83
87
  try:
84
88
  asyncio.run_coroutine_threadsafe(
85
- self.send_out_app_bundle(media_bundle, duplicate), self.loop
89
+ self.send_out_app_bundle(
90
+ media_bundle, duplicate, is_fresh_black=is_fresh_black
91
+ ),
92
+ self.loop,
86
93
  )
87
94
  except RuntimeError:
88
95
  pass
@@ -105,19 +112,30 @@ class HeadlessRuntime(Runtime):
105
112
  output = json.dumps(data, indent=2)
106
113
  print(f"\n[RUNTIME] {output}", flush=True)
107
114
 
108
- async def send_out_app_bundle(self, media_bundle, duplicate: bool) -> None:
109
- """
110
- Handle an output media bundle from the model.
111
- Extracts the video frame and writes it as a PNG file.
112
-
113
- Args:
114
- media_bundle: A :class:`MediaBundle` (video track extracted).
115
- duplicate: True if this is a re-emission of the last frame.
115
+ async def send_out_app_bundle(
116
+ self,
117
+ media_bundle,
118
+ duplicate: bool,
119
+ *,
120
+ is_fresh_black: bool = False,
121
+ ) -> None:
122
+ """Extract the video frame from ``media_bundle`` and write as PNG.
123
+
124
+ Skips ``is_fresh_black`` bundles (synthesised session-boundary
125
+ black, not model output) and — when ``ignore_duplicates`` is
126
+ set — ``duplicate`` keepalives.
116
127
  """
117
128
  if self.file_path is None:
118
129
  return
119
130
 
120
- # Skip duplicate frames (re-emissions from the frame buffer when it's empty)
131
+ if is_fresh_black:
132
+ self._duplicates_skipped += 1
133
+ logger.debug(
134
+ "Skipped fresh-black session-boundary frame",
135
+ total_skipped=self._duplicates_skipped,
136
+ )
137
+ return
138
+
121
139
  if self.config.ignore_duplicates and duplicate:
122
140
  self._duplicates_skipped += 1
123
141
  logger.debug(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: reactor_runtime
3
- Version: 2.7.5
3
+ Version: 2.7.7
4
4
  Summary: Reactor runtime with public model API
5
5
  Author-email: Reactor <team@reactor.inc>
6
6
  Requires-Python: >=3.9