reactor-runtime 3.8.0__tar.gz → 3.8.2__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 (130) hide show
  1. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/PKG-INFO +4 -4
  2. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/README.md +2 -2
  3. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/pyproject.toml +3 -3
  4. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/pyproject.toml.orig +3 -3
  5. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/core/__init__.py +9 -1
  6. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/core/model.py +7 -1
  7. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/core/stats.py +4 -1
  8. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/core/transport.py +23 -3
  9. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/core/values.py +26 -0
  10. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/http/routes.py +4 -0
  11. reactor_runtime-3.8.2/src/reactor_runtime/http/step_usage.py +30 -0
  12. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/metrics/session.py +1 -1
  13. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/runner/client_stats.py +21 -2
  14. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/runner/connection_manager.py +32 -2
  15. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/runner/runner.py +175 -42
  16. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/runner/runtime_stats.py +2 -0
  17. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/runner/session_start.py +12 -1
  18. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/step_results/__init__.py +3 -2
  19. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/step_results/mp4.py +26 -5
  20. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/step_results/store.py +140 -17
  21. reactor_runtime-3.8.2/src/reactor_runtime/step_results/usage.py +46 -0
  22. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/transport/webrtc/client_stats.py +15 -0
  23. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/transport/webrtc/connection.py +10 -2
  24. reactor_runtime-3.8.2/src/reactor_runtime/transport/webrtc/frame_stages.py +151 -0
  25. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/transport/webrtc/pacer.py +14 -3
  26. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/transport/webrtc/peer.py +54 -7
  27. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/transport/webrtc/stats.py +39 -0
  28. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_wire/v1/platform_pb2.py +20 -18
  29. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_wire/v1/platform_pb2.pyi +14 -2
  30. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/LICENSE +0 -0
  31. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/NOTICE +0 -0
  32. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/__init__.py +0 -0
  33. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/codes.py +0 -0
  34. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/core/fields.py +0 -0
  35. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/core/naming.py +0 -0
  36. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/core/service.py +0 -0
  37. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/core/session.py +0 -0
  38. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/core/typespec.py +0 -0
  39. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/distributed/__init__.py +0 -0
  40. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/distributed/errors.py +0 -0
  41. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/distributed/ipc.py +0 -0
  42. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/distributed/protocol.py +0 -0
  43. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/distributed/rank.py +0 -0
  44. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/distributed/runner.py +0 -0
  45. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/distributed/worker.py +0 -0
  46. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/event_stream.py +0 -0
  47. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/http/__init__.py +0 -0
  48. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/http/events.py +0 -0
  49. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/http/server.py +0 -0
  50. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/http/spec.py +0 -0
  51. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/interface/__init__.py +0 -0
  52. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/interface/app/__init__.py +0 -0
  53. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/interface/app/input_state.py +0 -0
  54. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/interface/app/outcome.py +0 -0
  55. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/interface/app/reactor_app.py +0 -0
  56. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/interface/app/step_completed.py +0 -0
  57. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/interface/client.py +0 -0
  58. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/interface/events/__init__.py +0 -0
  59. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/interface/events/decorators.py +0 -0
  60. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/interface/events/errors.py +0 -0
  61. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/interface/events/messages.py +0 -0
  62. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/interface/internal/__init__.py +0 -0
  63. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/interface/internal/aliases.py +0 -0
  64. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/interface/internal/bridge.py +0 -0
  65. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/interface/internal/input_buffer.py +0 -0
  66. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/interface/internal/reactor_core.py +0 -0
  67. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/interface/model/__init__.py +0 -0
  68. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/interface/model/contract.py +0 -0
  69. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/interface/model/reactor_model.py +0 -0
  70. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/interface/model/schema.py +0 -0
  71. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/interface/pipeline/__init__.py +0 -0
  72. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/interface/pipeline/idle.py +0 -0
  73. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/interface/pipeline/input_state.py +0 -0
  74. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/interface/pipeline/reactor_pipeline.py +0 -0
  75. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/interface/tracks/__init__.py +0 -0
  76. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/interface/tracks/descriptors.py +0 -0
  77. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/interface/tracks/input.py +0 -0
  78. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/interface/tracks/output.py +0 -0
  79. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/log.py +0 -0
  80. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/manifest.py +0 -0
  81. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/message_gateway.py +0 -0
  82. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/metrics/__init__.py +0 -0
  83. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/metrics/command.py +0 -0
  84. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/metrics/model.py +0 -0
  85. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/metrics/registry.py +0 -0
  86. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/paths.py +0 -0
  87. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/protocol/__init__.py +0 -0
  88. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/protocol/base.py +0 -0
  89. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/protocol/common.py +0 -0
  90. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/protocol/v0/__init__.py +0 -0
  91. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/protocol/v0/codec.py +0 -0
  92. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/protocol/v1/__init__.py +0 -0
  93. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/protocol/v1/codec.py +0 -0
  94. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/py.typed +0 -0
  95. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/recording/__init__.py +0 -0
  96. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/recording/chunk_encoder.py +0 -0
  97. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/recording/markers.py +0 -0
  98. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/recording/recorder.py +0 -0
  99. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/runner/__init__.py +0 -0
  100. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/runner/offer_epochs.py +0 -0
  101. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/runner/state_machine.py +0 -0
  102. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/runner/system_client.py +0 -0
  103. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/runner/upload_resolution.py +0 -0
  104. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/schema.py +0 -0
  105. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/serve.py +0 -0
  106. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/service.py +0 -0
  107. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/transport/__init__.py +0 -0
  108. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/transport/acceptor.py +0 -0
  109. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/transport/router.py +0 -0
  110. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/transport/webrtc/__init__.py +0 -0
  111. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/transport/webrtc/acceptor.py +0 -0
  112. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/transport/webrtc/config.py +0 -0
  113. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/transport/webrtc/frames.py +0 -0
  114. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/transport/webrtc/metrics.py +0 -0
  115. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/transport/webrtc/reading.py +0 -0
  116. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/transport/webrtc/router.py +0 -0
  117. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/transport/webrtc/sdp.py +0 -0
  118. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/transport/webrtc/signaling.py +0 -0
  119. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/transport/webrtc/version.py +0 -0
  120. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_runtime/upload_store.py +0 -0
  121. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_wire/v1/common_pb2.py +0 -0
  122. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_wire/v1/common_pb2.pyi +0 -0
  123. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_wire/v1/control_pb2.py +0 -0
  124. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_wire/v1/control_pb2.pyi +0 -0
  125. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_wire/v1/data_pb2.py +0 -0
  126. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_wire/v1/data_pb2.pyi +0 -0
  127. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_wire/v1/model_pb2.py +0 -0
  128. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_wire/v1/model_pb2.pyi +0 -0
  129. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/src/reactor_wire/v1/track_pb2.py +0 -0
  130. {reactor_runtime-3.8.0 → reactor_runtime-3.8.2}/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.8.0
