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.
@@ -0,0 +1,3 @@
1
+ """ScreenCapture Pro application package."""
2
+
3
+ __version__ = "2.1.1"
@@ -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