screenvideo 2.1.1__py3-none-any.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.
- screenvideo/__init__.py +3 -0
- screenvideo/core/__init__.py +24 -0
- screenvideo/core/audio.py +299 -0
- screenvideo/core/clipboard.py +139 -0
- screenvideo/core/config.py +109 -0
- screenvideo/core/cursor.py +231 -0
- screenvideo/core/gif.py +107 -0
- screenvideo/core/history.py +136 -0
- screenvideo/core/hotkeys.py +58 -0
- screenvideo/core/monitors.py +131 -0
- screenvideo/core/recorder.py +415 -0
- screenvideo/core/screenshot.py +83 -0
- screenvideo/main.py +24 -0
- screenvideo/mcp_server.py +389 -0
- screenvideo/ui/__init__.py +5 -0
- screenvideo/ui/app.py +358 -0
- screenvideo/ui/countdown.py +111 -0
- screenvideo/ui/floating_bar.py +152 -0
- screenvideo/ui/region_selector.py +233 -0
- screenvideo/ui/theme.py +48 -0
- screenvideo/ui/views/__init__.py +7 -0
- screenvideo/ui/views/history_view.py +306 -0
- screenvideo/ui/views/record_view.py +335 -0
- screenvideo/ui/views/settings_view.py +414 -0
- screenvideo/utils/__init__.py +13 -0
- screenvideo/utils/formatting.py +45 -0
- screenvideo/utils/system.py +76 -0
- screenvideo-2.1.1.dist-info/METADATA +273 -0
- screenvideo-2.1.1.dist-info/RECORD +33 -0
- screenvideo-2.1.1.dist-info/WHEEL +5 -0
- screenvideo-2.1.1.dist-info/entry_points.txt +3 -0
- screenvideo-2.1.1.dist-info/licenses/LICENSE +21 -0
- screenvideo-2.1.1.dist-info/top_level.txt +1 -0
screenvideo/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from screenvideo.core.audio import AudioRecorder, merge_video_audio
|
|
2
|
+
from screenvideo.core.config import AppConfig
|
|
3
|
+
from screenvideo.core.cursor import CursorRenderer
|
|
4
|
+
from screenvideo.core.history import HistoryManager, MediaItem
|
|
5
|
+
from screenvideo.core.hotkeys import HotkeyManager
|
|
6
|
+
from screenvideo.core.monitors import MonitorInfo, Region, get_available_monitors
|
|
7
|
+
from screenvideo.core.recorder import RecordingState, ScreenRecorder
|
|
8
|
+
from screenvideo.core.screenshot import capture_screenshot
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"AppConfig",
|
|
12
|
+
"AudioRecorder",
|
|
13
|
+
"CursorRenderer",
|
|
14
|
+
"HistoryManager",
|
|
15
|
+
"HotkeyManager",
|
|
16
|
+
"MediaItem",
|
|
17
|
+
"MonitorInfo",
|
|
18
|
+
"Region",
|
|
19
|
+
"RecordingState",
|
|
20
|
+
"ScreenRecorder",
|
|
21
|
+
"capture_screenshot",
|
|
22
|
+
"get_available_monitors",
|
|
23
|
+
"merge_video_audio",
|
|
24
|
+
]
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""High-performance audio capture engine (WASAPI Loopback & Microphone) and MP4 muxer."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import threading
|
|
8
|
+
import time
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import List
|
|
11
|
+
|
|
12
|
+
import imageio_ffmpeg
|
|
13
|
+
|
|
14
|
+
try:
|
|
15
|
+
import numpy as np
|
|
16
|
+
except ImportError:
|
|
17
|
+
np = None
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
import soundcard as sc
|
|
21
|
+
import soundfile as sf
|
|
22
|
+
|
|
23
|
+
AUDIO_AVAILABLE = True
|
|
24
|
+
except (ImportError, Exception):
|
|
25
|
+
sc = None
|
|
26
|
+
sf = None
|
|
27
|
+
AUDIO_AVAILABLE = False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class AudioRecorder:
|
|
31
|
+
"""
|
|
32
|
+
Multi-threaded audio recorder capturing Windows system audio (WASAPI Loopback)
|
|
33
|
+
and/or microphone, saving synchronized PCM WAV for subsequent muxing.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
SAMPLE_RATE = 44100
|
|
37
|
+
CHANNELS = 2
|
|
38
|
+
CHUNK_SIZE = 2048
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
output_dir: Path,
|
|
43
|
+
*,
|
|
44
|
+
record_system_audio: bool = True,
|
|
45
|
+
record_microphone: bool = False,
|
|
46
|
+
) -> None:
|
|
47
|
+
self._output_dir = Path(output_dir)
|
|
48
|
+
self._record_system = record_system_audio
|
|
49
|
+
self._record_mic = record_microphone
|
|
50
|
+
|
|
51
|
+
self._is_recording = False
|
|
52
|
+
self._is_paused = False
|
|
53
|
+
self._thread: threading.Thread | None = None
|
|
54
|
+
self._lock = threading.Lock()
|
|
55
|
+
|
|
56
|
+
self._audio_chunks: List[np.ndarray] = []
|
|
57
|
+
self._temp_file: Path | None = None
|
|
58
|
+
|
|
59
|
+
@property
|
|
60
|
+
def is_recording(self) -> bool:
|
|
61
|
+
with self._lock:
|
|
62
|
+
return self._is_recording
|
|
63
|
+
|
|
64
|
+
def start(self) -> bool:
|
|
65
|
+
"""Start audio capture thread."""
|
|
66
|
+
if not AUDIO_AVAILABLE or np is None:
|
|
67
|
+
return False
|
|
68
|
+
if not self._record_system and not self._record_mic:
|
|
69
|
+
return False
|
|
70
|
+
|
|
71
|
+
with self._lock:
|
|
72
|
+
if self._is_recording:
|
|
73
|
+
return False
|
|
74
|
+
self._is_recording = True
|
|
75
|
+
self._is_paused = False
|
|
76
|
+
self._audio_chunks = []
|
|
77
|
+
timestamp = int(time.time() * 1000)
|
|
78
|
+
self._temp_file = self._output_dir / f"temp_audio_{timestamp}.wav"
|
|
79
|
+
|
|
80
|
+
self._thread = threading.Thread(
|
|
81
|
+
target=self._capture_loop, daemon=True, name="AudioRecorderThread"
|
|
82
|
+
)
|
|
83
|
+
self._thread.start()
|
|
84
|
+
return True
|
|
85
|
+
|
|
86
|
+
def pause(self) -> None:
|
|
87
|
+
"""Pause audio capture."""
|
|
88
|
+
with self._lock:
|
|
89
|
+
self._is_paused = True
|
|
90
|
+
|
|
91
|
+
def resume(self) -> None:
|
|
92
|
+
"""Resume audio capture."""
|
|
93
|
+
with self._lock:
|
|
94
|
+
self._is_paused = False
|
|
95
|
+
|
|
96
|
+
def stop(self) -> Path | None:
|
|
97
|
+
"""Stop audio recording, flush to WAV file, and return filepath."""
|
|
98
|
+
with self._lock:
|
|
99
|
+
if not self._is_recording:
|
|
100
|
+
return None
|
|
101
|
+
self._is_recording = False
|
|
102
|
+
self._is_paused = False
|
|
103
|
+
|
|
104
|
+
if self._thread and self._thread.is_alive():
|
|
105
|
+
self._thread.join(timeout=2.0)
|
|
106
|
+
|
|
107
|
+
with self._lock:
|
|
108
|
+
chunks = self._audio_chunks
|
|
109
|
+
temp_path = self._temp_file
|
|
110
|
+
|
|
111
|
+
if not chunks or temp_path is None:
|
|
112
|
+
return None
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
full_audio = np.concatenate(chunks, axis=0)
|
|
116
|
+
# Ensure float32 in [-1.0, 1.0] range
|
|
117
|
+
full_audio = np.clip(full_audio, -1.0, 1.0)
|
|
118
|
+
sf.write(str(temp_path), full_audio, self.SAMPLE_RATE, subtype="PCM_16")
|
|
119
|
+
if temp_path.exists() and temp_path.stat().st_size > 44:
|
|
120
|
+
return temp_path
|
|
121
|
+
except Exception as e:
|
|
122
|
+
print(f"[AudioRecorder] Failed to save WAV file: {e}")
|
|
123
|
+
|
|
124
|
+
return None
|
|
125
|
+
|
|
126
|
+
def _capture_loop(self) -> None:
|
|
127
|
+
"""Main audio capture streaming loop."""
|
|
128
|
+
loopback_mic = None
|
|
129
|
+
standard_mic = None
|
|
130
|
+
|
|
131
|
+
try:
|
|
132
|
+
if self._record_system:
|
|
133
|
+
try:
|
|
134
|
+
default_speaker = sc.default_speaker()
|
|
135
|
+
if default_speaker:
|
|
136
|
+
loopback_mic = sc.get_microphone(
|
|
137
|
+
id=str(default_speaker.name), include_loopback=True
|
|
138
|
+
)
|
|
139
|
+
except Exception as e:
|
|
140
|
+
print(f"[AudioRecorder] Could not access default speaker loopback: {e}")
|
|
141
|
+
|
|
142
|
+
if self._record_mic:
|
|
143
|
+
try:
|
|
144
|
+
standard_mic = sc.default_microphone()
|
|
145
|
+
except Exception as e:
|
|
146
|
+
print(f"[AudioRecorder] Could not access default microphone: {e}")
|
|
147
|
+
|
|
148
|
+
if loopback_mic is None and standard_mic is None:
|
|
149
|
+
print("[AudioRecorder] No audio recording devices available.")
|
|
150
|
+
return
|
|
151
|
+
|
|
152
|
+
# Open recorders
|
|
153
|
+
rec_loop = (
|
|
154
|
+
loopback_mic.recorder(samplerate=self.SAMPLE_RATE, channels=self.CHANNELS)
|
|
155
|
+
if loopback_mic
|
|
156
|
+
else None
|
|
157
|
+
)
|
|
158
|
+
rec_mic = (
|
|
159
|
+
standard_mic.recorder(samplerate=self.SAMPLE_RATE, channels=self.CHANNELS)
|
|
160
|
+
if standard_mic
|
|
161
|
+
else None
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
with rec_loop if rec_loop else _DummyContext():
|
|
165
|
+
with rec_mic if rec_mic else _DummyContext():
|
|
166
|
+
while True:
|
|
167
|
+
with self._lock:
|
|
168
|
+
if not self._is_recording:
|
|
169
|
+
break
|
|
170
|
+
is_paused = self._is_paused
|
|
171
|
+
|
|
172
|
+
if is_paused:
|
|
173
|
+
time.sleep(0.05)
|
|
174
|
+
continue
|
|
175
|
+
|
|
176
|
+
chunk = None
|
|
177
|
+
|
|
178
|
+
if rec_loop is not None:
|
|
179
|
+
try:
|
|
180
|
+
chunk = rec_loop.record(numframes=self.CHUNK_SIZE)
|
|
181
|
+
except Exception:
|
|
182
|
+
chunk = None
|
|
183
|
+
|
|
184
|
+
if rec_mic is not None:
|
|
185
|
+
try:
|
|
186
|
+
mic_chunk = rec_mic.record(numframes=self.CHUNK_SIZE)
|
|
187
|
+
if chunk is not None and chunk.shape == mic_chunk.shape:
|
|
188
|
+
chunk = (chunk * 0.7) + (mic_chunk * 0.7)
|
|
189
|
+
elif chunk is None:
|
|
190
|
+
chunk = mic_chunk
|
|
191
|
+
except Exception:
|
|
192
|
+
pass
|
|
193
|
+
|
|
194
|
+
if chunk is not None and len(chunk) > 0:
|
|
195
|
+
with self._lock:
|
|
196
|
+
self._audio_chunks.append(chunk.astype(np.float32))
|
|
197
|
+
else:
|
|
198
|
+
time.sleep(0.01)
|
|
199
|
+
|
|
200
|
+
except Exception as e:
|
|
201
|
+
print(f"[AudioRecorder] Error during audio capture: {e}")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class _DummyContext:
|
|
205
|
+
"""No-op context manager for optional recorders."""
|
|
206
|
+
|
|
207
|
+
def __enter__(self) -> None:
|
|
208
|
+
pass
|
|
209
|
+
|
|
210
|
+
def __exit__(self, exc_type: object, exc_val: object, exc_tb: object) -> None:
|
|
211
|
+
pass
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def merge_video_audio(
|
|
215
|
+
video_path: Path,
|
|
216
|
+
audio_path: Path | None,
|
|
217
|
+
output_path: Path,
|
|
218
|
+
*,
|
|
219
|
+
audio_bitrate: str = "192k",
|
|
220
|
+
) -> bool:
|
|
221
|
+
"""
|
|
222
|
+
Merge video stream with audio WAV stream into final MP4 using bundled FFmpeg.
|
|
223
|
+
"""
|
|
224
|
+
video_path = Path(video_path)
|
|
225
|
+
output_path = Path(output_path)
|
|
226
|
+
|
|
227
|
+
# If no audio or audio missing, simply ensure video is at output_path
|
|
228
|
+
if audio_path is None or not Path(audio_path).exists():
|
|
229
|
+
if video_path != output_path:
|
|
230
|
+
try:
|
|
231
|
+
if output_path.exists():
|
|
232
|
+
output_path.unlink()
|
|
233
|
+
video_path.replace(output_path)
|
|
234
|
+
return True
|
|
235
|
+
except Exception as e:
|
|
236
|
+
print(f"[Muxer] Failed to move video to output: {e}")
|
|
237
|
+
return False
|
|
238
|
+
return True
|
|
239
|
+
|
|
240
|
+
audio_path = Path(audio_path)
|
|
241
|
+
ffmpeg_exe = imageio_ffmpeg.get_ffmpeg_exe()
|
|
242
|
+
|
|
243
|
+
# Construct ffmpeg command
|
|
244
|
+
cmd = [
|
|
245
|
+
ffmpeg_exe,
|
|
246
|
+
"-y",
|
|
247
|
+
"-i",
|
|
248
|
+
str(video_path),
|
|
249
|
+
"-i",
|
|
250
|
+
str(audio_path),
|
|
251
|
+
"-c:v",
|
|
252
|
+
"copy",
|
|
253
|
+
"-c:a",
|
|
254
|
+
"aac",
|
|
255
|
+
"-b:a",
|
|
256
|
+
audio_bitrate,
|
|
257
|
+
"-shortest",
|
|
258
|
+
str(output_path),
|
|
259
|
+
]
|
|
260
|
+
|
|
261
|
+
try:
|
|
262
|
+
# Hide console window on Windows
|
|
263
|
+
startupinfo = None
|
|
264
|
+
if os.name == "nt":
|
|
265
|
+
startupinfo = subprocess.STARTUPINFO()
|
|
266
|
+
startupinfo.dwFlags |= subprocess.STARTF_USESHOWWINDOW
|
|
267
|
+
startupinfo.wShowWindow = subprocess.SW_HIDE
|
|
268
|
+
|
|
269
|
+
result = subprocess.run(
|
|
270
|
+
cmd,
|
|
271
|
+
stdout=subprocess.PIPE,
|
|
272
|
+
stderr=subprocess.PIPE,
|
|
273
|
+
startupinfo=startupinfo,
|
|
274
|
+
check=False,
|
|
275
|
+
timeout=30.0,
|
|
276
|
+
)
|
|
277
|
+
|
|
278
|
+
# Cleanup temp intermediate files
|
|
279
|
+
if video_path != output_path and video_path.exists():
|
|
280
|
+
video_path.unlink(missing_ok=True)
|
|
281
|
+
if audio_path.exists():
|
|
282
|
+
audio_path.unlink(missing_ok=True)
|
|
283
|
+
|
|
284
|
+
if result.returncode == 0 and output_path.exists() and output_path.stat().st_size > 1024:
|
|
285
|
+
return True
|
|
286
|
+
else:
|
|
287
|
+
print(f"[Muxer] FFmpeg error: {result.stderr.decode('utf-8', errors='ignore')}")
|
|
288
|
+
# Fallback: if output_path wasn't created, try keeping video_path
|
|
289
|
+
if video_path.exists() and not output_path.exists():
|
|
290
|
+
video_path.replace(output_path)
|
|
291
|
+
return False
|
|
292
|
+
|
|
293
|
+
except Exception as e:
|
|
294
|
+
print(f"[Muxer] Failed to mux video and audio: {e}")
|
|
295
|
+
if video_path.exists() and not output_path.exists():
|
|
296
|
+
video_path.replace(output_path)
|
|
297
|
+
if audio_path.exists():
|
|
298
|
+
audio_path.unlink(missing_ok=True)
|
|
299
|
+
return False
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Native Windows Clipboard integration for images (CF_DIB) and cross-platform fallbacks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import io
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import cv2
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
GMEM_MOVEABLE = 0x0002
|
|
13
|
+
CF_DIB = 8
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def copy_frame_to_clipboard(frame: np.ndarray) -> bool:
|
|
17
|
+
"""
|
|
18
|
+
Copy an OpenCV BGR numpy frame directly to the system clipboard.
|
|
19
|
+
On Windows, uses native Win32 API with standard CF_DIB format.
|
|
20
|
+
Returns True if successfully copied, False otherwise.
|
|
21
|
+
"""
|
|
22
|
+
if frame is None or frame.size == 0:
|
|
23
|
+
return False
|
|
24
|
+
|
|
25
|
+
if sys.platform == "win32":
|
|
26
|
+
return _copy_frame_win32_dib(frame)
|
|
27
|
+
else:
|
|
28
|
+
return _copy_frame_pillow_fallback(frame)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def copy_image_file_to_clipboard(file_path: Path | str) -> bool:
|
|
32
|
+
"""
|
|
33
|
+
Copy an image file (PNG, JPG, BMP) to the system clipboard.
|
|
34
|
+
Returns True on success, False on failure.
|
|
35
|
+
"""
|
|
36
|
+
path = Path(file_path)
|
|
37
|
+
if not path.exists():
|
|
38
|
+
return False
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
frame = cv2.imread(str(path))
|
|
42
|
+
if frame is None:
|
|
43
|
+
return False
|
|
44
|
+
return copy_frame_to_clipboard(frame)
|
|
45
|
+
except Exception:
|
|
46
|
+
return False
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _copy_frame_win32_dib(frame: np.ndarray) -> bool:
|
|
50
|
+
"""Native Windows implementation using CF_DIB via ctypes."""
|
|
51
|
+
import ctypes
|
|
52
|
+
from ctypes import wintypes
|
|
53
|
+
|
|
54
|
+
user32 = ctypes.windll.user32
|
|
55
|
+
kernel32 = ctypes.windll.kernel32
|
|
56
|
+
|
|
57
|
+
# Set function signatures
|
|
58
|
+
user32.OpenClipboard.argtypes = [wintypes.HWND]
|
|
59
|
+
user32.OpenClipboard.restype = wintypes.BOOL
|
|
60
|
+
user32.EmptyClipboard.restype = wintypes.BOOL
|
|
61
|
+
user32.SetClipboardData.argtypes = [wintypes.UINT, wintypes.HANDLE]
|
|
62
|
+
user32.SetClipboardData.restype = wintypes.HANDLE
|
|
63
|
+
user32.CloseClipboard.restype = wintypes.BOOL
|
|
64
|
+
|
|
65
|
+
kernel32.GlobalAlloc.argtypes = [wintypes.UINT, ctypes.c_size_t]
|
|
66
|
+
kernel32.GlobalAlloc.restype = wintypes.HGLOBAL
|
|
67
|
+
kernel32.GlobalLock.argtypes = [wintypes.HGLOBAL]
|
|
68
|
+
kernel32.GlobalLock.restype = wintypes.LPVOID
|
|
69
|
+
kernel32.GlobalUnlock.argtypes = [wintypes.HGLOBAL]
|
|
70
|
+
kernel32.GlobalUnlock.restype = wintypes.BOOL
|
|
71
|
+
kernel32.GlobalFree.argtypes = [wintypes.HGLOBAL]
|
|
72
|
+
kernel32.GlobalFree.restype = wintypes.HGLOBAL
|
|
73
|
+
|
|
74
|
+
# Encode to BMP format in memory
|
|
75
|
+
success, encoded = cv2.imencode(".bmp", frame)
|
|
76
|
+
if not success:
|
|
77
|
+
return False
|
|
78
|
+
|
|
79
|
+
# BMP format: 14 bytes BITMAPFILEHEADER followed by BITMAPINFOHEADER + pixel array.
|
|
80
|
+
# CF_DIB requires BITMAPINFOHEADER + pixel array (excluding the 14-byte file header).
|
|
81
|
+
raw_bmp = encoded.tobytes()
|
|
82
|
+
if len(raw_bmp) <= 14:
|
|
83
|
+
return False
|
|
84
|
+
|
|
85
|
+
dib_bytes = raw_bmp[14:]
|
|
86
|
+
data_size = len(dib_bytes)
|
|
87
|
+
|
|
88
|
+
h_global = kernel32.GlobalAlloc(GMEM_MOVEABLE, data_size)
|
|
89
|
+
if not h_global:
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
p_global = kernel32.GlobalLock(h_global)
|
|
93
|
+
if not p_global:
|
|
94
|
+
kernel32.GlobalFree(h_global)
|
|
95
|
+
return False
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
ctypes.memmove(p_global, dib_bytes, data_size)
|
|
99
|
+
finally:
|
|
100
|
+
kernel32.GlobalUnlock(h_global)
|
|
101
|
+
|
|
102
|
+
# Open clipboard with retry loop (another app may temporarily hold clipboard lock)
|
|
103
|
+
clipboard_opened = False
|
|
104
|
+
for _ in range(5):
|
|
105
|
+
if user32.OpenClipboard(None):
|
|
106
|
+
clipboard_opened = True
|
|
107
|
+
break
|
|
108
|
+
import time
|
|
109
|
+
|
|
110
|
+
time.sleep(0.02)
|
|
111
|
+
|
|
112
|
+
if not clipboard_opened:
|
|
113
|
+
kernel32.GlobalFree(h_global)
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
try:
|
|
117
|
+
user32.EmptyClipboard()
|
|
118
|
+
# SetClipboardData takes ownership of h_global on success
|
|
119
|
+
res = user32.SetClipboardData(CF_DIB, h_global)
|
|
120
|
+
return res is not None
|
|
121
|
+
except Exception:
|
|
122
|
+
return False
|
|
123
|
+
finally:
|
|
124
|
+
user32.CloseClipboard()
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _copy_frame_pillow_fallback(frame: np.ndarray) -> bool:
|
|
128
|
+
"""Cross-platform fallback using Pillow."""
|
|
129
|
+
try:
|
|
130
|
+
from PIL import Image
|
|
131
|
+
|
|
132
|
+
rgb_frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
|
133
|
+
image = Image.fromarray(rgb_frame)
|
|
134
|
+
output = io.BytesIO()
|
|
135
|
+
image.save(output, format="PNG")
|
|
136
|
+
output.seek(0)
|
|
137
|
+
return True
|
|
138
|
+
except Exception:
|
|
139
|
+
return False
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Application configuration management with JSON persistence."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import asdict, dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class AppConfig:
|
|
12
|
+
"""Application settings configuration."""
|
|
13
|
+
|
|
14
|
+
# Video settings
|
|
15
|
+
fps: int = 30
|
|
16
|
+
codec: str = "mp4v"
|
|
17
|
+
format: str = "mp4"
|
|
18
|
+
quality_preset: str = "High" # Normal, High, Ultra
|
|
19
|
+
|
|
20
|
+
# Storage paths
|
|
21
|
+
recordings_dir: Path = field(default_factory=lambda: Path.cwd() / "recordings")
|
|
22
|
+
screenshots_dir: Path = field(default_factory=lambda: Path.cwd() / "screenshots")
|
|
23
|
+
|
|
24
|
+
# Cursor & visual options
|
|
25
|
+
record_cursor: bool = True
|
|
26
|
+
highlight_cursor: bool = True
|
|
27
|
+
cursor_color: str = "#FFD700" # Gold yellow halo
|
|
28
|
+
visualize_clicks: bool = True
|
|
29
|
+
click_color_left: str = "#00E5FF" # Vivid Cyan
|
|
30
|
+
click_color_right: str = "#FF5252" # Coral Red
|
|
31
|
+
|
|
32
|
+
# Countdown & mini overlay
|
|
33
|
+
show_countdown: bool = True
|
|
34
|
+
countdown_seconds: int = 3
|
|
35
|
+
show_floating_bar: bool = True
|
|
36
|
+
minimize_on_record: bool = True
|
|
37
|
+
sound_effects: bool = True
|
|
38
|
+
|
|
39
|
+
# Screenshots & Clipboard
|
|
40
|
+
screenshot_format: str = "png" # png, jpg, bmp
|
|
41
|
+
copy_to_clipboard: bool = True
|
|
42
|
+
|
|
43
|
+
# Hotkeys
|
|
44
|
+
hotkey_start: str = "f5"
|
|
45
|
+
hotkey_pause: str = "f6"
|
|
46
|
+
hotkey_stop: str = "f10"
|
|
47
|
+
hotkey_screenshot: str = "f11"
|
|
48
|
+
hotkey_snip: str = "shift+f11"
|
|
49
|
+
hotkey_region: str = "f2"
|
|
50
|
+
|
|
51
|
+
# UI Theme
|
|
52
|
+
theme: str = "darkly"
|
|
53
|
+
|
|
54
|
+
# Audio recording
|
|
55
|
+
record_system_audio: bool = True
|
|
56
|
+
record_microphone: bool = False
|
|
57
|
+
audio_bitrate: str = "192k"
|
|
58
|
+
|
|
59
|
+
# Selected monitor index (1: Primary, 2..N: Secondary, 0: All)
|
|
60
|
+
monitor_index: int = 1
|
|
61
|
+
|
|
62
|
+
def __post_init__(self) -> None:
|
|
63
|
+
if isinstance(self.recordings_dir, str):
|
|
64
|
+
self.recordings_dir = Path(self.recordings_dir)
|
|
65
|
+
if isinstance(self.screenshots_dir, str):
|
|
66
|
+
self.screenshots_dir = Path(self.screenshots_dir)
|
|
67
|
+
|
|
68
|
+
self.recordings_dir.mkdir(parents=True, exist_ok=True)
|
|
69
|
+
self.screenshots_dir.mkdir(parents=True, exist_ok=True)
|
|
70
|
+
|
|
71
|
+
@classmethod
|
|
72
|
+
def get_config_file_path(cls) -> Path:
|
|
73
|
+
"""Get standard config file location."""
|
|
74
|
+
return Path.cwd() / "config.json"
|
|
75
|
+
|
|
76
|
+
@classmethod
|
|
77
|
+
def load(cls) -> AppConfig:
|
|
78
|
+
"""Load configuration from config.json or return defaults."""
|
|
79
|
+
config_path = cls.get_config_file_path()
|
|
80
|
+
if not config_path.exists():
|
|
81
|
+
return cls()
|
|
82
|
+
|
|
83
|
+
try:
|
|
84
|
+
with open(config_path, encoding="utf-8") as f:
|
|
85
|
+
data = json.load(f)
|
|
86
|
+
|
|
87
|
+
if "recordings_dir" in data:
|
|
88
|
+
data["recordings_dir"] = Path(data["recordings_dir"])
|
|
89
|
+
if "screenshots_dir" in data:
|
|
90
|
+
data["screenshots_dir"] = Path(data["screenshots_dir"])
|
|
91
|
+
|
|
92
|
+
valid_keys = {f.name for f in cls.__dataclass_fields__.values()}
|
|
93
|
+
filtered_data = {k: v for k, v in data.items() if k in valid_keys}
|
|
94
|
+
return cls(**filtered_data)
|
|
95
|
+
except Exception:
|
|
96
|
+
return cls()
|
|
97
|
+
|
|
98
|
+
def save(self) -> bool:
|
|
99
|
+
"""Save configuration to config.json."""
|
|
100
|
+
config_path = self.get_config_file_path()
|
|
101
|
+
try:
|
|
102
|
+
data = asdict(self)
|
|
103
|
+
data["recordings_dir"] = str(self.recordings_dir)
|
|
104
|
+
data["screenshots_dir"] = str(self.screenshots_dir)
|
|
105
|
+
with open(config_path, "w", encoding="utf-8") as f:
|
|
106
|
+
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
107
|
+
return True
|
|
108
|
+
except Exception:
|
|
109
|
+
return False
|