3
+ Version: 3.8.2
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>
@@ -19,7 +19,7 @@ Requires-Dist: numpy>=2.1
19
19
  Requires-Dist: prometheus-client>=0.26.0
20
20
  Requires-Dist: protobuf>=7.35.1
21
21
  Requires-Dist: pyyaml>=6.0.3
22
- Requires-Dist: reactor-webrtc==0.21.0
22
+ Requires-Dist: reactor-webrtc==0.22.1
23
23
  Requires-Dist: uvicorn>=0.49.0
24
24
  Requires-Python: >=3.12
25
25
  Project-URL: Source, https://github.com/reactor-team/reactor-runtime
@@ -124,7 +124,7 @@ class MyModel(ReactorApp):
124
124
 
125
125
  Command handlers and lifecycle hooks run between steps, never during one. A model has one method per lifecycle hook kind (`@session_started`, `@session_ended`, `@connected`, `@disconnected`, `@file_uploaded`). A subclass extends an inherited hook by overriding that method and calling `super()`; the override runs whether or not it repeats the decorator. Marking a second method with the same kind, in one class or in a subclass, fails when the class is defined, so a hook is never dropped in silence. The default `run()` is the loop that drives the three hooks, and it reports each step that runs with `complete_step()`, which is what the runtime counts a session's steps by. To write that report yourself, for example to keep extra files with the step, return a `StepCompleted(output=..., files=...)` from `process_output()` instead of the `Output`. Override `run()` to write your own loop against `emit()`, `send()`, `complete_step()`, `@event`, `self.connected`, and the tracks; `process_input()`, `generate()`, and `process_output()` are then not called, and your loop calls `complete_step(StepCompleted(output=...))` each time it finishes a unit of work. Do that for a loop that is not one step per emit, such as a renderer that emits several times per step or a model that must block on an input.
