mini-arcade-core 1.2.0__tar.gz → 1.2.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 (99) hide show
  1. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/PKG-INFO +1 -1
  2. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/pyproject.toml +1 -1
  3. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/commands.py +69 -1
  4. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/game.py +5 -3
  5. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/loop/runner.py +27 -4
  6. mini_arcade_core-1.2.2/src/mini_arcade_core/runtime/capture/capture_service.py +96 -0
  7. mini_arcade_core-1.2.2/src/mini_arcade_core/runtime/capture/capture_service_protocol.py +125 -0
  8. mini_arcade_core-1.2.2/src/mini_arcade_core/runtime/capture/capture_settings.py +22 -0
  9. mini_arcade_core-1.2.2/src/mini_arcade_core/runtime/capture/capture_worker.py +174 -0
  10. mini_arcade_core-1.2.2/src/mini_arcade_core/runtime/capture/replay.py +132 -0
  11. mini_arcade_core-1.2.2/src/mini_arcade_core/runtime/capture/replay_format.py +120 -0
  12. mini_arcade_core-1.2.0/src/mini_arcade_core/runtime/capture/capture_adapter.py → mini_arcade_core-1.2.2/src/mini_arcade_core/runtime/capture/screenshot_capturer.py +29 -35
  13. mini_arcade_core-1.2.2/src/mini_arcade_core/runtime/input_frame.py +149 -0
  14. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/services.py +5 -3
  15. mini_arcade_core-1.2.0/src/mini_arcade_core/runtime/input_frame.py +0 -71
  16. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/LICENSE +0 -0
  17. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/README.md +0 -0
  18. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/__init__.py +0 -0
  19. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/backend/__init__.py +0 -0
  20. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/backend/backend.py +0 -0
  21. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/backend/events.py +0 -0
  22. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/backend/keys.py +0 -0
  23. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/backend/sdl_map.py +0 -0
  24. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/backend/types.py +0 -0
  25. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/bus.py +0 -0
  26. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/__init__.py +0 -0
  27. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/cheats.py +0 -0
  28. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/game_config.py +0 -0
  29. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/gameplay_settings.py +0 -0
  30. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/loop/__init__.py +0 -0
  31. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/loop/config.py +0 -0
  32. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/loop/hooks.py +0 -0
  33. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/loop/state.py +0 -0
  34. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/managers.py +0 -0
  35. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/__init__.py +0 -0
  36. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/context.py +0 -0
  37. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/effects/__init__.py +0 -0
  38. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/effects/base.py +0 -0
  39. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/effects/crt.py +0 -0
  40. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/effects/registry.py +0 -0
  41. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/effects/vignette.py +0 -0
  42. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/frame_packet.py +0 -0
  43. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/packet.py +0 -0
  44. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/passes/__init__.py +0 -0
  45. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/passes/base.py +0 -0
  46. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/passes/begin_frame.py +0 -0
  47. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/passes/end_frame.py +0 -0
  48. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/passes/lighting.py +0 -0
  49. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/passes/postfx.py +0 -0
  50. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/passes/ui.py +0 -0
  51. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/passes/world.py +0 -0
  52. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/pipeline.py +0 -0
  53. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/render_service.py +0 -0
  54. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/render/viewport.py +0 -0
  55. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/scenes/__init__.py +0 -0
  56. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/scenes/models.py +0 -0
  57. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/engine/scenes/scene_manager.py +0 -0
  58. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/__init__.py +0 -0
  59. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/audio/__init__.py +0 -0
  60. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/audio/audio_adapter.py +0 -0
  61. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/audio/audio_port.py +0 -0
  62. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/capture/__init__.py +0 -0
  63. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/capture/capture_port.py +0 -0
  64. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/context.py +0 -0
  65. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/file/__init__.py +0 -0
  66. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/file/file_adapter.py +0 -0
  67. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/file/file_port.py +0 -0
  68. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/input/__init__.py +0 -0
  69. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/input/input_adapter.py +0 -0
  70. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/input/input_port.py +0 -0
  71. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/render/__init__.py +0 -0
  72. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/render/render_port.py +0 -0
  73. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/scene/__init__.py +0 -0
  74. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/scene/scene_query_adapter.py +0 -0
  75. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/scene/scene_query_port.py +0 -0
  76. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/window/__init__.py +0 -0
  77. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/window/window_adapter.py +0 -0
  78. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/runtime/window/window_port.py +0 -0
  79. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/scenes/__init__.py +0 -0
  80. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/scenes/autoreg.py +0 -0
  81. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/scenes/debug_overlay.py +0 -0
  82. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/scenes/registry.py +0 -0
  83. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/scenes/sim_scene.py +0 -0
  84. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/scenes/systems/__init__.py +0 -0
  85. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/scenes/systems/base_system.py +0 -0
  86. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/scenes/systems/system_pipeline.py +0 -0
  87. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/spaces/__init__.py +0 -0
  88. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/spaces/d2/__init__.py +0 -0
  89. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/spaces/d2/boundaries2d.py +0 -0
  90. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/spaces/d2/collision2d.py +0 -0
  91. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/spaces/d2/geometry2d.py +0 -0
  92. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/spaces/d2/kinematics2d.py +0 -0
  93. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/spaces/d2/physics2d.py +0 -0
  94. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/ui/__init__.py +0 -0
  95. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/ui/menu.py +0 -0
  96. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/utils/__init__.py +0 -0
  97. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/utils/deprecated_decorator.py +0 -0
  98. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/utils/logging.py +0 -0
  99. {mini_arcade_core-1.2.0 → mini_arcade_core-1.2.2}/src/mini_arcade_core/utils/profiler.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: mini-arcade-core
