reactor-runtime 3.2.2__tar.gz → 3.2.4__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.2 → reactor_runtime-3.2.4}/PKG-INFO +7 -7
  2. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/README.md +6 -6
  3. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/pyproject.toml +1 -1
  4. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/pyproject.toml.orig +1 -1
  5. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/manifest.py +16 -10
  6. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/recording/recorder.py +124 -19
  7. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/runner/runner.py +16 -6
  8. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/LICENSE +0 -0
  9. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/NOTICE +0 -0
  10. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/__init__.py +0 -0
  11. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/codes.py +0 -0
  12. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/core/__init__.py +0 -0
  13. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/core/fields.py +0 -0
  14. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/core/model.py +0 -0
  15. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/core/naming.py +0 -0
  16. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/core/service.py +0 -0
  17. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/core/session.py +0 -0
  18. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/core/transport.py +0 -0
  19. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/core/typespec.py +0 -0
  20. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/core/values.py +0 -0
  21. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/event_stream.py +0 -0
  22. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/http/__init__.py +0 -0
  23. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/http/events.py +0 -0
  24. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/http/routes.py +0 -0
  25. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/http/server.py +0 -0
  26. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/http/spec.py +0 -0
  27. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/interface/__init__.py +0 -0
  28. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/interface/client.py +0 -0
  29. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/interface/events/__init__.py +0 -0
  30. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/interface/events/decorators.py +0 -0
  31. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/interface/events/errors.py +0 -0
  32. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/interface/events/messages.py +0 -0
  33. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/interface/internal/__init__.py +0 -0
  34. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/interface/internal/bridge.py +0 -0
  35. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/interface/internal/input_buffer.py +0 -0
  36. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/interface/internal/reactor_core.py +0 -0
  37. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/interface/model/__init__.py +0 -0
  38. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/interface/model/contract.py +0 -0
  39. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/interface/model/reactor_model.py +0 -0
  40. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/interface/model/schema.py +0 -0
  41. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/interface/pipeline/__init__.py +0 -0
  42. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/interface/pipeline/idle.py +0 -0
  43. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/interface/pipeline/input_state.py +0 -0
  44. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/interface/pipeline/reactor_pipeline.py +0 -0
  45. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/interface/tracks/__init__.py +0 -0
  46. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/interface/tracks/descriptors.py +0 -0
  47. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/interface/tracks/input.py +0 -0
  48. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/interface/tracks/output.py +0 -0
  49. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/log.py +0 -0
  50. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/message_gateway.py +0 -0
  51. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/metrics.py +0 -0
  52. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/paths.py +0 -0
  53. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/protocol/__init__.py +0 -0
  54. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/protocol/base.py +0 -0
  55. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/protocol/common.py +0 -0
  56. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/protocol/v0/__init__.py +0 -0
  57. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/protocol/v0/codec.py +0 -0
  58. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/protocol/v1/__init__.py +0 -0
  59. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/protocol/v1/codec.py +0 -0
  60. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/py.typed +0 -0
  61. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/recording/__init__.py +0 -0
  62. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/recording/chunk_encoder.py +0 -0
  63. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/recording/markers.py +0 -0
  64. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/runner/__init__.py +0 -0
  65. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/runner/connection_manager.py +0 -0
  66. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/runner/offer_epochs.py +0 -0
  67. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/runner/state_machine.py +0 -0
  68. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/schema.py +0 -0
  69. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/serve.py +0 -0
  70. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/service.py +0 -0
  71. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/transport/__init__.py +0 -0
  72. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/transport/acceptor.py +0 -0
  73. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/transport/router.py +0 -0
  74. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/transport/webrtc/__init__.py +0 -0
  75. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/transport/webrtc/acceptor.py +0 -0
  76. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/transport/webrtc/config.py +0 -0
  77. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/transport/webrtc/connection.py +0 -0
  78. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/transport/webrtc/frames.py +0 -0
  79. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/transport/webrtc/pacer.py +0 -0
  80. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/transport/webrtc/peer.py +0 -0
  81. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/transport/webrtc/router.py +0 -0
  82. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/transport/webrtc/sdp.py +0 -0
  83. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/transport/webrtc/signaling.py +0 -0
  84. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/transport/webrtc/stats.py +0 -0
  85. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/transport/webrtc/version.py +0 -0
  86. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_runtime/upload_store.py +0 -0
  87. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_wire/v1/common_pb2.py +0 -0
  88. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_wire/v1/common_pb2.pyi +0 -0
  89. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_wire/v1/control_pb2.py +0 -0
  90. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_wire/v1/control_pb2.pyi +0 -0
  91. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_wire/v1/data_pb2.py +0 -0
  92. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_wire/v1/data_pb2.pyi +0 -0
  93. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_wire/v1/model_pb2.py +0 -0
  94. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_wire/v1/model_pb2.pyi +0 -0
  95. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_wire/v1/platform_pb2.py +0 -0
  96. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_wire/v1/platform_pb2.pyi +0 -0
  97. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/src/reactor_wire/v1/track_pb2.py +0 -0
  98. {reactor_runtime-3.2.2 → reactor_runtime-3.2.4}/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.2