126
126
 
127
- A session can start with its setup already in hand: `POST /start_session` takes a `starting_input` of state values and commands, which the runtime applies on the normal command path before any client connects. A system client sends them, a connection the runtime opens itself, so a handler that takes `client` gets a `ClientInfo` whose `system` is true. The step loop takes its first step only once every starting command has landed, uploads included, so no step runs on the defaults the starting input replaces. In a live session the system client then leaves and the loop waits for a viewer, as it always does; a model with its own `run()` sees the system client connect like any client. A live session stays `waiting` while its starting input applies, because the system client does not occupy it, and `/events` journals the system client's `connection_opened` and `connection_closed` as `waiting -> waiting`. A session with `steps` is occupied by the system client until it ends, so it streams with no other client. The runtime ends it itself once the model has reported that many steps, with a normal stop whose close reason is "Session ended: the requested steps are complete."
127
+ A session can start with its setup already in hand: `POST /start_session` takes a `starting_input` of state values and commands, which the runtime applies on the normal command path before any client connects. A system client sends them, a connection the runtime opens itself, so a handler that takes `client` gets a `ClientInfo` whose `system` is true. The step loop takes its first step only once every starting command has landed, uploads included, so no step runs on the defaults the starting input replaces. In a live session the system client then leaves and the loop waits for a viewer, as it always does; a model with its own `run()` sees the system client connect like any client. A live session stays `waiting` while its starting input applies, because the system client does not occupy it, and `/events` journals the system client's `connection_opened` and `connection_closed` as `waiting -> waiting`. A session with `steps` is occupied by the system client until it ends, so it streams with no other client. `steps` is a whole number from 1 to 10,000. The runtime ends it itself once the model has reported that many steps, with a normal stop whose close reason is "Session ended: the requested steps are complete." A session with `steps` has no client to correct its starting input, so a starting command the runtime cannot apply (an upload whose bytes never arrive, or arguments the contract refuses) ends it at once with the reason `start_refused` and the refusal as its close reason. If the handler of a starting command fails instead, by raising `CommandError` or any other exception, every step the model has not reported yet is recorded with the handler's message, so a session at step 3 of 4 saves steps 3 and 4 with that error, and the session ends with the reason `error`. The model keeps running and takes the next session.
128
128
 
129
129
  A model can keep each step it reports as a folder of files, turned on in `reactor.yaml`:
130
130
 
@@ -137,7 +137,7 @@ runtime:
137
137
  queue: 8
