e2D 2.0.0__cp313-cp313-win_amd64.whl → 2.0.1__cp313-cp313-win_amd64.whl
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.
- e2D/__init__.py +199 -72
- e2D/__init__.pyi +145 -0
- e2D/ccolors.c +34514 -0
- e2D/ccolors.cp313-win_amd64.pyd +0 -0
- e2D/ccolors.pyi +51 -0
- e2D/ccolors.pyx +350 -0
- e2D/color_defs.py +238 -0
- e2D/colors.py +380 -0
- e2D/colors.pyi +104 -0
- e2D/commons.py +38 -10
- e2D/commons.pyi +79 -0
- e2D/cvectors.c +152 -152
- e2D/cvectors.cp313-win_amd64.pyd +0 -0
- e2D/cvectors.pyi +243 -0
- e2D/devices.py +19 -6
- e2D/devices.pyi +65 -0
- e2D/plots.py +55 -29
- e2D/plots.pyi +238 -0
- e2D/shapes.py +81 -44
- e2D/shapes.pyi +272 -0
- e2D/test_colors.py +122 -0
- e2D/text_renderer.py +46 -16
- e2D/text_renderer.pyi +118 -0
- e2D/types.py +58 -0
- e2D/types.pyi +61 -0
- e2D/vectors.py +153 -61
- e2D/vectors.pyi +106 -0
- e2D/winrec.py +275 -0
- e2D/winrec.pyi +87 -0
- {e2d-2.0.0.dist-info → e2d-2.0.1.dist-info}/METADATA +43 -15
- e2d-2.0.1.dist-info/RECORD +46 -0
- {e2d-2.0.0.dist-info → e2d-2.0.1.dist-info}/WHEEL +1 -1
- e2d-2.0.0.dist-info/RECORD +0 -26
- {e2d-2.0.0.dist-info → e2d-2.0.1.dist-info}/licenses/LICENSE +0 -0
- {e2d-2.0.0.dist-info → e2d-2.0.1.dist-info}/top_level.txt +0 -0
e2D/types.pyi
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Type stubs for types module
|
|
3
|
+
Common type definitions for e2D library
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import Union, Sequence
|
|
7
|
+
import numpy as np
|
|
8
|
+
import numpy.typing as npt
|
|
9
|
+
import moderngl
|
|
10
|
+
from .cvectors import Vector2D
|
|
11
|
+
from glfw import _GLFWwindow
|
|
12
|
+
|
|
13
|
+
# Type alias for vector-like objects - use this for ALL 2D vectors, positions, and sizes
|
|
14
|
+
# Accepts Vector2D instances, tuples (x, y), or lists [x, y]
|
|
15
|
+
VectorType = Union[Vector2D, tuple[int | float, int | float], Sequence[int | float]]
|
|
16
|
+
|
|
17
|
+
# Color type (RGBA values between 0.0 and 1.0)
|
|
18
|
+
ColorType = tuple[float, float, float, float]
|
|
19
|
+
|
|
20
|
+
# Numeric types
|
|
21
|
+
Number = int | float
|
|
22
|
+
IntVec2 = tuple[int, int]
|
|
23
|
+
FloatVec2 = tuple[float, float]
|
|
24
|
+
NumVec2 = tuple[Number, Number]
|
|
25
|
+
|
|
26
|
+
# Array-like types for numpy and buffers
|
|
27
|
+
pArray = list | tuple
|
|
28
|
+
ArrayLike = npt.NDArray[np.float32] | npt.NDArray[np.float64] | npt.NDArray[np.int32]
|
|
29
|
+
|
|
30
|
+
# Shader and GPU resource types
|
|
31
|
+
ContextType = moderngl.Context
|
|
32
|
+
ProgramType = moderngl.Program
|
|
33
|
+
ComputeShaderType = moderngl.ComputeShader
|
|
34
|
+
BufferType = moderngl.Buffer
|
|
35
|
+
VAOType = moderngl.VertexArray
|
|
36
|
+
TextureType = moderngl.Texture
|
|
37
|
+
|
|
38
|
+
ProgramAttrType = moderngl.Uniform | moderngl.UniformBlock | moderngl.Attribute | moderngl.Varying
|
|
39
|
+
UniformType = moderngl.Uniform
|
|
40
|
+
UniformBlockType = moderngl.UniformBlock
|
|
41
|
+
|
|
42
|
+
# Window type
|
|
43
|
+
WindowType = _GLFWwindow
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
__all__ = [
|
|
47
|
+
'VectorType',
|
|
48
|
+
'Vector2D',
|
|
49
|
+
'ColorType',
|
|
50
|
+
'Number',
|
|
51
|
+
'IntVec2',
|
|
52
|
+
'FloatVec2',
|
|
53
|
+
'NumVec2',
|
|
54
|
+
'ArrayLike',
|
|
55
|
+
'ContextType',
|
|
56
|
+
'ProgramType',
|
|
57
|
+
'BufferType',
|
|
58
|
+
'VAOType',
|
|
59
|
+
'TextureType',
|
|
60
|
+
'WindowType',
|
|
61
|
+
]
|
e2D/vectors.py
CHANGED
|
@@ -3,73 +3,164 @@ High-level Python wrapper for Vector2D with additional utilities
|
|
|
3
3
|
Provides compatibility layer and convenience functions
|
|
4
4
|
"""
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
6
|
+
from typing import Any, List, Literal, Protocol, runtime_checkable, TYPE_CHECKING, Sequence, cast
|
|
7
|
+
import numpy as np
|
|
8
|
+
from numpy.typing import NDArray
|
|
9
|
+
|
|
10
|
+
# Define a protocol that both implementations must follow
|
|
11
|
+
@runtime_checkable
|
|
12
|
+
class Vector2DProtocol(Protocol):
|
|
13
|
+
"""Protocol defining the Vector2D interface for type checking"""
|
|
14
|
+
x: float
|
|
15
|
+
y: float
|
|
16
|
+
|
|
17
|
+
def __init__(self, x: float = 0.0, y: float = 0.0) -> None: ...
|
|
18
|
+
def copy(self) -> "Vector2DProtocol": ...
|
|
19
|
+
def normalize(self) -> None: ...
|
|
20
|
+
def __add__(self, other: Any) -> "Vector2DProtocol": ...
|
|
21
|
+
def __sub__(self, other: Any) -> "Vector2DProtocol": ...
|
|
22
|
+
def __mul__(self, other: Any) -> "Vector2DProtocol": ...
|
|
23
|
+
def __getitem__(self, idx: int) -> float: ...
|
|
24
|
+
def __setitem__(self, idx: int, value: float) -> None: ...
|
|
25
|
+
def __len__(self) -> int: ...
|
|
26
|
+
@property
|
|
27
|
+
def length(self) -> float: ...
|
|
28
|
+
@property
|
|
29
|
+
def length_sqrd(self) -> float: ...
|
|
30
|
+
|
|
31
|
+
# For type checking - define stubs
|
|
32
|
+
if TYPE_CHECKING:
|
|
33
|
+
# Constructor function for type checking
|
|
34
|
+
def Vector2D(x: float = 0.0, y: float = 0.0) -> Vector2DProtocol: ... # noqa: F811
|
|
35
|
+
|
|
36
|
+
# Type stubs for batch operations
|
|
37
|
+
def batch_add_inplace(vectors: Sequence[Vector2DProtocol], displacement: Vector2DProtocol) -> None: ...
|
|
38
|
+
def batch_scale_inplace(vectors: Sequence[Vector2DProtocol], scalar: float) -> None: ...
|
|
39
|
+
def batch_normalize_inplace(vectors: Sequence[Vector2DProtocol]) -> None: ...
|
|
40
|
+
def vectors_to_array(vectors: Sequence[Vector2DProtocol]) -> NDArray[np.float64]: ...
|
|
41
|
+
def array_to_vectors(arr: NDArray[np.float64]) -> list[Vector2DProtocol]: ...
|
|
42
|
+
|
|
43
|
+
# Try to import the optimized Cython version, fall back to pure Python
|
|
44
|
+
_COMPILED = False
|
|
45
|
+
if not TYPE_CHECKING:
|
|
46
|
+
try:
|
|
47
|
+
from .cvectors import ( # type: ignore[assignment]
|
|
48
|
+
Vector2D,
|
|
49
|
+
batch_add_inplace,
|
|
50
|
+
batch_scale_inplace,
|
|
51
|
+
batch_normalize_inplace,
|
|
52
|
+
vectors_to_array,
|
|
53
|
+
array_to_vectors,
|
|
54
|
+
)
|
|
55
|
+
_COMPILED = True
|
|
56
|
+
except ImportError:
|
|
57
|
+
print("WARNING: Compiled cvectors module not found. Please run: python setup.py build_ext --inplace")
|
|
58
|
+
print("Falling back to pure Python implementation (slower)")
|
|
59
|
+
_COMPILED = False
|
|
48
60
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
61
|
+
# Fallback pure Python implementation
|
|
62
|
+
class Vector2D: # type: ignore[no-redef]
|
|
63
|
+
"""Pure Python fallback (much slower than compiled version)"""
|
|
64
|
+
def __init__(self, x: float = 0.0, y: float = 0.0) -> None:
|
|
65
|
+
self.data = np.array([x, y], dtype=np.float64)
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def x(self) -> float:
|
|
69
|
+
return self.data[0]
|
|
70
|
+
|
|
71
|
+
@x.setter
|
|
72
|
+
def x(self, value: float) -> None:
|
|
73
|
+
self.data[0] = value
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def y(self) -> float:
|
|
77
|
+
return self.data[1]
|
|
78
|
+
|
|
79
|
+
@y.setter
|
|
80
|
+
def y(self, value: float) -> None:
|
|
81
|
+
self.data[1] = value
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def length(self) -> np.floating:
|
|
85
|
+
return np.linalg.norm(self.data)
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def length_sqrd(self) -> float:
|
|
89
|
+
return np.dot(self.data, self.data)
|
|
90
|
+
|
|
91
|
+
def copy(self) -> "Vector2D":
|
|
92
|
+
return Vector2D(self.x, self.y)
|
|
93
|
+
|
|
94
|
+
def normalize(self) -> None:
|
|
95
|
+
"""Normalize this vector in place"""
|
|
96
|
+
length = self.length
|
|
97
|
+
if length > 0:
|
|
98
|
+
self.data /= length
|
|
99
|
+
|
|
100
|
+
def __add__(self, other) -> "Vector2D":
|
|
101
|
+
if isinstance(other, Vector2D):
|
|
102
|
+
return Vector2D(self.x + other.x, self.y + other.y)
|
|
103
|
+
return Vector2D(self.x + other, self.y + other)
|
|
104
|
+
|
|
105
|
+
def __sub__(self, other) -> "Vector2D":
|
|
106
|
+
if isinstance(other, Vector2D):
|
|
107
|
+
return Vector2D(self.x - other.x, self.y - other.y)
|
|
108
|
+
return Vector2D(self.x - other, self.y - other)
|
|
109
|
+
|
|
110
|
+
def __mul__(self, other) -> "Vector2D":
|
|
111
|
+
if isinstance(other, Vector2D):
|
|
112
|
+
return Vector2D(self.x * other.x, self.y * other.y)
|
|
113
|
+
return Vector2D(self.x * other, self.y * other)
|
|
114
|
+
|
|
115
|
+
def __getitem__(self, idx) -> float:
|
|
116
|
+
"""Support indexing like a tuple (v[0], v[1])"""
|
|
117
|
+
if idx == 0:
|
|
118
|
+
return self.x
|
|
119
|
+
elif idx == 1:
|
|
120
|
+
return self.y
|
|
121
|
+
raise IndexError("Vector2D index out of range")
|
|
122
|
+
|
|
123
|
+
def __setitem__(self, idx, value) -> None:
|
|
124
|
+
"""Support setting by index (v[0] = x)"""
|
|
125
|
+
if idx == 0:
|
|
126
|
+
self.x = value
|
|
127
|
+
elif idx == 1:
|
|
128
|
+
self.y = value
|
|
129
|
+
else:
|
|
130
|
+
raise IndexError("Vector2D index out of range")
|
|
131
|
+
|
|
132
|
+
def __len__(self) -> Literal[2]:
|
|
133
|
+
"""Support len(v) -> 2"""
|
|
134
|
+
return 2
|
|
135
|
+
|
|
136
|
+
def __repr__(self) -> str:
|
|
137
|
+
return f"Vector2D({self.x}, {self.y})"
|
|
52
138
|
|
|
53
|
-
|
|
54
|
-
|
|
139
|
+
# Fallback batch operations
|
|
140
|
+
def batch_add_inplace(vectors: Sequence[Vector2D], displacement: Vector2D) -> None: # type: ignore[misc]
|
|
141
|
+
"""Pure Python fallback for batch add"""
|
|
142
|
+
for vec in vectors:
|
|
143
|
+
vec.data += displacement.data # type: ignore[attr-defined]
|
|
55
144
|
|
|
56
|
-
def
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
145
|
+
def batch_scale_inplace(vectors: Sequence[Vector2D], scalar: float) -> None: # type: ignore[misc]
|
|
146
|
+
"""Pure Python fallback for batch scale"""
|
|
147
|
+
for vec in vectors:
|
|
148
|
+
vec.data *= scalar # type: ignore[attr-defined]
|
|
60
149
|
|
|
61
|
-
def
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
150
|
+
def batch_normalize_inplace(vectors: Sequence[Vector2D]) -> None: # type: ignore[misc]
|
|
151
|
+
"""Pure Python fallback for batch normalize"""
|
|
152
|
+
for vec in vectors:
|
|
153
|
+
length = vec.length
|
|
154
|
+
if length > 0:
|
|
155
|
+
vec.data /= length # type: ignore[attr-defined]
|
|
65
156
|
|
|
66
|
-
def
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
return Vector2D(self.x * other, self.y * other)
|
|
157
|
+
def vectors_to_array(vectors: Sequence[Vector2D]) -> NDArray[np.float64]: # type: ignore[misc]
|
|
158
|
+
"""Pure Python fallback for vectors to array"""
|
|
159
|
+
return np.array([v.data for v in vectors], dtype=np.float64) # type: ignore[attr-defined]
|
|
70
160
|
|
|
71
|
-
def
|
|
72
|
-
|
|
161
|
+
def array_to_vectors(arr: NDArray[np.float64]) -> list[Vector2D]: # type: ignore[misc]
|
|
162
|
+
"""Pure Python fallback for array to vectors"""
|
|
163
|
+
return [Vector2D(row[0], row[1]) for row in arr]
|
|
73
164
|
|
|
74
165
|
|
|
75
166
|
# Convenience aliases
|
|
@@ -129,6 +220,7 @@ __all__ = [
|
|
|
129
220
|
'batch_normalize_inplace',
|
|
130
221
|
'vectors_to_array',
|
|
131
222
|
'array_to_vectors',
|
|
223
|
+
'_COMPILED',
|
|
132
224
|
]
|
|
133
225
|
|
|
134
226
|
|
e2D/vectors.pyi
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Type stubs for vectors module
|
|
3
|
+
High-level Python wrapper for Vector2D with additional utilities
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from typing import List, Tuple, Callable
|
|
7
|
+
from .cvectors import Vector2D, batch_add_inplace, batch_scale_inplace, batch_normalize_inplace, vectors_to_array, array_to_vectors
|
|
8
|
+
|
|
9
|
+
_COMPILED : bool
|
|
10
|
+
# Convenience aliases
|
|
11
|
+
V2 = Vector2D
|
|
12
|
+
Vec2 = Vector2D
|
|
13
|
+
|
|
14
|
+
# Pre-defined common vectors
|
|
15
|
+
class CommonVectors:
|
|
16
|
+
"""Pre-allocated common vectors (do not modify these!)"""
|
|
17
|
+
ZERO: Vector2D
|
|
18
|
+
ONE: Vector2D
|
|
19
|
+
UP: Vector2D
|
|
20
|
+
DOWN: Vector2D
|
|
21
|
+
LEFT: Vector2D
|
|
22
|
+
RIGHT: Vector2D
|
|
23
|
+
|
|
24
|
+
@staticmethod
|
|
25
|
+
def zero() -> Vector2D:
|
|
26
|
+
"""Create new zero vector"""
|
|
27
|
+
...
|
|
28
|
+
|
|
29
|
+
@staticmethod
|
|
30
|
+
def one() -> Vector2D:
|
|
31
|
+
"""Create new one vector"""
|
|
32
|
+
...
|
|
33
|
+
|
|
34
|
+
@staticmethod
|
|
35
|
+
def up() -> Vector2D:
|
|
36
|
+
"""Create new up vector"""
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
@staticmethod
|
|
40
|
+
def down() -> Vector2D:
|
|
41
|
+
"""Create new down vector"""
|
|
42
|
+
...
|
|
43
|
+
|
|
44
|
+
@staticmethod
|
|
45
|
+
def left() -> Vector2D:
|
|
46
|
+
"""Create new left vector"""
|
|
47
|
+
...
|
|
48
|
+
|
|
49
|
+
@staticmethod
|
|
50
|
+
def right() -> Vector2D:
|
|
51
|
+
"""Create new right vector"""
|
|
52
|
+
...
|
|
53
|
+
|
|
54
|
+
# Additional utility functions
|
|
55
|
+
def lerp(start: float, end: float, t: float) -> float:
|
|
56
|
+
"""Linear interpolation between two values"""
|
|
57
|
+
...
|
|
58
|
+
|
|
59
|
+
def create_grid(width: int, height: int, spacing: float = 1.0) -> List[Vector2D]:
|
|
60
|
+
"""
|
|
61
|
+
Create a grid of vectors
|
|
62
|
+
|
|
63
|
+
Args:
|
|
64
|
+
width: Number of columns
|
|
65
|
+
height: Number of rows
|
|
66
|
+
spacing: Distance between points
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
List of Vector2D objects
|
|
70
|
+
"""
|
|
71
|
+
...
|
|
72
|
+
|
|
73
|
+
def create_circle(radius: float, num_points: int) -> List[Vector2D]:
|
|
74
|
+
"""
|
|
75
|
+
Create vectors arranged in a circle
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
radius: Circle radius
|
|
79
|
+
num_points: Number of points
|
|
80
|
+
|
|
81
|
+
Returns:
|
|
82
|
+
List of Vector2D objects
|
|
83
|
+
"""
|
|
84
|
+
...
|
|
85
|
+
|
|
86
|
+
def benchmark(num_iterations: int = 100000) -> None:
|
|
87
|
+
"""
|
|
88
|
+
Run a simple benchmark to test vector performance
|
|
89
|
+
"""
|
|
90
|
+
...
|
|
91
|
+
|
|
92
|
+
__all__ = [
|
|
93
|
+
'Vector2D',
|
|
94
|
+
'V2',
|
|
95
|
+
'Vec2',
|
|
96
|
+
'CommonVectors',
|
|
97
|
+
'batch_add_inplace',
|
|
98
|
+
'batch_scale_inplace',
|
|
99
|
+
'batch_normalize_inplace',
|
|
100
|
+
'vectors_to_array',
|
|
101
|
+
'array_to_vectors',
|
|
102
|
+
'lerp',
|
|
103
|
+
'create_grid',
|
|
104
|
+
'create_circle',
|
|
105
|
+
'_COMPILED',
|
|
106
|
+
]
|
e2D/winrec.py
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
from typing import Any, Optional
|
|
3
|
+
import numpy as np
|
|
4
|
+
import cv2
|
|
5
|
+
import threading
|
|
6
|
+
import queue
|
|
7
|
+
import time
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class WinRec:
|
|
11
|
+
"""
|
|
12
|
+
Asynchronous screen recorder for ModernGL-based e2D applications.
|
|
13
|
+
|
|
14
|
+
Usage:
|
|
15
|
+
rootEnv.init_rec(fps=30, draw_on_screen=True, path='output.mp4')
|
|
16
|
+
# Recording happens automatically in the render loop
|
|
17
|
+
# F9: Pause/Resume, F10: Restart, F12: Screenshot
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
rootEnv: Any,
|
|
23
|
+
fps: int = 30,
|
|
24
|
+
draw_on_screen: bool = True,
|
|
25
|
+
path: str = 'output.mp4'
|
|
26
|
+
) -> None:
|
|
27
|
+
self.rootEnv = rootEnv
|
|
28
|
+
self.path = path
|
|
29
|
+
self.fps = fps
|
|
30
|
+
self.draw_on_screen = draw_on_screen
|
|
31
|
+
self.is_recording = True # Recording state (pause/resume)
|
|
32
|
+
self.screenshot_counter = 0 # Counter for screenshot filenames
|
|
33
|
+
self.recording_frames = 0 # Frames actually recorded (excludes paused frames)
|
|
34
|
+
self.pause_start_time: Optional[float] = None # Track when recording was paused
|
|
35
|
+
self.total_pause_duration = 0.0 # Cumulative pause time
|
|
36
|
+
|
|
37
|
+
# Get window size
|
|
38
|
+
width, height = self.rootEnv.window_size
|
|
39
|
+
|
|
40
|
+
# Setup video writer
|
|
41
|
+
self.video_writer = cv2.VideoWriter(
|
|
42
|
+
self.path,
|
|
43
|
+
cv2.VideoWriter_fourcc(*'mp4v'), # type: ignore
|
|
44
|
+
self.fps,
|
|
45
|
+
(width, height)
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# Pre-allocate buffers for zero-copy operations
|
|
49
|
+
self.frame_buffer = np.empty(shape=(height, width, 3), dtype=np.uint8)
|
|
50
|
+
|
|
51
|
+
# Setup async video writing
|
|
52
|
+
self.frame_queue: queue.Queue = queue.Queue(maxsize=120) # Buffer up to 4 seconds at 30fps
|
|
53
|
+
self.running = True
|
|
54
|
+
|
|
55
|
+
# Statistics tracking
|
|
56
|
+
self.frames_written = 0
|
|
57
|
+
self.frames_dropped = 0
|
|
58
|
+
self.write_start_time = time.time()
|
|
59
|
+
self.last_stat_update = time.time()
|
|
60
|
+
self.current_write_fps = 0.0
|
|
61
|
+
|
|
62
|
+
self.write_thread = threading.Thread(target=self._write_worker, daemon=False)
|
|
63
|
+
self.write_thread.start()
|
|
64
|
+
|
|
65
|
+
def _write_worker(self) -> None:
|
|
66
|
+
"""Background thread that writes frames to video file."""
|
|
67
|
+
while self.running or not self.frame_queue.empty():
|
|
68
|
+
try:
|
|
69
|
+
frame = self.frame_queue.get(timeout=0.1)
|
|
70
|
+
self.video_writer.write(frame)
|
|
71
|
+
self.frames_written += 1
|
|
72
|
+
self.frame_queue.task_done()
|
|
73
|
+
|
|
74
|
+
# Update write FPS every second
|
|
75
|
+
current_time = time.time()
|
|
76
|
+
if current_time - self.last_stat_update >= 1.0:
|
|
77
|
+
elapsed = current_time - self.write_start_time
|
|
78
|
+
self.current_write_fps = self.frames_written / elapsed if elapsed > 0 else 0
|
|
79
|
+
self.last_stat_update = current_time
|
|
80
|
+
except queue.Empty:
|
|
81
|
+
continue
|
|
82
|
+
|
|
83
|
+
def handle_input(self) -> None:
|
|
84
|
+
"""Handle recording control keyboard inputs (F9-F12)."""
|
|
85
|
+
import glfw
|
|
86
|
+
from .devices import KeyState
|
|
87
|
+
|
|
88
|
+
# F9: Toggle pause/resume recording
|
|
89
|
+
if self.rootEnv.keyboard.get_key(glfw.KEY_F9, KeyState.JUST_PRESSED):
|
|
90
|
+
self.toggle_recording()
|
|
91
|
+
status = "REC" if self.is_recording else "PAUSED"
|
|
92
|
+
print(f"[Recording] {status}")
|
|
93
|
+
|
|
94
|
+
# F10: Restart recording (reset all and resume)
|
|
95
|
+
if self.rootEnv.keyboard.get_key(glfw.KEY_F10, KeyState.JUST_PRESSED):
|
|
96
|
+
self.restart()
|
|
97
|
+
print("[Recording] Restarted (buffer cleared, timers reset)")
|
|
98
|
+
|
|
99
|
+
# F12: Take screenshot
|
|
100
|
+
if self.rootEnv.keyboard.get_key(glfw.KEY_F12, KeyState.JUST_PRESSED):
|
|
101
|
+
screenshot_path = self.take_screenshot()
|
|
102
|
+
print(f"[Screenshot] Saved: {screenshot_path}")
|
|
103
|
+
|
|
104
|
+
def update(self) -> None:
|
|
105
|
+
"""Capture frame from ModernGL framebuffer and queue for writing."""
|
|
106
|
+
# Handle keyboard input first
|
|
107
|
+
self.handle_input()
|
|
108
|
+
|
|
109
|
+
# Skip frame capture if recording is paused
|
|
110
|
+
if not self.is_recording:
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
# Increment recording frame counter
|
|
114
|
+
self.recording_frames += 1
|
|
115
|
+
|
|
116
|
+
# Read framebuffer from ModernGL context
|
|
117
|
+
# Note: OpenGL reads bottom-to-top, so we need to flip
|
|
118
|
+
width, height = self.rootEnv.window_size
|
|
119
|
+
pixels = self.rootEnv.ctx.screen.read(components=3)
|
|
120
|
+
|
|
121
|
+
# Convert bytes to numpy array and reshape
|
|
122
|
+
frame = np.frombuffer(pixels, dtype=np.uint8).reshape((height, width, 3))
|
|
123
|
+
|
|
124
|
+
# Flip vertically (OpenGL coordinates are bottom-up)
|
|
125
|
+
frame = np.flipud(frame)
|
|
126
|
+
|
|
127
|
+
# Convert RGB to BGR for OpenCV
|
|
128
|
+
cv2.cvtColor(frame, cv2.COLOR_RGB2BGR, dst=self.frame_buffer)
|
|
129
|
+
|
|
130
|
+
# Queue frame copy for async writing (non-blocking)
|
|
131
|
+
try:
|
|
132
|
+
self.frame_queue.put_nowait(self.frame_buffer.copy())
|
|
133
|
+
except queue.Full:
|
|
134
|
+
self.frames_dropped += 1 # Track dropped frames
|
|
135
|
+
|
|
136
|
+
def get_rec_seconds(self) -> float:
|
|
137
|
+
"""Get recorded time in seconds (excludes paused time)."""
|
|
138
|
+
return self.recording_frames / self.fps if self.fps > 0 else 0.0
|
|
139
|
+
|
|
140
|
+
def draw(self) -> None:
|
|
141
|
+
"""Draw recording statistics overlay on screen."""
|
|
142
|
+
if not self.draw_on_screen:
|
|
143
|
+
return
|
|
144
|
+
|
|
145
|
+
# Calculate statistics
|
|
146
|
+
buffer_size = self.frame_queue.qsize()
|
|
147
|
+
buffer_percent = (buffer_size / self.frame_queue.maxsize) * 100
|
|
148
|
+
buffer_seconds = buffer_size / self.fps if self.fps > 0 else 0
|
|
149
|
+
|
|
150
|
+
# Estimate optimal buffer size based on write performance
|
|
151
|
+
if self.current_write_fps > 0 and self.fps > 0:
|
|
152
|
+
write_lag = self.fps / self.current_write_fps
|
|
153
|
+
estimated_buffer = int(self.fps * 2 * write_lag) # 2 seconds of lag compensation
|
|
154
|
+
else:
|
|
155
|
+
estimated_buffer = self.frame_queue.maxsize
|
|
156
|
+
|
|
157
|
+
# Recording state indicator
|
|
158
|
+
rec_status = "REC" if self.is_recording else "PAUSED"
|
|
159
|
+
|
|
160
|
+
# Format with fixed width for stable display
|
|
161
|
+
from .text_renderer import TextStyle, Pivots
|
|
162
|
+
from .color_defs import WHITE, BLACK
|
|
163
|
+
|
|
164
|
+
row1 = (f"[{rec_status}] RecFrames:{self.recording_frames:>6} | "
|
|
165
|
+
f"RecTime:{self.get_rec_seconds():>6.2f}s | "
|
|
166
|
+
f"AppTime:{self.rootEnv.runtime:>6.2f}s")
|
|
167
|
+
row2 = (f"Buffer:{buffer_size:>3}/{self.frame_queue.maxsize:<3} "
|
|
168
|
+
f"({buffer_percent:>5.1f}%, {buffer_seconds:>4.1f}s) | "
|
|
169
|
+
f"WriteFPS:{self.current_write_fps:>5.1f}")
|
|
170
|
+
row3 = (f"Written:{self.frames_written:>6} | "
|
|
171
|
+
f"Dropped:{self.frames_dropped:>4} | "
|
|
172
|
+
f"OptBuf:{estimated_buffer:>3}")
|
|
173
|
+
row4 = "[F9]Pause/Resume [F10]Restart [F12]Screenshot"
|
|
174
|
+
|
|
175
|
+
# Position in bottom-right corner with spacing
|
|
176
|
+
win_width, win_height = self.rootEnv.window_size_f
|
|
177
|
+
x_pos = win_width - 16
|
|
178
|
+
y_base = win_height - 16
|
|
179
|
+
line_height = 30 # Approximate line height
|
|
180
|
+
|
|
181
|
+
style = TextStyle(color=WHITE, bg_color=(0.0, 0.0, 0.0, 0.9))
|
|
182
|
+
|
|
183
|
+
# Draw from bottom to top
|
|
184
|
+
self.rootEnv.print(row4, (x_pos, y_base), style=style, pivot=Pivots.BOTTOM_RIGHT)
|
|
185
|
+
self.rootEnv.print(row3, (x_pos, y_base - line_height), style=style, pivot=Pivots.BOTTOM_RIGHT)
|
|
186
|
+
self.rootEnv.print(row2, (x_pos, y_base - line_height * 2), style=style, pivot=Pivots.BOTTOM_RIGHT)
|
|
187
|
+
self.rootEnv.print(row1, (x_pos, y_base - line_height * 3), style=style, pivot=Pivots.BOTTOM_RIGHT)
|
|
188
|
+
|
|
189
|
+
def pause(self) -> None:
|
|
190
|
+
"""Pause recording (stop capturing frames)."""
|
|
191
|
+
if self.is_recording:
|
|
192
|
+
self.is_recording = False
|
|
193
|
+
self.pause_start_time = time.time()
|
|
194
|
+
|
|
195
|
+
def resume(self) -> None:
|
|
196
|
+
"""Resume recording (continue capturing frames)."""
|
|
197
|
+
if not self.is_recording and self.pause_start_time is not None:
|
|
198
|
+
self.total_pause_duration += time.time() - self.pause_start_time
|
|
199
|
+
self.pause_start_time = None
|
|
200
|
+
self.is_recording = True
|
|
201
|
+
|
|
202
|
+
def toggle_recording(self) -> None:
|
|
203
|
+
"""Toggle between pause and resume."""
|
|
204
|
+
if self.is_recording:
|
|
205
|
+
self.pause()
|
|
206
|
+
else:
|
|
207
|
+
self.resume()
|
|
208
|
+
|
|
209
|
+
def restart(self) -> None:
|
|
210
|
+
"""Restart recording: clear buffer, reset all counters and timers, resume recording."""
|
|
211
|
+
self.clear_buffer()
|
|
212
|
+
self.recording_frames = 0
|
|
213
|
+
self.total_pause_duration = 0.0
|
|
214
|
+
self.pause_start_time = None
|
|
215
|
+
self.is_recording = True
|
|
216
|
+
|
|
217
|
+
def clear_buffer(self) -> None:
|
|
218
|
+
"""Clear the frame queue and reset write statistics."""
|
|
219
|
+
# Clear the queue
|
|
220
|
+
while not self.frame_queue.empty():
|
|
221
|
+
try:
|
|
222
|
+
self.frame_queue.get_nowait()
|
|
223
|
+
self.frame_queue.task_done()
|
|
224
|
+
except queue.Empty:
|
|
225
|
+
break
|
|
226
|
+
|
|
227
|
+
# Reset write counters (but keep recording frames)
|
|
228
|
+
self.frames_written = 0
|
|
229
|
+
self.frames_dropped = 0
|
|
230
|
+
self.write_start_time = time.time()
|
|
231
|
+
self.last_stat_update = time.time()
|
|
232
|
+
self.current_write_fps = 0.0
|
|
233
|
+
|
|
234
|
+
def take_screenshot(self, filename: Optional[str] = None) -> str:
|
|
235
|
+
"""
|
|
236
|
+
Save current screen as PNG screenshot.
|
|
237
|
+
|
|
238
|
+
Args:
|
|
239
|
+
filename: Optional custom filename. If None, auto-generates with counter.
|
|
240
|
+
|
|
241
|
+
Returns:
|
|
242
|
+
str: Path to saved screenshot
|
|
243
|
+
"""
|
|
244
|
+
if filename is None:
|
|
245
|
+
# Auto-generate filename with counter
|
|
246
|
+
base_path = self.path.rsplit('.', 1)[0] # Remove .mp4 extension
|
|
247
|
+
filename = f"{base_path}_screenshot_{self.screenshot_counter:04d}.png"
|
|
248
|
+
self.screenshot_counter += 1
|
|
249
|
+
|
|
250
|
+
# Capture current screen from ModernGL
|
|
251
|
+
width, height = self.rootEnv.window_size
|
|
252
|
+
pixels = self.rootEnv.ctx.screen.read(components=3)
|
|
253
|
+
|
|
254
|
+
# Convert bytes to numpy array and reshape
|
|
255
|
+
frame = np.frombuffer(pixels, dtype=np.uint8).reshape((height, width, 3))
|
|
256
|
+
|
|
257
|
+
# Flip vertically (OpenGL coordinates are bottom-up)
|
|
258
|
+
frame = np.flipud(frame)
|
|
259
|
+
|
|
260
|
+
# Convert RGB to BGR for OpenCV
|
|
261
|
+
screenshot_buffer = np.empty(shape=(height, width, 3), dtype=np.uint8)
|
|
262
|
+
cv2.cvtColor(frame, cv2.COLOR_RGB2BGR, dst=screenshot_buffer)
|
|
263
|
+
|
|
264
|
+
# Save as PNG
|
|
265
|
+
cv2.imwrite(filename, screenshot_buffer)
|
|
266
|
+
return filename
|
|
267
|
+
|
|
268
|
+
def quit(self) -> None:
|
|
269
|
+
"""Stop recording and release resources."""
|
|
270
|
+
# Stop accepting new frames and wait for queue to flush
|
|
271
|
+
self.running = False
|
|
272
|
+
self.frame_queue.join() # Wait for all queued frames to be written
|
|
273
|
+
self.write_thread.join(timeout=5.0) # Wait for thread to finish
|
|
274
|
+
self.video_writer.release()
|
|
275
|
+
print(f"[Recording] Saved {self.frames_written} frames to {self.path}")
|