3
- Version: 1.2.0
3
+ Version: 1.2.2
4
4
  Summary: Tiny scene-based game loop core for small arcade games.
5
5
  License: Copyright (c) 2025 Santiago Rincón
6
6
 
@@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api"
4
4
 
5
5
  [project]
6
6
  name = "mini-arcade-core"
7
- version = "1.2.0"
7
+ version = "1.2.2"
8
8
  description = "Tiny scene-based game loop core for small arcade games."
9
9
  authors = [
10
10
  { name = "Santiago Rincon", email = "rincores@gmail.com" },
@@ -5,9 +5,11 @@ Command protocol for executing commands with a given context.
5
5
  from __future__ import annotations
6
6
 
7
7
  from dataclasses import dataclass, field
8
+ from pathlib import Path
8
9
  from typing import TYPE_CHECKING, List, Optional, Protocol, TypeVar
9
10
 
10
11
  from mini_arcade_core.engine.scenes.models import ScenePolicy
12
+ from mini_arcade_core.runtime.capture.replay_format import ReplayHeader
11
13
 
12
14
  if TYPE_CHECKING:
13
15
  from mini_arcade_core.runtime.services import RuntimeServices
@@ -121,7 +123,7 @@ class ScreenshotCommand(Command):
121
123
  self,
122
124
  context: CommandContext,
123
125
  ):
124
- context.services.capture.screenshot(label=self.label, mode="manual")
126
+ context.services.capture.screenshot(label=self.label)
125
127
 
126
128
 
127
129
  @dataclass(frozen=True)
@@ -216,3 +218,69 @@ class ToggleEffectCommand(Command):
216
218
  if stack is None:
217
219
  return
218
220
  stack.toggle(self.effect_id)
221
+
222
+
223
+ @dataclass(frozen=True)
224
+ class StartReplayRecordCommand(Command):
225
+ """
226
+ Start recording a replay to the specified file.
227
+
228
+ :ivar filename (str): The filename to save the replay to.
229
+ :ivar game_id (str): Identifier of the game.
230
+ :ivar initial_scene (str): The initial scene of the replay.
231
+ :ivar seed (int): The random seed used in the replay.
232
+ :ivar fps (int): Frames per second for the replay.
233
+ """
234
+
235
+ filename: str
236
+ game_id: str = "mini-arcade"
237
+ initial_scene: str = "unknown"
238
+ seed: int = 0
239
+ fps: int = 60
240
+
241
+ def execute(self, context: CommandContext):
242
+ header = ReplayHeader(
243
+ game_id=self.game_id,
244
+ initial_scene=self.initial_scene,
245
+ seed=self.seed,
246
+ fps=self.fps,
247
+ )
248
+ context.services.capture.start_replay_record(
249
+ filename=self.filename,
250
+ header=header,
251
+ )
252
+
253
+
254
+ @dataclass(frozen=True)
255
+ class StopReplayRecordCommand(Command):
256
+ """Stop recording the current replay."""
257
+
258
+ def execute(self, context: CommandContext):
259
+ context.services.capture.stop_replay_record()
260
+
261
+
262
+ @dataclass(frozen=True)
263
+ class StartReplayPlayCommand(Command):
264
+ """
265
+ Start playing back a replay from the specified file.
266
+
267
+ :ivar path (str): The path to the replay file.
268
+ :ivar change_scene (bool): Whether to change to the replay's initial scene.
269
+ """
270
+
271
+ path: str
272
+ change_scene: bool = True
273
+
274
+ def execute(self, context: CommandContext):
275
+ header = context.services.capture.start_replay_play(Path(self.path))
276
+ if self.change_scene:
277
+ # NOTE: **IMPORTANT** align game state with the replay header
278
+ context.managers.scenes.change(header.initial_scene)
279
+
280
+
281
+ @dataclass(frozen=True)
282
+ class StopReplayPlayCommand(Command):
283
+ """Stop playing back the current replay."""
284
+
285
+ def execute(self, context: CommandContext):
286
+ context.services.capture.stop_replay_play()
@@ -24,7 +24,7 @@ from mini_arcade_core.engine.render.pipeline import RenderPipeline
24
24
  from mini_arcade_core.engine.render.render_service import RenderService