138
138
  ```
139
139
 
140
- Every track of the step's output becomes its own stream in one `output.mp4`, beside the extra `files` the step carried and a `result.json` that lists them. `queue` bounds how many steps may wait to be saved; a step that finds it full is not saved, so saving never holds the model up. Each step is its own encode, so this suits a model that finishes a step every few seconds rather than every frame.
140
+ Every track of the step's output becomes its own stream in one `output.mp4`, beside the extra `files` the step carried and a `result.json` that lists them, with the measured length, frame count, frame rate, and size of each video track in its `usage`. `queue` bounds how many steps the model reports may wait to be saved; a step that finds it full is not saved, so saving never holds the model up. The steps a failed starting command records are saved past that bound, so each of them gets its folder. Each step is its own encode, so this suits a model that finishes a step every few seconds rather than every frame.
141
141
 
142
142
  When step results are on, `GET /sessions/{id}/steps` lists a session's saved folders by the session's own id; each stays readable for five minutes after it is written, after the session ends too.
143
143
 
@@ -97,7 +97,7 @@ class MyModel(ReactorApp):
97
97
 
98
98
  Command handlers and lifecycle hooks run between steps, never during one. A model has one method per lifecycle hook kind (`@session_started`, `@session_ended`, `@connected`, `@disconnected`, `@file_uploaded`). A subclass extends an inherited hook by overriding that method and calling `super()`; the override runs whether or not it repeats the decorator. Marking a second method with the same kind, in one class or in a subclass, fails when the class is defined, so a hook is never dropped in silence. The default `run()` is the loop that drives the three hooks, and it reports each step that runs with `complete_step()`, which is what the runtime counts a session's steps by. To write that report yourself, for example to keep extra files with the step, return a `StepCompleted(output=..., files=...)` from `process_output()` instead of the `Output`. Override `run()` to write your own loop against `emit()`, `send()`, `complete_step()`, `@event`, `self.connected`, and the tracks; `process_input()`, `generate()`, and `process_output()` are then not called, and your loop calls `complete_step(StepCompleted(output=...))` each time it finishes a unit of work. Do that for a loop that is not one step per emit, such as a renderer that emits several times per step or a model that must block on an input.
99
99
 
100
- A session can start with its setup already in hand: `POST /start_session` takes a `starting_input` of state values and commands, which the runtime applies on the normal command path before any client connects. A system client sends them, a connection the runtime opens itself, so a handler that takes `client` gets a `ClientInfo` whose `system` is true. The step loop takes its first step only once every starting command has landed, uploads included, so no step runs on the defaults the starting input replaces. In a live session the system client then leaves and the loop waits for a viewer, as it always does; a model with its own `run()` sees the system client connect like any client. A live session stays `waiting` while its starting input applies, because the system client does not occupy it, and `/events` journals the system client's `connection_opened` and `connection_closed` as `waiting -> waiting`. A session with `steps` is occupied by the system client until it ends, so it streams with no other client. The runtime ends it itself once the model has reported that many steps, with a normal stop whose close reason is "Session ended: the requested steps are complete."
100
+ A session can start with its setup already in hand: `POST /start_session` takes a `starting_input` of state values and commands, which the runtime applies on the normal command path before any client connects. A system client sends them, a connection the runtime opens itself, so a handler that takes `client` gets a `ClientInfo` whose `system` is true. The step loop takes its first step only once every starting command has landed, uploads included, so no step runs on the defaults the starting input replaces. In a live session the system client then leaves and the loop waits for a viewer, as it always does; a model with its own `run()` sees the system client connect like any client. A live session stays `waiting` while its starting input applies, because the system client does not occupy it, and `/events` journals the system client's `connection_opened` and `connection_closed` as `waiting -> waiting`. A session with `steps` is occupied by the system client until it ends, so it streams with no other client. `steps` is a whole number from 1 to 10,000. The runtime ends it itself once the model has reported that many steps, with a normal stop whose close reason is "Session ended: the requested steps are complete." A session with `steps` has no client to correct its starting input, so a starting command the runtime cannot apply (an upload whose bytes never arrive, or arguments the contract refuses) ends it at once with the reason `start_refused` and the refusal as its close reason. If the handler of a starting command fails instead, by raising `CommandError` or any other exception, every step the model has not reported yet is recorded with the handler's message, so a session at step 3 of 4 saves steps 3 and 4 with that error, and the session ends with the reason `error`. The model keeps running and takes the next session.
101
101
 
102
102
  A model can keep each step it reports as a folder of files, turned on in `reactor.yaml`:
103
103
 
@@ -110,7 +110,7 @@ runtime:
110
110
  queue: 8
111
111
  ```
112
112
 
113
- Every track of the step's output becomes its own stream in one `output.mp4`, beside the extra `files` the step carried and a `result.json` that lists them. `queue` bounds how many steps may wait to be saved; a step that finds it full is not saved, so saving never holds the model up. Each step is its own encode, so this suits a model that finishes a step every few seconds rather than every frame.
113
+ Every track of the step's output becomes its own stream in one `output.mp4`, beside the extra `files` the step carried and a `result.json` that lists them, with the measured length, frame count, frame rate, and size of each video track in its `usage`. `queue` bounds how many steps the model reports may wait to be saved; a step that finds it full is not saved, so saving never holds the model up. The steps a failed starting command records are saved past that bound, so each of them gets its folder. Each step is its own encode, so this suits a model that finishes a step every few seconds rather than every frame.
114
114
 
115
115
  When step results are on, `GET /sessions/{id}/steps` lists a session's saved folders by the session's own id; each stays readable for five minutes after it is written, after the session ends too.
116
116
 
