pygpt-net 2.6.32__py3-none-any.whl → 2.6.33__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.
pygpt_net/CHANGELOG.txt CHANGED
@@ -1,3 +1,8 @@
1
+ 2.6.33 (2025-09-02)
2
+
3
+ - Added a "crop" option, auto-resize canvas, and layer support to Painter.
4
+ - Improved video capture from the camera.
5
+
1
6
  2.6.32 (2025-09-02)
2
7
 
3
8
  - Added video generation and support for Google Veo 3 models.
pygpt_net/__init__.py CHANGED
@@ -13,7 +13,7 @@ __author__ = "Marcin Szczygliński"
13
13
  __copyright__ = "Copyright 2025, Marcin Szczygliński"
14
14
  __credits__ = ["Marcin Szczygliński"]
15
15
  __license__ = "MIT"
16
- __version__ = "2.6.32"
16
+ __version__ = "2.6.33"
17
17
  __build__ = "2025-09-02"
18
18
  __maintainer__ = "Marcin Szczygliński"
19
19
  __github__ = "https://github.com/szczyglis-dev/py-gpt"
@@ -126,6 +126,8 @@ class Attachment(QObject):
126
126
  self.uploaded = False
127
127
  auto_index = self.window.core.config.get("attachments_auto_index", False)
128
128
  attachments = self.window.core.attachments.get_all(mode, only_files=True)
129
+ if self.mode != self.MODE_QUERY_CONTEXT:
130
+ auto_index = False # disable auto index for full context and summary modes
129
131
 
130
132
  if self.is_verbose() and len(attachments) > 0:
131
133
  print(f"\nUploading attachments...\nWork Mode: {mode}")
@@ -1,13 +1,4 @@
1
- #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
- # ================================================== #
4
- # This file is a part of PYGPT package #
5
- # Website: https://pygpt.net #
6
- # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
- # MIT License #
8
- # Created By : Marcin Szczygliński #
9
- # Updated Date: 2024.12.14 08:00:00 #
10
- # ================================================== #
1
+ # controller/painter/common.py
11
2
 
12
3
  from typing import Tuple, Optional, Dict, List
13
4
 
@@ -49,8 +40,11 @@ class Common:
49
40
  :param enabled: bool
50
41
  """
51
42
  if enabled:
43
+ # keep UI color for compatibility
52
44
  self.window.ui.nodes['painter.select.brush.color'].setCurrentText("Black")
53
45
  self.window.ui.painter.set_brush_color(Qt.black)
46
+ # switch widget to brush mode (layered painting)
47
+ self.window.ui.painter.set_mode("brush")
54
48
  self.window.core.config.set('painter.brush.mode', "brush")
55
49
  self.window.core.config.save()
56
50
 
@@ -61,8 +55,11 @@ class Common:
61
55
  :param enabled: bool
62
56
  """
63
57
  if enabled:
58
+ # keep UI color for compatibility
64
59
  self.window.ui.nodes['painter.select.brush.color'].setCurrentText("White")
65
60
  self.window.ui.painter.set_brush_color(Qt.white)
61
+ # switch widget to erase mode (layered erasing)
62
+ self.window.ui.painter.set_mode("erase")
66
63
  self.window.core.config.set('painter.brush.mode', "erase")
67
64
  self.window.core.config.save()
68
65
 
@@ -76,8 +73,10 @@ class Common:
76
73
  selected = self.window.ui.nodes['painter.select.canvas.size'].currentData()
77
74
  if selected:
78
75
  size = self.convert_to_size(selected)
76
+ # setCurrentText might not exist in the combo's items for custom sizes; harmless if it doesn't match
79
77
  self.window.ui.nodes['painter.select.canvas.size'].setCurrentText(selected)
80
78
  self.set_canvas_size(size[0], size[1])
79
+ # resizing the widget triggers automatic image rescale in PainterWidget.resizeEvent
81
80
  self.window.core.config.set('painter.canvas.size', selected)
82
81
  self.window.core.config.save()
83
82
 
@@ -192,4 +191,4 @@ class Common:
192
191
 
193
192
  :return: path to capture directory