3
+ Version: 3.2.4
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>
@@ -30,7 +30,7 @@ Description-Content-Type: text/markdown
30
30
 
31
31
  **Build real-time AI models in Python.**
32
32
 
33
- [📖 Documentation](https://deploy-docs.reactor.inc) · [🚀 Quickstart](https://deploy-docs.reactor.inc/development/quickstart) · [🌐 Reactor](https://reactor.inc)
33
+ [📖 Documentation](https://docs.reactor.inc/deploy/overview) · [🚀 Quickstart](https://docs.reactor.inc/deploy/development/overview) · [🌐 Reactor](https://reactor.inc)
34
34
 
35
35
  </div>
36
36
 
@@ -95,19 +95,19 @@ reactor run
95
95
 
96
96
  ## Install
97
97
 
98
- Everything runs through the [`reactor` CLI](https://deploy-docs.reactor.inc/platform/installation). There is nothing to install on your host but the CLI and Docker; the runtime ships inside the image the CLI builds for your workspace.
98
+ Everything runs through the [`reactor` CLI](https://docs.reactor.inc/deploy/platform/installation). There is nothing to install on your host but the CLI and Docker; the runtime ships inside the image the CLI builds for your workspace.
99
99
 
100
100
  ```sh
101
101
  brew install reactor-team/tools/reactor-cli
102
102
  ```
103
103
 
104
- Not on macOS, or pinning a release in CI? See [Install the CLI](https://deploy-docs.reactor.inc/platform/installation).
104
+ Not on macOS, or pinning a release in CI? See [Install the CLI](https://docs.reactor.inc/deploy/platform/installation).
105
105
 
106
106
  ## Learn more
107
107
 
108
- - [Quickstart](https://deploy-docs.reactor.inc/development/quickstart): from zero to a streaming model in 2 minutes
109
- - [Model anatomy](https://deploy-docs.reactor.inc/development/reactor-model/model-anatomy): every piece of a Reactor model, line by line
110
- - [The run loop](https://deploy-docs.reactor.inc/development/reactor-model/run-loop): emitting frames, batches, and frame rates
108
+ - [Quickstart](https://docs.reactor.inc/deploy/development/overview): from zero to a streaming model in 2 minutes
109
+ - [Model anatomy](https://docs.reactor.inc/deploy/development/reactor-model/model-anatomy): every piece of a Reactor model, line by line
110
+ - [The run loop](https://docs.reactor.inc/deploy/development/reactor-model/run-loop): emitting frames, batches, and frame rates
111
111
 
112
112
  ## Development
113
113
 
@@ -4,7 +4,7 @@
4
4
 
5
5
  **Build real-time AI models in Python.**
6
6
 
7
- [📖 Documentation](https://deploy-docs.reactor.inc) · [🚀 Quickstart](https://deploy-docs.reactor.inc/development/quickstart) · [🌐 Reactor](https://reactor.inc)
7
+ [📖 Documentation](https://docs.reactor.inc/deploy/overview) · [🚀 Quickstart](https://docs.reactor.inc/deploy/development/overview) · [🌐 Reactor](https://reactor.inc)
8
8
 
9
9
  </div>
10
10
 
@@ -69,19 +69,19 @@ reactor run
69
69
 
70
70
  ## Install
71
71
 
72
- Everything runs through the [`reactor` CLI](https://deploy-docs.reactor.inc/platform/installation). There is nothing to install on your host but the CLI and Docker; the runtime ships inside the image the CLI builds for your workspace.
72
+ Everything runs through the [`reactor` CLI](https://docs.reactor.inc/deploy/platform/installation). There is nothing to install on your host but the CLI and Docker; the runtime ships inside the image the CLI builds for your workspace.
73
73
 
74
74
  ```sh
75
75
  brew install reactor-team/tools/reactor-cli
76
76
  ```
77
77
 
78
- Not on macOS, or pinning a release in CI? See [Install the CLI](https://deploy-docs.reactor.inc/platform/installation).
78
+ Not on macOS, or pinning a release in CI? See [Install the CLI](https://docs.reactor.inc/deploy/platform/installation).
79
79
 
80
80
  ## Learn more
81
81
 
82
- - [Quickstart](https://deploy-docs.reactor.inc/development/quickstart): from zero to a streaming model in 2 minutes
83
- - [Model anatomy](https://deploy-docs.reactor.inc/development/reactor-model/model-anatomy): every piece of a Reactor model, line by line
84
- - [The run loop](https://deploy-docs.reactor.inc/development/reactor-model/run-loop): emitting frames, batches, and frame rates
82
+ - [Quickstart](https://docs.reactor.inc/deploy/development/overview): from zero to a streaming model in 2 minutes
83
+ - [Model anatomy](https://docs.reactor.inc/deploy/development/reactor-model/model-anatomy): every piece of a Reactor model, line by line
84
+ - [The run loop](https://docs.reactor.inc/deploy/development/reactor-model/run-loop): emitting frames, batches, and frame rates
85
85
 
86
86
  ## Development
87
87
 
@@ -92,7 +92,7 @@ pythonpath = ["."]
92
92
 
93
93
  [project]
94
94
  name = "reactor-runtime"
95
- version = "3.2.2"
95
+ version = "3.2.4"
96
96
  description = "A Python framework for building real-time, interactive video models"
97
97
  readme = "README.md"
98
98
  license = "Apache-2.0"
@@ -20,7 +20,7 @@ version = "1.20260814.7"
20
20
 
21
21
  [project]
22
22
  name = "reactor-runtime"
23
- version = "3.2.2"
23
+ version = "3.2.4"
24
24
  description = "A Python framework for building real-time, interactive video models"
25
25
  readme = "README.md"
26
26
  license = "Apache-2.0"
@@ -2,8 +2,8 @@
2
2
 
3
3
  The ``reactor.yaml`` manifest is the runtime's one configuration file:
4
4
  ``runtime.import`` names the model as a ``"module:Class"`` reference,
5
- ``runtime.config`` points at the model's own config file, and the top-level
6
- ``recording:`` block configures the recorder. This module turns that file into
5
+ ``runtime.config`` points at the model's own config file, and the
6
+ ``runtime.recording`` block configures the recorder. This module turns that file into
7
7
  a :class:`~reactor_runtime.core.RuntimeConfig` and the reference into the model
8
8
  class — and it is the only code that does either, so every entry point resolves
9
9
  the same model from the same directory.
@@ -32,8 +32,8 @@ def load_config(manifest: Path) -> RuntimeConfig:
32
32
 
33
33
  ``runtime.import`` — the ``"module:Class"`` model reference — and
34
34
  ``runtime.config`` — the path to the model's own config file — name the
35
- model, ``model.name`` is the name it is published under, and the top-level
36
- ``recording:`` block configures the recorder; the rest of the manifest
35
+ model, ``model.name`` is the name it is published under, and the
36
+ ``runtime.recording`` block configures the recorder; the rest of the manifest
37
37
  describes the model to the platform and is not the runtime's concern. The
38
38
  config path is passed to the model verbatim (resolved to an absolute path);
39
39
  the runtime never parses its contents.
@@ -65,7 +65,7 @@ def load_config(manifest: Path) -> RuntimeConfig:
65
65
  model_ref=model_ref,
66
66
  model_name=_model_name(document.get("model")),
67
67
  config_path=_resolve_config_path(runtime, manifest),
68
- recording=_recording_from_manifest(document.get("recording")),
68
+ recording=_recording_from_manifest(runtime, document),
69
69
  )
70
70
 
71
71
 
@@ -110,19 +110,25 @@ def import_model_class(model_ref: str) -> type[ReactorCore]:
110
110
  return model_cls
111
111
 
112
112
 
113
- def _recording_from_manifest(block: Any) -> RecordingConfig:
113
+ def _recording_from_manifest(runtime: dict[str, Any], document: dict[str, Any]) -> RecordingConfig:
114
114
  """Parse the manifest's ``recording:`` block into a :class:`RecordingConfig`.
115
115
 
116
- A missing or non-mapping block leaves recording disabled at its defaults.
117
- Unknown keys are ignored so a manifest can carry forward-looking settings
118
- without breaking an older runtime.
116
+ The block nests under ``runtime:``. A top-level ``recording:`` block is
117
+ still honored for older manifests, and the nested block wins when both are
118
+ present. A missing or non-mapping block leaves recording disabled at its
119
+ defaults. Unknown keys are ignored so a manifest can carry forward-looking
120
+ settings without breaking an older runtime.
119
121
 
120
122
  Args:
121
- block: The raw ``recording:`` value from the manifest, if any.
123
+ runtime: The manifest's ``runtime`` section.
124
+ document: The whole manifest document, for the legacy top-level block.
122
125
 
123
126
  Returns:
124
127
  The parsed recorder configuration.
125
128
  """
129
+ block = runtime.get("recording")
130
+ if not isinstance(block, dict):
131
+ block = document.get("recording")
126
132
  if not isinstance(block, dict):
127
133
  return RecordingConfig()
128
134
  raw_video = block.get("video")
@@ -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.
File without changes
File without changes