@@ -10,7 +10,7 @@ module-name = [
10
10
  namespace = true
11
11
 
12
12
  [tool.reactor-wire]
13
- version = "1.20260929.8"
13
+ version = "1.20261013.10"
14
14
 
15
15
  [tool.ruff]
16
16
  line-length = 100
@@ -95,7 +95,7 @@ pythonpath = ["."]
95
95
 
96
96
  [project]
97
97
  name = "reactor-runtime"
98
- version = "3.8.0"
98
+ version = "3.8.2"
99
99
  description = "A Python framework for building real-time, interactive video models"
100
100
  readme = "README.md"
101
101
  license = "Apache-2.0"
@@ -119,7 +119,7 @@ dependencies = [
119
119
  "prometheus-client>=0.26.0",
120
120
  "protobuf>=7.35.1",
121
121
  "pyyaml>=6.0.3",
122
- "reactor-webrtc==0.21.0",
122
+ "reactor-webrtc==0.22.1",
123
123
  "uvicorn>=0.49.0",
124
124
  ]
125
125
 
@@ -16,11 +16,11 @@ namespace = true
16
16
  # gates the pin against proto/, so a release cannot ship a schema older than the
17
17
  # sources it was built from.
18
18
  [tool.reactor-wire]
19
- version = "1.20260929.8"
19
+ version = "1.20261013.10"
20
20
 
21
21
  [project]
22
22
  name = "reactor-runtime"
23
- version = "3.8.0"
23
+ version = "3.8.2"
24
24
  description = "A Python framework for building real-time, interactive video models"
25
25
  readme = "README.md"
26
26
  license = "Apache-2.0"
@@ -42,7 +42,7 @@ dependencies = [
42
42
  "prometheus-client>=0.26.0",
43
43
  "protobuf>=7.35.1",
44
44
  "pyyaml>=6.0.3",
45
- "reactor-webrtc==0.21.0",
45
+ "reactor-webrtc==0.22.1",
46
46
  "uvicorn>=0.49.0",
47
47
  ]
48
48
 
@@ -34,7 +34,12 @@ from reactor_runtime.core.session import (
34
34
  Transition,
35
35
  )
36
36
  from reactor_runtime.core.stats import TransportReading, TransportTrackReading
37
- from reactor_runtime.core.transport import Connection, ConnectionSink, TransportStatsSource
37
+ from reactor_runtime.core.transport import (
38
+ Connection,
39
+ ConnectionSink,
40
+ FrameStageSource,
41
+ TransportStatsSource,
42
+ )
38
43
  from reactor_runtime.core.typespec import TypeSpec
39
44
  from reactor_runtime.core.values import (
40
45
  ClientConnectionStat,
@@ -45,6 +50,7 @@ from reactor_runtime.core.values import (
45
50
  CompletedStep,
46
51
  ConnectionCapabilities,
47
52
  ConnId,
53
+ FrameStage,
48
54
  Health,
49
55
  HealthStatus,
50
56
  InputFrame,
@@ -76,6 +82,8 @@ __all__ = [
76
82
  "EndReason",
77
83
  "FieldInfo",
78
84
  "FileUploaded",
85
+ "FrameStage",
86
+ "FrameStageSource",
79
87
  "Health",
80
88
  "HealthStatus",
81
89
  "InputField",
@@ -189,13 +189,19 @@ def _build_command(cls: type[Command]) -> None:
189
189
 
190
190
 
191
191
  class EndReason(StrEnum):
192
- """Why a session ended."""
192
+ """Why a session ended.
193
+
194
+ ``START_REFUSED`` ends a session with a step count whose starting input
195
+ cannot be applied: a starting command names an upload whose bytes never
196
+ arrive, or the contract refuses its arguments.
197
+ """
193
198
 
194
199
  STOPPED = "stopped"
195
200
  TIMED_OUT = "timed_out"
196
201
  EVICTED = "evicted"
197
202
  MODERATED = "moderated"
198
203
  ERROR = "error"
204
+ START_REFUSED = "start_refused"
199
205
 
200
206
 
201
207
  @dataclass(frozen=True)
@@ -13,7 +13,7 @@ from __future__ import annotations
13
13
  from collections.abc import Mapping
14
14
  from dataclasses import dataclass, field
15
15
 
16
- from reactor_runtime.core.values import ClientTrackDirection, TrackKind
16
+ from reactor_runtime.core.values import ClientTrackDirection, FrameStage, TrackKind
17
17
 
18
18
 
19
19
  @dataclass(frozen=True)
@@ -32,6 +32,8 @@ class TransportTrackReading:
32
32
  ``"packets_lost"``, ``"frames_per_second"``), and a measurement the
33
33
  transport has no value for is left out rather than reported as
34
34
  zero.
35
+ frame_stages: Where the track's frames spent their time on this side
36
+ of the wire since the previous reading, stage by stage.
35
37
  """
36
38
 
37
39
  track_name: str
@@ -39,6 +41,7 @@ class TransportTrackReading:
39
41
  direction: ClientTrackDirection
40
42
  codec: str = ""
41
43
  metrics: Mapping[str, float] = field(default_factory=dict)
44
+ frame_stages: tuple[FrameStage, ...] = ()
42
45
 
43
46
 
44
47
  @dataclass(frozen=True)
@@ -4,13 +4,14 @@ The two structural protocols that form the seam between a transport and the
4
4
  runner, pointing opposite ways. ``Connection`` carries commands down to one
5
5
  client; ``ConnectionSink`` carries facts up from the transport. Both are
6
6
  ``Protocol``s, so a transport author or a test fake conforms by shape and
7
- signaling never reaches the runner. ``TransportStatsSource`` is an optional
8
- capability a connection adds when its transport measures its wire.
7
+ signaling never reaches the runner. ``TransportStatsSource`` and
8
+ ``FrameStageSource`` are optional capabilities a connection adds when its
9
+ transport measures its wire.
9
10
  """
10
11
 
11
12
  from __future__ import annotations
12
13
 
13
- from collections.abc import Mapping
14
+ from collections.abc import Collection, Mapping
14
15
  from typing import Protocol, runtime_checkable
15
16
 
16
17
  from reactor_runtime.core.stats import TransportReading
@@ -18,6 +19,7 @@ from reactor_runtime.core.values import (
18
19
  ClientStatsBatch,
19
20
  ConnectionCapabilities,
20
21
  ConnId,
22
+ FrameStage,
21
23
  InputFrame,
22
24
  MediaChunk,
23
25
  )
@@ -118,6 +120,24 @@ class TransportStatsSource(Protocol):
118
120
  """
119
121
 
120
122
 
123
+ @runtime_checkable
124
+ class FrameStageSource(Protocol):
125
+ """A connection whose transport times each frame's stages on its side of the wire.
126
+
127
+ Optional, beside :class:`TransportStatsSource`, and checked for the same
128
+ way. A track's times add up until they are taken, so each take covers the
129
+ window since that track's previous one.
130
+ """
131
+
132
+ def take_frame_stages(self, tracks: Collection[str]) -> Mapping[str, tuple[FrameStage, ...]]:
133
+ """Return the stage times of *tracks* since their previous take, and start them again.
134
+
135
+ Keyed by track name. A track with no frames in its window is left out,
136
+ and so is a stage with no frames in it. Tracks not named keep their
137
+ times for a later take.
138
+ """
139
+
140
+
121
141
  @runtime_checkable
122
142
  class ConnectionSink(Protocol):
123
143
  """The transport-to-runner upward channel — the facts-up half.
@@ -44,6 +44,29 @@ class CommandFailure:
44
44
  message: str
45
45
 
46
46
 
47
+ @dataclass(frozen=True)
48
+ class FrameStage:
49
+ """The time a track's frames spent in one stage of their trip, over one window.
50
+
51
+ The stages cover the transport between the client's app and the model:
52
+ ``submit``, ``encode_wait``, ``encode``, ``packetize``, ``pacer``,
53
+ ``jitter_buffer``, ``decode``, ``delivery``, ``output_pacing`` and
54
+ ``output_queue``, in the order a frame goes through them. Each side
55
+ reports the stages it can see. The set grows, so a reader ignores a name
56
+ it does not know.
57
+
58
+ Attributes:
59
+ name: The stage.
60
+ total_ms: The time all *frames* frames spent in the stage together,
61
+ so the average per frame is ``total_ms / frames``.
62
+ frames: How many frames the window counted in the stage.
63
+ """
64
+
65
+ name: str
66
+ total_ms: float
67
+ frames: int
68
+
69
+
47
70
  @dataclass(frozen=True)
48
71
  class ClientTrackStat:
49
72
  """One WebRTC quality reading a client took for one of its tracks.
@@ -80,6 +103,8 @@ class ClientTrackStat:
80
103
  readings with different totals cannot be re-aggregated correctly
81
104
  afterward; a raw count can always be summed and divided once, at
82
105
  query time.
106
+ frame_stages: Where the track's frames spent their time since the
107
+ client's previous batch, stage by stage.
83
108
  """
84
109
 
85
110
  timestamp: int
@@ -89,6 +114,7 @@ class ClientTrackStat:
89
114
  codec: str
90
115
  paused: bool
91
116
  metrics: Mapping[str, float]
117
+ frame_stages: tuple[FrameStage, ...] = ()
92
118
 
93
119
 
94
120
  @dataclass(frozen=True)
@@ -18,6 +18,7 @@ from pydantic import BaseModel, Field
18
18
 
19
19
  from reactor_runtime.core import Health, HealthStatus, RuntimeState, SessionState
20
20
  from reactor_runtime.http.events import format_sse
21
+ from reactor_runtime.http.step_usage import StepUsage
21
22
  from reactor_runtime.metrics import CONTENT_TYPE as METRICS_CONTENT_TYPE
22
23
  from reactor_runtime.metrics import RuntimeMetrics
23
24
  from reactor_runtime.recording import ClipManifest, ClipSessionGoneError, Pending
@@ -380,6 +381,9 @@ class StepResult(BaseModel):
380
381
  step: int
381
382
  session_id: str
382
383
  files: list[StepFile] = Field(description="The files beside result.json.")
384
+ usage: list[StepUsage] = Field(
385
+ description="One entry per video track the step wrote; empty when it wrote none."
386
+ )
383
387
  messages: list[StepMessage] = Field(
384
388
  description="The messages the model broadcast since the step before."
385
389
  )
@@ -0,0 +1,30 @@
1
+ """The HTTP shape of what a saved step delivered: the ``usage`` entries of ``result.json``.
2
+
3
+ Each kind of output a step can deliver has its own model here, tagged by a
4
+ ``kind`` literal, and :data:`StepUsage` is the type of one entry. A new kind
5
+ adds its model to this module and joins :data:`StepUsage`, which then becomes
6
+ a union discriminated on ``kind``, so the route models never change.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import Literal
12
+
13
+ from pydantic import BaseModel, Field
14
+
15
+
16
+ class StepVideoUsage(BaseModel):
17
+ """The measured facts of one video track the step wrote, as the file holds it."""
18
+
19
+ kind: Literal["video"]
20
+ track: str = Field(description="The output track the stream holds.")
21
+ file: str = Field(description="The file the stream is in.")
22
+ duration_s: float = Field(description="How long the stream plays, in seconds.")
23
+ frames: int = Field(description="How many frames the stream holds.")
24
+ fps: float = Field(description="The rate the stream plays at, in frames per second.")
25
+ width: int = Field(description="The stream's width in pixels.")
26
+ height: int = Field(description="The stream's height in pixels.")
27
+
28
+
29
+ StepUsage = StepVideoUsage
30
+ """One ``usage`` entry of a step's ``result.json``."""
@@ -29,7 +29,7 @@ def _reason_label(detail: Mapping[str, Any]) -> str:
29
29
 
30
30
  The runtime authors every reason, and a move that names none is a plain stop —
31
31
  the same reading the runner's own dispatch takes. The type guard holds the
32
- label to the five :class:`EndReason` values whatever a caller puts in the
32
+ label to the :class:`EndReason` values whatever a caller puts in the
33
33
  detail, because one unbounded label value costs a series forever.
34
34
  """
35
35
  reason = detail.get("reason", EndReason.STOPPED)
@@ -12,16 +12,17 @@ from __future__ import annotations
12
12
 
13
13
  import math
14
14
  import time
15
- from collections.abc import Callable, Mapping
15
+ from collections.abc import Callable, Iterable, Mapping
16
16
  from typing import Any
17
17
 
18
- from reactor_runtime.core import ClientStatsBatch, ConnId
18
+ from reactor_runtime.core import ClientStatsBatch, ConnId, FrameStage
19
19
 
20
20
  # The SDK sends a batch every few seconds, with a few tracks and a few dozen
21
21
  # metrics each; these limits are far above that.
22
22
  MIN_INTERVAL_SECONDS = 1.0
23
23
  MAX_TRACKS = 32
24
24
  MAX_METRICS = 64
25
+ MAX_FRAME_STAGES = 32
25
26
  MAX_NAME_LENGTH = 128
26
27
 
27
28
 
@@ -70,7 +71,10 @@ def fits(batch: ClientStatsBatch) -> bool:
70
71
  readings.append(batch.connection_stat.metrics)
71
72
  if any(len(metrics) > MAX_METRICS for metrics in readings):
72
73
  return False
74
+ if any(len(stat.frame_stages) > MAX_FRAME_STAGES for stat in batch.track_stats):
75
+ return False
73
76
  names = [name for metrics in readings for name in metrics]
77
+ names += [stage.name for stat in batch.track_stats for stage in stat.frame_stages]
74
78
  names += [field for stat in batch.track_stats for field in (stat.track_name, stat.codec)]
75
79
  return all(len(name) <= MAX_NAME_LENGTH for name in names)
76
80
 
@@ -91,6 +95,7 @@ def to_detail(batch: ClientStatsBatch) -> dict[str, Any]:
91
95
  "codec": stat.codec,
92
96
  "paused": stat.paused,
93
97
  "metrics": _finite(stat.metrics),
98
+ "frame_stages": frame_stages_detail(stat.frame_stages),
94
99
  }
95
100
  for stat in batch.track_stats
96
101
  ],
@@ -105,6 +110,20 @@ def to_detail(batch: ClientStatsBatch) -> dict[str, Any]:
105
110
  }
106
111
 
107
112
 
113
+ def frame_stages_detail(stages: Iterable[FrameStage]) -> list[dict[str, Any]]:
114
+ """Shape a track's stage times for a fact's detail.
115
+
116
+ A stage with no name or no frames has no average, and a negative time or
117
+ one JSON cannot carry is no measurement, so all of them are left out. The
118
+ runtime's own stages follow the same rule when they are timed.
119
+ """
120
+ return [
121
+ {"name": stage.name, "total_ms": stage.total_ms, "frames": stage.frames}
122
+ for stage in stages
123
+ if stage.name and stage.frames > 0 and math.isfinite(stage.total_ms) and stage.total_ms >= 0
124
+ ]
125
+
126
+
108
127
  def _finite(metrics: Mapping[str, float]) -> dict[str, float]:
109
128
  """Keep the metric values JSON can carry: finite numbers, not NaN or infinity."""
110
129
  return {key: value for key, value in metrics.items() if math.isfinite(value)}
@@ -41,11 +41,13 @@ replaced rather than edited.
41
41
  from __future__ import annotations
42
42
 
43
43
  import random
44
- from collections.abc import Callable, Mapping
44
+ from collections.abc import Callable, Iterable, Mapping
45
+ from dataclasses import replace
45
46
 
46
47
  from reactor_runtime.core import (
47
48
  Connection,
48
49
  ConnId,
50
+ FrameStageSource,
49
51
  MediaChunk,
50
52
  SessionEvent,
51
53
  TransportReading,
@@ -146,7 +148,7 @@ class ConnectionManager:
146
148
 
147
149
  A connection whose transport has not taken a reading yet, or does not
148
150
  measure its wire (it is not a :class:`TransportStatsSource`), is left
149
- out.
151
+ out. Reading them changes nothing, so any number of callers can.
150
152
  """
151
153
  return [
152
154
  (cid, reading)
@@ -155,6 +157,34 @@ class ConnectionManager:
155
157
  and (reading := conn.latest_reading) is not None
156
158
  ]
157
159
 
160
+ def take_frame_stages(
161
+ self, readings: Iterable[tuple[ConnId, TransportReading]]
162
+ ) -> list[tuple[ConnId, TransportReading]]:
163
+ """Add each connection's stage times since the previous take to its *readings*.
164
+
165
+ For a connection that times its frames (a :class:`FrameStageSource`),
166
+ each track in its reading gets the stage times the connection held for
167
+ it, and those are taken: the next call gets only what was timed after
168
+ this one, so the runner calls this once per report. Stages of a track
169
+ the reading does not carry yet stay with the connection until a
170
+ reading does. A connection with no reading here keeps all its stages.
171
+ """
172
+ out: list[tuple[ConnId, TransportReading]] = []
173
+ for cid, reading in readings:
174
+ conn = self._by_id.get(cid)
175
+ if isinstance(conn, FrameStageSource) and reading.tracks:
176
+ stages = conn.take_frame_stages([t.track_name for t in reading.tracks])
177
+ if stages:
178
+ reading = replace(
179
+ reading,
180
+ tracks=tuple(
181
+ replace(track, frame_stages=stages.get(track.track_name, ()))
182
+ for track in reading.tracks
183
+ ),
184
+ )
185
+ out.append((cid, reading))
186
+ return out
187
+
158
188
  def new_conn_id(self) -> ConnId:
159
189
  """Mint a fresh random connection id, unique within the session.
160
190