194
193
  """
195
- return self.window.core.config.get_user_dir('capture')
194
+ return self.window.core.config.get_user_dir('capture')
@@ -1,13 +1,4 @@
1
- #!/usr/bin/env python3
2
- # -*- coding: utf-8 -*-
3
- # ================================================== #
4
- # This file is a part of PYGPT package #
5
- # Website: https://pygpt.net #
6
- # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
- # MIT License #
8
- # Created By : Marcin Szczygliński #
9
- # Updated Date: 2024.11.20 21:00:00 #
10
- # ================================================== #
1
+ # controller/painter/painter.py
11
2
 
12
3
  import os
13
4
 
@@ -72,7 +63,8 @@ class Painter:
72
63
  return
73
64
  path = os.path.join(self.common.get_capture_dir(), '_current.png')
74
65
  if os.path.exists(path):
75
- self.window.ui.painter.image.load(path)
66
+ # load as flat source; layers will be rebuilt on canvas resize
67
+ self.window.ui.painter.load_flat_image(path)
76
68
  else:
77
69
  # clear image
78
70
  self.window.ui.painter.clear_image()
@@ -88,4 +80,4 @@ class Painter:
88
80
 
89
81
  def reload(self):
90
82
  """Reload painter"""
91
- self.setup()
83
+ self.setup()
@@ -6,13 +6,14 @@
6
6
  # GitHub: https://github.com/szczyglis-dev/py-gpt #
7
7
  # MIT License #
8
8
  # Created By : Marcin Szczygliński #
9
- # Updated Date: 2025.08.11 14:00:00 #
9
+ # Updated Date: 2025.09.02 09:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import os
13
13
  import time
14
14
 
15
- from PySide6.QtCore import QObject, Signal, QRunnable, Slot
15
+ from PySide6.QtCore import QObject, Signal, QRunnable, Slot, QEventLoop, QTimer, Qt
16
+ from PySide6.QtGui import QImage
16
17
 
17
18
 
18
19
  class Camera:
@@ -50,85 +51,400 @@ class CaptureWorker(QRunnable):
50
51
  self.args = args
51
52
  self.kwargs = kwargs
52
53
  self.window = None
54
+
55
+ # Common
53
56
  self.initialized = False
54
- self.capture = None
55
- self.frame = None
56
57
  self.allow_finish = False
58
+ self._fps_interval = 1.0 / 30.0 # default 30 FPS throttle
59
+
60
+ # Qt Multimedia objects (created in worker thread)
61
+ self.session = None
62
+ self.camera = None
63
+ self.sink = None
64
+ self.loop = None
65
+ self.poll_timer = None
66
+ self._qt_got_first_frame = False
67
+ self._probe_loop = None
68
+
69
+ # OpenCV fallback
70
+ self.cv_cap = None
71
+
72
+ # Timing (shared)
73
+ self._last_emit = 0.0
74
+
75
+ # =========================
76
+ # Qt Multimedia path
77
+ # =========================
78
+ def _select_camera_format(self, device, target_w: int, target_h: int):
79
+ """
80
+ Select best matching camera format by resolution.
57
81
 