25
25
  from mini_arcade_core.engine.scenes.scene_manager import SceneAdapter
26
26
  from mini_arcade_core.runtime.audio.audio_adapter import SDLAudioAdapter
27
- from mini_arcade_core.runtime.capture.capture_adapter import CaptureAdapter
27
+ from mini_arcade_core.runtime.capture.capture_service import CaptureService
28
28
  from mini_arcade_core.runtime.file.file_adapter import LocalFilesAdapter
29
29
  from mini_arcade_core.runtime.input.input_adapter import InputAdapter
30
30
  from mini_arcade_core.runtime.scene.scene_query_adapter import (
@@ -70,10 +70,12 @@ class Game:
70
70
  ),
71
71
  )
72
72
  self.services = RuntimeServices(
73
- window=WindowAdapter(self.backend), # Turn into a manager?
73
+ window=WindowAdapter(self.backend),
74
74
  audio=SDLAudioAdapter(self.backend),
75
75
  files=LocalFilesAdapter(),
76
- capture=CaptureAdapter(self.backend),
76
+ capture=CaptureService(
77
+ self.backend
78
+ ), # NOTE: Should actually be a manager?
77
79
  input=InputAdapter(),
78
80
  render=RenderService(),
79
81
  scenes=SceneQueryAdapter(self.managers.scenes),
@@ -139,14 +139,37 @@ class EngineRunner:
139
139
  def _build_input(
140
140
  self, events, *, frame: FrameState, timer: FrameTimer | None
141
141
  ):
142
- # Build InputFrame from events.
143
- input_frame = self.services.input.build(
144
- events, frame.frame_index, frame.dt
145
- )
142
+ cap = self.services.capture
143
+
144
+ if cap.replay_playing:
145
+ input_frame = cap.next_replay_input()
146
+
147
+ # optional but recommended: keep runner's frame_index/dt authoritative
148
+ input_frame = InputFrame(
149
+ frame_index=frame.frame_index,
150
+ dt=frame.dt,
151
+ keys_down=input_frame.keys_down,
152
+ keys_pressed=input_frame.keys_pressed,
153
+ keys_released=input_frame.keys_released,
154
+ buttons=input_frame.buttons,
155
+ axes=input_frame.axes,
156
+ mouse_pos=input_frame.mouse_pos,
157
+ mouse_delta=input_frame.mouse_delta,
158
+ text_input=input_frame.text_input,
159
+ quit=input_frame.quit,
160
+ )
161
+ else:
162
+ input_frame = self.services.input.build(
163
+ events, frame.frame_index, frame.dt
164
+ )
165
+
146
166
  if timer:
147
167
  timer.mark("input_built")
168
+
148
169
  if input_frame.quit:
149
170
  self.managers.command_queue.push(QuitCommand())
171
+
172
+ cap.record_input(input_frame)
150
173
  return input_frame
151
174
 
152
175
  def _should_quit(self, input_frame: InputFrame) -> bool:
@@ -0,0 +1,96 @@
1
+ """
2
+ Capture service managing screenshots and replays.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from pathlib import Path
8
+ from typing import Optional
9
+
10
+ from mini_arcade_core.backend import Backend
11
+ from mini_arcade_core.runtime.capture.capture_service_protocol import (
12
+ CaptureServicePort,
13
+ )
14
+ from mini_arcade_core.runtime.capture.capture_settings import CaptureSettings
15
+ from mini_arcade_core.runtime.capture.replay import (
16
+ ReplayPlayer,
17
+ ReplayRecorder,
18
+ ReplayRecorderConfig,
19
+ )
20
+ from mini_arcade_core.runtime.capture.replay_format import ReplayHeader
21
+ from mini_arcade_core.runtime.capture.screenshot_capturer import (
22
+ ScreenshotCapturer,
23
+ )
24
+ from mini_arcade_core.runtime.input_frame import InputFrame
25
+
26
+
27
+ class CaptureService(CaptureServicePort):
28
+ """
29
+ Owns:
30
+ - screenshots (delegated)
31
+ - replay recording (InputFrame stream)
32
+ - replay playback (feeds InputFrames)
33
+ - (later) video recording
34
+ """
35
+
36
+ # pylint: disable=too-many-arguments
37
+ def __init__(
38
+ self,
39
+ backend: Backend,
40
+ *,
41
+ screenshots: Optional[ScreenshotCapturer] = None,
42
+ replay_recorder: Optional[ReplayRecorder] = None,
43
+ replay_player: Optional[ReplayPlayer] = None,
44
+ settings: Optional[CaptureSettings] = None,
45
+ ):
46
+ self.backend = backend
47
+ self.settings = settings or CaptureSettings()
48
+
49
+ self.screenshots = screenshots or ScreenshotCapturer(backend)
50
+ self.replay_recorder = replay_recorder or ReplayRecorder()
51
+ self.replay_player = replay_player or ReplayPlayer()
52
+
53
+ # -------- screenshots --------
54
+ def screenshot(self, label: str | None = None) -> str:
55
+ return self.screenshots.screenshot(label)
56
+
57
+ def screenshot_sim(
58
+ self, run_id: str, frame_index: int, label: str = "frame"
59
+ ) -> str:
60
+ return self.screenshots.screenshot_sim(run_id, frame_index, label)
61
+
62
+ # -------- replays --------
63
+ @property
64
+ def replay_playing(self) -> bool:
65
+ return self.replay_player.active
66
+
67
+ @property
68
+ def replay_recording(self) -> bool:
69
+ return self.replay_recorder.active
70
+
71
+ def start_replay_record(
72
+ self,
73
+ *,
74
+ filename: str,
75
+ header: ReplayHeader,
76
+ ) -> None:
77
+ path = Path(self.settings.replays_dir) / filename
78
+ self.replay_recorder.start(
79
+ ReplayRecorderConfig(path=path, header=header)
80
+ )
81
+
82
+ def stop_replay_record(self) -> None:
83
+ self.replay_recorder.stop()
84
+
85
+ def record_input(self, frame: InputFrame) -> None:
86
+ self.replay_recorder.record(frame)
87
+
88
+ def start_replay_play(self, filename: str) -> ReplayHeader:
89
+ path = Path(self.settings.replays_dir) / filename
90
+ return self.replay_player.start(path)
91
+
92
+ def stop_replay_play(self) -> None:
93
+ self.replay_player.stop()
94
+
95
+ def next_replay_input(self) -> InputFrame:
96
+ return self.replay_player.next()
@@ -0,0 +1,125 @@
1
+ """
2
+ Capture service protocol
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from mini_arcade_core.backend import Backend
8
+ from mini_arcade_core.runtime.capture.capture_settings import CaptureSettings
9
+ from mini_arcade_core.runtime.capture.replay import (
10
+ ReplayPlayer,
11
+ ReplayRecorder,
12
+ )
13
+ from mini_arcade_core.runtime.capture.replay_format import ReplayHeader
14
+ from mini_arcade_core.runtime.capture.screenshot_capturer import (
15
+ ScreenshotCapturer,
16
+ )
17
+ from mini_arcade_core.runtime.input_frame import InputFrame
18
+
19
+
20
+ class CaptureServicePort:
21
+ """
22
+ Interface for the Capture Service.
23
+ """
24
+
25
+ backend: Backend
26
+ settings: CaptureSettings
27
+ screenshots: ScreenshotCapturer
28
+ replay_recorder: ReplayRecorder
29
+ replay_player: ReplayPlayer
30
+
31
+ # -------- screenshots --------
32
+ def screenshot(self, label: str | None = None) -> str:
33
+ """
34
+ Take a screenshot with an optional label.
35
+
36
+ :param label: Optional label for the screenshot.
37
+ :type label: str | None
38
+ :return: Path to the saved screenshot.
39
+ :rtype: str
40
+ """
41
+
42
+ def screenshot_sim(
43
+ self, run_id: str, frame_index: int, label: str = "frame"
44
+ ) -> str:
45
+ """
46
+ Take a screenshot for a simulation frame.
47
+
48
+ :param run_id: Unique identifier for the simulation run.
49
+ :type run_id: str
50
+
51
+ :param frame_index: Index of the frame in the simulation.
52
+ :type frame_index: int
53
+
54
+ :param label: Label for the screenshot.
55
+ :type label: str
56
+
57
+ :return: Path to the saved screenshot.
58
+ :rtype: str
59
+ """
60
+
61
+ # -------- replays --------
62
+ @property
63
+ def replay_playing(self) -> bool:
64
+ """
65
+ Check if a replay is currently being played back.
66
+
67
+ :return: True if a replay is active, False otherwise.
68
+ :rtype: bool
69
+ """
70
+
71
+ @property
72
+ def replay_recording(self) -> bool:
73
+ """
74
+ Check if a replay is currently being recorded.
75
+
76
+ :return: True if recording is active, False otherwise.
77
+ :rtype: bool
78
+ """
79
+
80
+ def start_replay_record(
81
+ self,
82
+ *,
83
+ filename: str,
84
+ header: ReplayHeader,
85
+ ) -> None:
86
+ """
87
+ Start recording a replay.
88
+
89
+ :param filename: The filename to save the replay to.
90
+ :type filename: str
91
+ :param header: The header information for the replay.
92
+ :type header: ReplayHeader
93
+ """
94
+
95
+ def stop_replay_record(self) -> None:
96
+ """Stop recording the current replay."""
97
+
98
+ def record_input(self, frame: InputFrame) -> None:
99
+ """
100
+ Record an input frame to the replay.
101
+
102
+ :param frame: The input frame to record.
103
+ :type frame: InputFrame
104
+ """
105
+
106
+ def start_replay_play(self, filename: str) -> ReplayHeader:
107
+ """
108
+ Start playing back a replay.
109
+
110
+ :param filename: The filename of the replay to play.
111
+ :type filename: str
112
+ :return: The header information of the replay.
113
+ :rtype: ReplayHeader
114
+ """
115
+
116
+ def stop_replay_play(self) -> None:
117
+ """Stop playing back the current replay."""
118
+
119
+ def next_replay_input(self) -> InputFrame:
120
+ """
121
+ Get the next input frame from the replay.
122
+
123
+ :return: The next input frame.
124
+ :rtype: InputFrame
125
+ """
@@ -0,0 +1,22 @@
1
+ """
2
+ Capture settings dataclass
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass
8
+
9
+
10
+ @dataclass
11
+ class CaptureSettings:
12
+ """
13
+ Settings for the Capture Service.
14
+
15
+ :ivar screenshots_dir: Directory to save screenshots.
16
+ :ivar screenshots_ext: File extension/format for screenshots.
17
+ :ivar replays_dir: Directory to save replays.
18
+ """
19
+
20
+ screenshots_dir: str = "screenshots"
21
+ screenshots_ext: str = "png"
22
+ replays_dir: str = "replays"
@@ -0,0 +1,174 @@
1
+ """
2
+ Capture worker thread for saving screenshots.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from queue import Empty, Queue
10
+ from threading import Event, Thread
11
+ from typing import Callable, Optional
12
+
13
+ from PIL import Image
14
+
15
+ from mini_arcade_core.utils import logger
16
+
17
+
18
+ @dataclass(frozen=True)
19
+ class CaptureJob:
20
+ """
21
+ Job representing a screenshot to be saved.
22
+
23
+ :ivar job_id (str): Unique identifier for the capture job.
24
+ :ivar out_path (Path): Destination path for the saved screenshot.
25
+ :ivar bmp_path (Path): Temporary path of the bitmap image to be saved.
26
+ """
27
+
28
+ job_id: str
29
+ out_path: Path
30
+ bmp_path: Path # <-- file-based now
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class CaptureResult:
35
+ """
36
+ Result of a completed capture job.
37
+
38
+ :ivar job_id (str): Unique identifier for the capture job.
39
+ :ivar out_path (Path): Destination path where the screenshot was saved.
40
+ :ivar ok (bool): Whether the capture was successful.
41
+ :ivar error (Optional[str]): Error message if the capture failed.
42
+ """
43
+
44
+ job_id: str
45
+ out_path: Path
46
+ ok: bool
47
+ error: str | None = None
48
+
49
+
50
+ @dataclass
51
+ class WorkerConfig:
52
+ """
53
+ Configuration options for the CaptureWorker.
54
+
55
+ :ivar queue_size (int): Maximum number of jobs to queue.
56
+ :ivar on_done (Optional[Callable[[CaptureResult], None]]):
57
+ Optional callback invoked when a job is done.
58
+ :ivar name (str): Name of the worker thread.
59
+ :ivar daemon (bool): Whether the thread is a daemon thread.
60
+ :ivar delete_temp (bool): Whether to delete temporary bitmap files after saving.
61
+ """
62
+
63
+ queue_size: int = 64
64
+ on_done: Optional[Callable[[CaptureResult], None]] = None
65
+ name: str = "capture-worker"
66
+ daemon: bool = True
67
+ delete_temp: bool = True
68
+
69
+
70
+ class CaptureWorker:
71
+ """Capture worker thread for saving screenshots asynchronously."""
72
+
73
+ def __init__(
74
+ self,
75
+ worker_config: WorkerConfig | None = None,
76
+ ):
77
+ """
78
+ :param queue_size: Maximum number of jobs to queue.
79
+ :type queue_size: int
80
+ :param on_done: Optional callback invoked when a job is done.
81
+ :type on_done: Optional[Callable[[CaptureResult], None]]
82
+ :param name: Name of the worker thread.
83
+ :type name: str
84
+ :param daemon: Whether the thread is a daemon thread.
85
+ :type daemon: bool
86
+ :param delete_temp: Whether to delete temporary bitmap files after saving.
87
+ :type delete_temp: bool
88
+ """
89
+ if worker_config is None:
90
+ worker_config = WorkerConfig()
91
+ self._q: Queue[CaptureJob] = Queue(maxsize=worker_config.queue_size)
92
+ self._stop = Event()
93
+ self._thread = Thread(
94
+ target=self._run,
95
+ name=worker_config.name,
96
+ daemon=worker_config.daemon,
97
+ )
98
+ self._on_done = worker_config.on_done
99
+ self._delete_temp = worker_config.delete_temp
100
+
101
+ def start(self):
102
+ """Start the capture worker thread."""
103
+ if self._thread.is_alive():
104
+ return
105
+ self._stop.clear()
106
+ self._thread.start()
107
+
108
+ def stop(self):
109
+ """Stop the capture worker thread."""
110
+ self._stop.set()
111
+ if self._thread.is_alive():
112
+ self._thread.join(timeout=2.0)
113
+
114
+ def enqueue(self, job: CaptureJob) -> bool:
115
+ """
116
+ Enqueue a capture job.
117
+
118
+ :param job: CaptureJob to enqueue.
119
+ :type job: CaptureJob
120
+ :return: True if the job was enqueued successfully, False otherwise.
121
+ :rtype: bool
122
+ """
123
+ if self._stop.is_set():
124
+ return False
125
+ try:
126
+ self._q.put_nowait(job)
127
+ return True
128
+ # Justification: Queue.put_nowait can raise a broad exception
129
+ # pylint: disable=broad-exception-caught
130
+ except Exception:
131
+ return False
132
+ # pylint: enable=broad-exception-caught
133
+
134
+ def _run(self):
135
+ while not self._stop.is_set():
136
+ try:
137
+ job = self._q.get(timeout=0.1)
138
+ except Empty:
139
+ continue
140
+
141
+ try:
142
+ job.out_path.parent.mkdir(parents=True, exist_ok=True)
143
+
144
+ img = Image.open(str(job.bmp_path))
145
+ img.save(str(job.out_path))
146
+
147
+ if self._delete_temp:
148
+ try:
149
+ job.bmp_path.unlink(missing_ok=True)
150
+ except Exception: # pylint: disable=broad-exception-caught
151
+ logger.warning(
152
+ f"Failed to delete temp bmp: {job.bmp_path}"
153
+ )
154
+
155
+ res = CaptureResult(
156
+ job_id=job.job_id, out_path=job.out_path, ok=True
157
+ )
158
+
159
+ except Exception as exc: # pylint: disable=broad-exception-caught
160
+ logger.exception("CaptureWorker failed to save screenshot")
161
+ res = CaptureResult(
162
+ job_id=job.job_id,
163
+ out_path=job.out_path,
164
+ ok=False,
165
+ error=str(exc),
166
+ )
167
+
168
+ if self._on_done:
169
+ try:
170
+ self._on_done(res)
171
+ except Exception: # pylint: disable=broad-exception-caught
172
+ logger.warning("CaptureWorker on_done callback failed")
173
+
174
+ self._q.task_done()