58
- def setup_camera(self):
59
- """Initialize camera"""
82
+ :param device: QCameraDevice
83
+ :param target_w: target width
84
+ :param target_h: target height
85
+ """
60
86
  try:
61
- import cv2
62
- # get params from global config
63
- self.capture = cv2.VideoCapture(self.window.core.config.get('vision.capture.idx'))
64
- if not self.capture or not self.capture.isOpened():
65
- self.allow_finish = False
66
- self.signals.unfinished.emit()
67
- return
68
- self.capture.set(cv2.CAP_PROP_FRAME_WIDTH, self.window.core.config.get('vision.capture.width'))
69
- self.capture.set(cv2.CAP_PROP_FRAME_HEIGHT, self.window.core.config.get('vision.capture.height'))
87
+ formats = list(device.videoFormats())
88
+ except Exception:
89
+ formats = []
90
+ if not formats:
91
+ return None
92
+
93
+ best = None
94
+ best_score = float('inf')
95
+ for f in formats:
96
+ res = f.resolution()
97
+ w, h = res.width(), res.height()
98
+ score = abs(w - target_w) + abs(h - target_h)
99
+ if score < best_score:
100
+ best_score = score
101
+ best = f
102
+ return best
103
+
104
+ def _init_qt(self) -> bool:
105
+ """
106
+ Try to initialize Qt camera pipeline.
107
+
108
+ :return: True if initialized
109
+ """
110
+ try:
111
+ from PySide6.QtMultimedia import (
112
+ QCamera,
113
+ QMediaDevices,
114
+ QMediaCaptureSession,
115
+ QVideoSink,
116
+ )
117
+
118
+ idx = int(self.window.core.config.get('vision.capture.idx'))
119
+ target_w = int(self.window.core.config.get('vision.capture.width'))
120
+ target_h = int(self.window.core.config.get('vision.capture.height'))
121
+ target_fps = 30
122
+ self._fps_interval = 1.0 / float(target_fps)
123
+
124
+ devices = list(QMediaDevices.videoInputs())
125
+ if not devices:
126
+ return False
127
+
128
+ if idx < 0 or idx >= len(devices):
129
+ idx = 0
130
+ dev = devices[idx]
131
+
132
+ self.camera = QCamera(dev)
133
+ fmt = self._select_camera_format(dev, target_w, target_h)
134
+ if fmt is not None:
135
+ self.camera.setCameraFormat(fmt)
136
+
137
+ self.session = QMediaCaptureSession()
138
+ self.session.setCamera(self.camera)
139
+
140
+ self.sink = QVideoSink()
141
+ self.sink.videoFrameChanged.connect(self.on_qt_frame_changed, Qt.DirectConnection)
142
+ self.session.setVideoOutput(self.sink)
143
+
144
+ self.camera.errorOccurred.connect(self._on_qt_camera_error, Qt.QueuedConnection)
145
+ return True
146
+
70
147
  except Exception as e:
148
+ # Qt Multimedia not available or failed to init
71
149
  self.window.core.debug.log(e)
150
+ return False
151
+
152
+ def _teardown_qt(self):
153
+ """Release Qt camera pipeline."""
154
+ try:
155
+ if self.sink is not None:
156
+ try:
157
+ self.sink.videoFrameChanged.disconnect(self.on_qt_frame_changed)
158
+ except Exception:
159
+ pass
160
+ if self.camera is not None and self.camera.isActive():
161
+ self.camera.stop()
162
+ except Exception:
163
+ pass
164
+ finally:
165
+ self.sink = None
166
+ self.session = None
167
+ self.camera = None
168
+
169
+ def _probe_qt_start(self, timeout_ms: int = 1500) -> bool:
170
+ """
171
+ Wait briefly for the first frame to confirm Qt pipeline is working.
172
+
173
+ :param timeout_ms: timeout in milliseconds
174
+ :return: True if first frame received
175
+ """
176
+ try:
177
+ if self.camera is None:
178
+ return False
179
+
180
+ self._qt_got_first_frame = False
181
+ self._probe_loop = QEventLoop()
182
+
183
+ # Timeout quits the probe loop
184
+ QTimer.singleShot(timeout_ms, self._probe_loop.quit)
185
+
186
+ # Start camera and wait for first frame or timeout
187
+ self.camera.start()
188
+ self._probe_loop.exec()
189
+
190
+ got = self._qt_got_first_frame
191
+ self._probe_loop = None
192
+ return got
193
+ except Exception as e:
194
+ self.window.core.debug.log(e)
195
+ return False
196
+
197
+ @Slot(object)
198
+ def _on_qt_camera_error(self, err):
199
+ """
200
+ Handle Qt camera errors.
201
+
202
+ :param err: error object
203
+ """
204
+ try:
205
+ # Stop loop if running
206
+ if self.loop is not None and self.loop.isRunning():
207
+ self.loop.quit()
208
+ if self._probe_loop is not None and self._probe_loop.isRunning():
209
+ self._probe_loop.quit()
210
+ except Exception:
211
+ pass
212
+ finally:
213
+ self.allow_finish = False
72
214
  if self.signals is not None:
73
- self.signals.error.emit(e)
74
- self.signals.finished.emit(e)
215
+ self.signals.error.emit(err)
216
+
217
+ @Slot(object)
218
+ def on_qt_frame_changed(self, video_frame):
219
+ """
220
+ Convert QVideoFrame to RGB numpy array and emit.
221
+
222
+ :param video_frame: QVideoFrame
223
+ """
224
+ try:
225
+ # Mark that we have a first frame for probe
226
+ if not self._qt_got_first_frame:
227
+ self._qt_got_first_frame = True
228
+ # If we are probing, quit the probe loop immediately
229
+ if self._probe_loop is not None and self._probe_loop.isRunning():
230
+ self._probe_loop.quit()
231
+
232
+ # Throttle FPS for normal operation path
233
+ now = time.monotonic()
234
+ if self.loop is not None and self.loop.isRunning():
235
+ if (now - self._last_emit) < self._fps_interval:
236
+ return
237
+
238
+ img = video_frame.toImage()
239
+ if img.isNull():
240
+ return
241
+
242
+ img = img.convertToFormat(QImage.Format.Format_RGB888)
243
+
244
+ w = img.width()
245
+ h = img.height()
246
+ bpl = img.bytesPerLine()
75
247
 
248
+ ptr = img.bits()
249
+ size = bpl * h
250
+ try:
251
+ ptr.setsize(size)
252
+ except Exception:
253
+ # Some bindings may not require setsize; ignore if unsupported
254
+ pass
255
+
256
+ import numpy as np
257
+ arr = np.frombuffer(ptr, dtype=np.uint8)
258
+
259
+ if bpl != w * 3:
260
+ arr = arr.reshape(h, bpl)[:, : w * 3]
261
+ arr = arr.reshape(h, w, 3).copy()
262
+ else:
263
+ arr = arr.reshape(h, w, 3).copy()
264
+
265
+ if self.signals is not None:
266
+ self.signals.capture.emit(arr)
267
+ self._last_emit = now
268
+
269
+ except Exception as e:
270
+ self.window.core.debug.log(e)
271
+
272
+ # =========================
273
+ # OpenCV fallback path
274
+ # =========================
275
+ def _init_cv2(self) -> bool:
276
+ """
277
+ Try to initialize OpenCV VideoCapture fallback.
278
+
279
+ :return: True if initialized
280
+ """
281
+ try:
282
+ import cv2
283
+ idx = int(self.window.core.config.get('vision.capture.idx'))
284
+ target_w = int(self.window.core.config.get('vision.capture.width'))
285
+ target_h = int(self.window.core.config.get('vision.capture.height'))
286
+ target_fps = 30
287
+ self._fps_interval = 1.0 / float(target_fps)
288
+
289
+ cap = cv2.VideoCapture(idx)
290
+ if not cap or not cap.isOpened():
291
+ return False
292
+
293
+ cap.set(cv2.CAP_PROP_FRAME_WIDTH, target_w)
294
+ cap.set(cv2.CAP_PROP_FRAME_HEIGHT, target_h)
295
+ self.cv_cap = cap
296
+ return True
297
+ except Exception as e:
298
+ self.window.core.debug.log(e)
299
+ return False
300
+
301
+ def _teardown_cv2(self):
302
+ """Release OpenCV capture."""
303
+ try:
304
+ if self.cv_cap is not None and self.cv_cap.isOpened():
305
+ self.cv_cap.release()
306
+ except Exception:
307
+ pass
308
+ finally:
309
+ self.cv_cap = None
310
+
311
+ # =========================
312
+ # Runner
313
+ # =========================
76
314
  @Slot()
77
315
  def run(self):
78
- """Frame capture loop"""
79
- target_fps = 30
80
- fps_interval = 1.0 / target_fps
316
+ """Run capture using Qt first; fall back to OpenCV if needed."""
81
317
  self.allow_finish = True
318
+ self._last_emit = 0.0
319
+
320
+ used_backend = None
82
321
  try:
83
- import cv2
84
- if not self.initialized:
85
- self.setup_camera()
86
- self.signals.started.emit()
87
- self.initialized = True
88
- last_frame_time = time.time()
89
- while True:
90
- if self.window.is_closing \
91
- or self.capture is None \
92
- or not self.capture.isOpened() \
93
- or self.window.controller.camera.stop:
94
- self.release() # release camera
95
- self.signals.stopped.emit()
96
- break
97
- _, frame = self.capture.read()
98
- frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
99
- now = time.time()
100
- if now - last_frame_time >= fps_interval:
101
- self.signals.capture.emit(frame)
102
- last_frame_time = now
322
+ # Try Qt Multimedia
323
+ if self._init_qt():
324
+ if self._probe_qt_start(timeout_ms=1500):
325
+ # Qt confirmed working; start main event-driven loop
326
+ used_backend = 'qt'
327
+ self.initialized = True
328
+ if self.signals is not None:
329
+ self.signals.started.emit()
330
+
331
+ self.loop = QEventLoop()
332
+
333
+ self.poll_timer = QTimer()
334
+ self.poll_timer.setTimerType(Qt.PreciseTimer)
335
+ self.poll_timer.setInterval(30)
336
+ self.poll_timer.timeout.connect(self._poll_stop_qt, Qt.DirectConnection)
337
+ self.poll_timer.start()
338
+
339
+ self.loop.exec()
340
+
341
+ if self.signals is not None:
342
+ self.signals.stopped.emit()
343
+ else:
344
+ # Fallback to OpenCV if no frames arrive quickly
345
+ print("QT camera init failed, trying CV2 fallback...")
346
+ self._teardown_qt()
347
+ else:
348
+ # Qt init failed outright, fallback to CV2
349
+ print("QT camera init failed, trying CV2 fallback...")
350
+
351
+ # Try OpenCV fallback if Qt was not used
352
+ if used_backend is None:
353
+ if self._init_cv2():
354
+ used_backend = 'cv2'
355
+ self.initialized = True
356
+ if self.signals is not None:
357
+ self.signals.started.emit()
358
+
359
+ import cv2
360
+ target_fps = 30
361
+ fps_interval = 1.0 / float(target_fps)
362
+ last_frame_time = time.time()
363
+
364
+ while True:
365
+ if self._should_stop():
366
+ break
367
+
368
+ ok, frame = self.cv_cap.read()
369
+ if not ok or frame is None:
370
+ continue
371
+
372
+ now = time.time()
373
+ if now - last_frame_time >= fps_interval:
374
+ # Convert BGR -> RGB for the controller/UI pipeline
375
+ frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
376
+ if self.signals is not None:
377
+ self.signals.capture.emit(frame)
378
+ last_frame_time = now
379
+
380
+ if self.signals is not None:
381
+ self.signals.stopped.emit()
382
+ else:
383
+ # Both providers failed
384
+ self.allow_finish = False
103
385
 
104
386
  except Exception as e:
105
387
  self.window.core.debug.log(e)
106
388
  if self.signals is not None:
107
389
  self.signals.error.emit(e)
108
-
109
390
  finally:
110
- self.release() # release camera
391
+ # Cleanup resources
392
+ try:
393
+ if self.poll_timer is not None:
394
+ self.poll_timer.stop()
395
+ except Exception:
396
+ pass
397
+ self.poll_timer = None
398
+
399
+ if used_backend == 'qt':
400
+ self._teardown_qt()
401
+ else:
402
+ self._teardown_qt() # no-op if not initialized
403
+ self._teardown_cv2()
404
+
405
+ # Emit final state
111
406
  if self.signals is not None:
112
407
  if self.allow_finish:
113
408
  self.signals.finished.emit()
114
409
  else:
115
410
  self.signals.unfinished.emit()
411
+
116
412
  self.cleanup()
117
413
 
118
- def release(self):
119
- """Release camera"""
120
- if self.capture is not None and self.capture.isOpened():
121
- self.capture.release()
122
- self.capture = None
123
- self.frame = None
124
- self.initialized = False
414
+ def _poll_stop_qt(self):
415
+ """Check stop flags while running Qt pipeline."""
416
+ try:
417
+ if self._should_stop():
418
+ if self.camera is not None and self.camera.isActive():
419
+ self.camera.stop()
420
+ if self.loop is not None and self.loop.isRunning():
421
+ self.loop.quit()
422
+ except Exception as e:
423
+ self.window.core.debug.log(e)
424
+ if self.loop is not None and self.loop.isRunning():
425
+ self.loop.quit()
426
+
427
+ def _should_stop(self) -> bool:
428
+ """
429
+ Check external stop flags.
430
+
431
+ :return: True if should stop
432
+ """
433
+ try:
434
+ if getattr(self.window, 'is_closing', False):
435
+ return True
436
+ if self.window is not None and self.window.controller.camera.stop:
437
+ return True
438
+ except Exception:
439
+ return True
440
+ return False
125
441
 
126
442
  def cleanup(self):
127
443
  """Cleanup resources after worker execution."""
128
444
  sig = self.signals
129
445
  self.signals = None
130
- if sig is not None:
131
- try:
446
+ try:
447
+ if sig is not None:
132
448
  sig.deleteLater()
133
- except RuntimeError:
134
- pass
449
+ except RuntimeError:
450
+ pass