pygpt-net 2.7.2__py3-none-any.whl → 2.7.4__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.
Files changed (48) hide show
  1. pygpt_net/CHANGELOG.txt +12 -0
  2. pygpt_net/__init__.py +3 -3
  3. pygpt_net/app.py +382 -350
  4. pygpt_net/controller/chat/attachment.py +5 -1
  5. pygpt_net/controller/chat/image.py +40 -5
  6. pygpt_net/controller/files/files.py +3 -1
  7. pygpt_net/controller/layout/layout.py +2 -2
  8. pygpt_net/controller/media/media.py +70 -1
  9. pygpt_net/controller/theme/nodes.py +2 -1
  10. pygpt_net/controller/ui/mode.py +5 -1
  11. pygpt_net/controller/ui/ui.py +17 -2
  12. pygpt_net/core/filesystem/url.py +4 -1
  13. pygpt_net/core/render/web/helpers.py +5 -0
  14. pygpt_net/data/config/config.json +5 -4
  15. pygpt_net/data/config/models.json +3 -3
  16. pygpt_net/data/config/settings.json +0 -14
  17. pygpt_net/data/css/web-blocks.css +3 -0
  18. pygpt_net/data/css/web-chatgpt.css +3 -0
  19. pygpt_net/data/locale/locale.de.ini +6 -0
  20. pygpt_net/data/locale/locale.en.ini +7 -1
  21. pygpt_net/data/locale/locale.es.ini +6 -0
  22. pygpt_net/data/locale/locale.fr.ini +6 -0
  23. pygpt_net/data/locale/locale.it.ini +6 -0
  24. pygpt_net/data/locale/locale.pl.ini +7 -1
  25. pygpt_net/data/locale/locale.uk.ini +6 -0
  26. pygpt_net/data/locale/locale.zh.ini +6 -0
  27. pygpt_net/launcher.py +115 -55
  28. pygpt_net/preload.py +243 -0
  29. pygpt_net/provider/api/google/image.py +317 -10
  30. pygpt_net/provider/api/google/video.py +160 -4
  31. pygpt_net/provider/api/openai/image.py +201 -93
  32. pygpt_net/provider/api/openai/video.py +99 -24
  33. pygpt_net/provider/api/x_ai/image.py +25 -2
  34. pygpt_net/provider/core/config/patch.py +17 -1
  35. pygpt_net/ui/layout/chat/input.py +20 -2
  36. pygpt_net/ui/layout/chat/painter.py +6 -4
  37. pygpt_net/ui/layout/toolbox/image.py +21 -11
  38. pygpt_net/ui/layout/toolbox/raw.py +2 -2
  39. pygpt_net/ui/layout/toolbox/video.py +22 -9
  40. pygpt_net/ui/main.py +84 -3
  41. pygpt_net/ui/widget/dialog/base.py +3 -10
  42. pygpt_net/ui/widget/option/combo.py +119 -1
  43. pygpt_net/ui/widget/textarea/input_extra.py +664 -0
  44. {pygpt_net-2.7.2.dist-info → pygpt_net-2.7.4.dist-info}/METADATA +27 -20
  45. {pygpt_net-2.7.2.dist-info → pygpt_net-2.7.4.dist-info}/RECORD +48 -46
  46. {pygpt_net-2.7.2.dist-info → pygpt_net-2.7.4.dist-info}/LICENSE +0 -0
  47. {pygpt_net-2.7.2.dist-info → pygpt_net-2.7.4.dist-info}/WHEEL +0 -0
  48. {pygpt_net-2.7.2.dist-info → pygpt_net-2.7.4.dist-info}/entry_points.txt +0 -0
@@ -6,13 +6,13 @@
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.12.31 16:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import base64
13
13
  import datetime
14
14
  import os
15
- from typing import Optional, Dict, Any
15
+ from typing import Optional, Dict, Any, List
16
16
 
17
17
  import requests
18
18
 
@@ -51,12 +51,15 @@ class Image:
51
51
  :param extra: Extra arguments
52
52
  :param sync: Synchronous mode
53
53
  """
54
+ extra = extra or {}
54
55
  prompt = context.prompt
55
56
  ctx = context.ctx
56
57
  model = context.model
57
58
  num = extra.get("num", 1)
58
59
  inline = extra.get("inline", False)
59
60
  sub_mode = self.MODE_GENERATE
61
+ image_id = extra.get("image_id") # previous image reference for remix
62
+ extra_prompt = extra.get("extra_prompt", "")
60
63
 
61
64
  # if attachments then switch mode to EDIT
62
65
  attachments = context.attachments
@@ -72,27 +75,29 @@ class Image:
72
75
  prompt_model = self.window.core.models.get(tmp_model)
73
76
 
74
77
  # worker
75
- self.worker = ImageWorker()
76
- self.worker.window = self.window
77
- self.worker.client = self.window.core.api.openai.get_client()
78
- self.worker.ctx = ctx
79
- self.worker.mode = sub_mode # mode can be "generate" or "edit"
80
- self.worker.attachments = attachments # attachments for edit mode
81
- self.worker.raw = self.window.core.config.get('img_raw')
82
- self.worker.model = model.id # model ID for generate image, e.g. "dall-e-3"
83
- self.worker.model_prompt = prompt_model # model for generate prompt, not image!
84
- self.worker.input_prompt = prompt
85
- self.worker.system_prompt = self.window.core.prompt.get('img')
86
- self.worker.num = num
87
- self.worker.inline = inline
78
+ worker = ImageWorker()
79
+ worker.window = self.window
80
+ worker.client = self.window.core.api.openai.get_client()
81
+ worker.ctx = ctx
82
+ worker.mode = sub_mode # mode can be "generate" or "edit"
83
+ worker.attachments = attachments # attachments for edit mode
84
+ worker.raw = self.window.core.config.get('img_raw')
85
+ worker.model = model.id # model ID for generate image, e.g. "dall-e-3"
86
+ worker.model_prompt = prompt_model # model for generate prompt, not image!
87
+ worker.input_prompt = prompt
88
+ worker.system_prompt = self.window.core.prompt.get('img')
89
+ worker.num = num
90
+ worker.inline = inline
91
+ worker.extra_prompt = extra_prompt
92
+ worker.image_id = image_id # remix: previous image path/identifier
88
93
 
89
94
  # config
90
95
  if self.window.core.config.has('img_quality'):
91
- self.worker.quality = self.window.core.config.get('img_quality')
96
+ worker.quality = self.window.core.config.get('img_quality')
92
97
  if self.window.core.config.has('img_resolution'):
93
- self.worker.resolution = self.window.core.config.get('img_resolution')
98
+ worker.resolution = self.window.core.config.get('img_resolution')
94
99
 
95
- # signals
100
+ self.worker = worker
96
101
  self.worker.signals.finished.connect(self.window.core.image.handle_finished)
97
102
  self.worker.signals.finished_inline.connect(self.window.core.image.handle_finished_inline)
98
103
  self.worker.signals.status.connect(self.window.core.image.handle_status)
@@ -131,22 +136,25 @@ class ImageWorker(QRunnable):
131
136
  self.kwargs = kwargs
132
137
  self.window = None
133
138
  self.client = None
134
- self.ctx = None
139
+ self.ctx: Optional[CtxItem] = None
135
140
  self.raw = False
136
141
  self.mode = Image.MODE_GENERATE # default mode is generate
137
142
  self.model = "dall-e-3"
138
143
  self.quality = "standard"
139
144
  self.resolution = "1792x1024"
140
- self.attachments = {} # attachments for edit mode
145
+ self.attachments: Dict[str, Any] = {} # attachments for edit mode
141
146
  self.model_prompt = None
142
- self.input_prompt = None
147
+ self.input_prompt: Optional[str] = None
143
148
  self.system_prompt = None
144
149
  self.inline = False
150
+ self.extra_prompt: Optional[str] = None
145
151
  self.num = 1
152
+ self.image_id: Optional[str] = None # previous image reference for remix
153
+
154
+ # legacy maps kept for backwards compatibility (dall-e-2 / dall-e-3 exact ids)
146
155
  self.allowed_max_num = {
147
156
  "dall-e-2": 4,
148
157
  "dall-e-3": 1,
149
- "gpt-image-1": 1,
150
158
  }
151
159
  self.allowed_resolutions = {
152
160
  "dall-e-2": [
@@ -159,29 +167,60 @@ class ImageWorker(QRunnable):
159
167
  "1024x1792",
160
168
  "1024x1024",
161
169
  ],
162
- "gpt-image-1": [
163
- "1536x1024",
164
- "1024x1536",
165
- "1024x1024",
166
- "auto",
167
- ],
168
170
  }
169
171
  self.allowed_quality = {
170
- "dall-e-2": [
171
- "standard",
172
- ],
173
- "dall-e-3": [
174
- "standard",
175
- "hd",
176
- ],
177
- "gpt-image-1": [
178
- "auto",
179
- "high",
180
- "medium",
181
- "low",
182
- ],
172
+ "dall-e-2": ["standard"],
173
+ "dall-e-3": ["standard", "hd"],
183
174
  }
184
175
 
176
+ # ---------- model helpers ----------
177
+
178
+ def _is_gpt_image_model(self, model_id: Optional[str] = None) -> bool:
179
+ mid = (model_id or self.model or "").lower()
180
+ return mid.startswith("gpt-image-1")
181
+
182
+ def _is_dalle2(self, model_id: Optional[str] = None) -> bool:
183
+ mid = (model_id or self.model or "").lower()
184
+ return mid == "dall-e-2"
185
+
186
+ def _is_dalle3(self, model_id: Optional[str] = None) -> bool:
187
+ mid = (model_id or self.model or "").lower()
188
+ return mid == "dall-e-3"
189
+
190
+ def _max_num_for_model(self) -> int:
191
+ if self._is_gpt_image_model():
192
+ return 1
193
+ if self._is_dalle2():
194
+ return self.allowed_max_num["dall-e-2"]
195
+ if self._is_dalle3():
196
+ return self.allowed_max_num["dall-e-3"]
197
+ return 1
198
+
199
+ def _normalize_resolution_for_model(self, resolution: Optional[str]) -> str:
200
+ res = (resolution or "").strip() or "1024x1024"
201
+ if self._is_gpt_image_model():
202
+ allowed = {"1024x1024", "1536x1024", "1024x1536", "auto"}
203
+ return res if res in allowed else "auto"
204
+ if self._is_dalle2():
205
+ allowed = set(self.allowed_resolutions["dall-e-2"])
206
+ return res if res in allowed else "1024x1024"
207
+ if self._is_dalle3():
208
+ allowed = set(self.allowed_resolutions["dall-e-3"])
209
+ return res if res in allowed else "1024x1024"
210
+ return res
211
+
212
+ def _normalize_quality_for_model(self, quality: Optional[str]) -> Optional[str]:
213
+ q = (quality or "").strip().lower()
214
+ if self._is_gpt_image_model():
215
+ allowed = {"auto", "high", "medium", "low"}
216
+ return q if q in allowed else "auto"
217
+ if self._is_dalle2():
218
+ return "standard"
219
+ if self._is_dalle3():
220
+ allowed = {"standard", "hd"}
221
+ return q if q in allowed else "standard"
222
+ return None
223
+
185
224
  @Slot()
186
225
  def run(self):
187
226
  """Run worker"""
@@ -209,55 +248,102 @@ class ImageWorker(QRunnable):
209
248
  self.signals.error.emit(e)
210
249
  self.signals.status.emit(trans('img.status.prompt.error') + ": " + str(e))
211
250
 
251
+ # Fallback negative prompt injection (OpenAI Images API has no native negative_prompt field)
252
+ if self.extra_prompt and str(self.extra_prompt).strip():
253
+ try:
254
+ self.input_prompt = self._merge_negative_prompt(self.input_prompt or "", self.extra_prompt)
255
+ except Exception:
256
+ pass
257
+
212
258
  self.signals.status.emit(trans('img.status.generating') + ": {}...".format(self.input_prompt))
213
259
 
214
- paths = [] # downloaded images paths
260
+ paths: List[str] = [] # downloaded images paths
215
261
  try:
216
- # check if number of images is supported
217
- if self.model in self.allowed_max_num:
218
- if self.num > self.allowed_max_num[self.model]:
219
- self.num = self.allowed_max_num[self.model]
220
-
221
- # check if resolution is supported
222
- resolution = self.resolution
223
- if self.model in self.allowed_resolutions:
224
- if resolution not in self.allowed_resolutions[self.model]:
225
- resolution = self.allowed_resolutions[self.model][0]
226
-
227
- quality = self.quality
228
- if self.model in self.allowed_quality:
229
- if quality not in self.allowed_quality[self.model]:
230
- quality = self.allowed_quality[self.model][0]
231
-
232
- # send to API
262
+ # enforce model-specific limits/options
263
+ self.num = min(max(1, int(self.num or 1)), self._max_num_for_model())
264
+ resolution = self._normalize_resolution_for_model(self.resolution)
265
+ quality = self._normalize_quality_for_model(self.quality)
266
+
233
267
  response = None
234
- if self.mode == Image.MODE_GENERATE:
235
- if self.model == "dall-e-2":
236
- response = self.client.images.generate(
237
- model=self.model,
238
- prompt=self.input_prompt,
239
- n=self.num,
240
- size=resolution,
241
- )
242
- elif self.model == "dall-e-3" or self.model == "gpt-image-1":
243
- response = self.client.images.generate(
244
- model=self.model,
245
- prompt=self.input_prompt,
246
- n=self.num,
247
- quality=quality,
248
- size=resolution,
249
- )
250
- elif self.mode == Image.MODE_EDIT:
251
- images = []
252
- for uuid in self.attachments:
253
- attachment = self.attachments[uuid]
254
- if attachment.path and os.path.exists(attachment.path):
255
- images.append(open(attachment.path, "rb"))
256
- response = self.client.images.edit(
257
- model=self.model,
258
- image=images,
259
- prompt=self.input_prompt,
260
- )
268
+
269
+ # Remix path: if image_id provided, prefer editing with previous image reference
270
+ if self.image_id:
271
+ if self._is_dalle3():
272
+ try:
273
+ self.signals.status.emit("Remix is not supported for this model; generating a new image.")
274
+ except Exception:
275
+ pass
276
+ else:
277
+ remix_images = []
278
+ try:
279
+ if isinstance(self.image_id, str) and os.path.exists(self.image_id):
280
+ remix_images.append(open(self.image_id, "rb"))
281
+ except Exception:
282
+ remix_images = []
283
+
284
+ if len(remix_images) > 0:
285
+ try:
286
+ edit_kwargs = {
287
+ "model": self.model,
288
+ "image": remix_images,
289
+ "prompt": self.input_prompt,
290
+ "n": self.num,
291
+ "size": resolution,
292
+ }
293
+ if self._is_gpt_image_model() or self._is_dalle3():
294
+ if quality:
295
+ edit_kwargs["quality"] = quality
296
+ response = self.client.images.edit(**edit_kwargs)
297
+ finally:
298
+ for f in remix_images:
299
+ try:
300
+ f.close()
301
+ except Exception:
302
+ pass
303
+
304
+ # Normal API paths when remix not executed or unsupported
305
+ if response is None:
306
+ if self.mode == Image.MODE_GENERATE:
307
+ if self._is_dalle2():
308
+ response = self.client.images.generate(
309
+ model=self.model,
310
+ prompt=self.input_prompt,
311
+ n=self.num,
312
+ size=resolution,
313
+ )
314
+ else:
315
+ gen_kwargs = {
316
+ "model": self.model,
317
+ "prompt": self.input_prompt,
318
+ "n": self.num,
319
+ "size": resolution,
320
+ }
321
+ if (self._is_gpt_image_model() or self._is_dalle3()) and quality:
322
+ gen_kwargs["quality"] = quality
323
+ response = self.client.images.generate(**gen_kwargs)
324
+ elif self.mode == Image.MODE_EDIT:
325
+ images = []
326
+ for uuid in self.attachments or {}:
327
+ attachment = self.attachments[uuid]
328
+ if attachment.path and os.path.exists(attachment.path):
329
+ images.append(open(attachment.path, "rb"))
330
+ try:
331
+ edit_kwargs = {
332
+ "model": self.model,
333
+ "image": images,
334
+ "prompt": self.input_prompt,
335
+ "n": self.num,
336
+ "size": resolution,
337
+ }
338
+ if (self._is_gpt_image_model() or self._is_dalle3()) and quality:
339
+ edit_kwargs["quality"] = quality
340
+ response = self.client.images.edit(**edit_kwargs)
341
+ finally:
342
+ for f in images:
343
+ try:
344
+ f.close()
345
+ except Exception:
346
+ pass
261
347
 
262
348
  # check response
263
349
  if response is None:
@@ -278,14 +364,13 @@ class ImageWorker(QRunnable):
278
364
  msg = trans('img.status.downloading') + " (" + str(i + 1) + " / " + str(self.num) + ") -> " + str(path)
279
365
  self.signals.status.emit(msg)
280
366
 
281
- if response.data[i] is None:
282
- self.signals.error.emit("API Error: empty image data")
283
- return
284
- if response.data[i].url: # dall-e 2 and 3 returns URL
285
- res = requests.get(response.data[i].url)
367
+ item = response.data[i]
368
+ data = None
369
+ if getattr(item, "url", None):
370
+ res = requests.get(item.url)
286
371
  data = res.content
287
- else: # gpt-image-1 returns base64 encoded image
288
- data = base64.b64decode(response.data[i].b64_json)
372
+ elif getattr(item, "b64_json", None):
373
+ data = base64.b64decode(item.b64_json)
289
374
 
290
375
  # save image
291
376
  if data and self.window.core.image.save_image(path, data):
@@ -293,6 +378,16 @@ class ImageWorker(QRunnable):
293
378
  else:
294
379
  self.signals.error.emit("Error saving image")
295
380
 
381
+ # store image_id for future remix (use first saved path as reference)
382
+ if paths:
383
+ try:
384
+ if not isinstance(self.ctx.extra, dict):
385
+ self.ctx.extra = {}
386
+ self.ctx.extra["image_id"] = paths[0]
387
+ self.window.core.ctx.update_item(self.ctx)
388
+ except Exception:
389
+ pass
390
+
296
391
  # send finished signal
297
392
  if self.inline:
298
393
  self.signals.finished_inline.emit( # separated signal for inline mode
@@ -323,3 +418,16 @@ class ImageWorker(QRunnable):
323
418
  sig.deleteLater()
324
419
  except RuntimeError:
325
420
  pass
421
+
422
+ # ---------- prompt utilities ----------
423
+
424
+ @staticmethod
425
+ def _merge_negative_prompt(prompt: str, negative: Optional[str]) -> str:
426
+ """
427
+ Append a negative prompt to the main text prompt for providers without a native negative_prompt field.
428
+ """
429
+ base = (prompt or "").strip()
430
+ neg = (negative or "").strip()
431
+ if not neg:
432
+ return base
433
+ return (base + ("\n" if base else "") + f"Negative prompt: {neg}").strip()
@@ -6,7 +6,7 @@
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.12.26 12:00:00 #
9
+ # Updated Date: 2025.12.31 16:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import datetime
@@ -59,6 +59,8 @@ class Video:
59
59
  prompt = context.prompt
60
60
  num = int(extra.get("num", 1))
61
61
  inline = bool(extra.get("inline", False))
62
+ video_id = extra.get("video_id")
63
+ extra_prompt = extra.get("extra_prompt", "")
62
64
 
63
65
  # decide sub-mode based on attachments (image-to-video when image is attached)
64
66
  sub_mode = self.MODE_GENERATE
@@ -85,6 +87,8 @@ class Video:
85
87
  worker.raw = self.window.core.config.get('img_raw')
86
88
  worker.num = num
87
89
  worker.inline = inline
90
+ worker.extra_prompt = extra_prompt
91
+ worker.video_id = video_id
88
92
 
89
93
  # optional params (app-level options)
90
94
  worker.aspect_ratio = str(extra.get("aspect_ratio") or self.window.core.config.get('video.aspect_ratio') or "16:9")
@@ -155,6 +159,8 @@ class VideoWorker(QRunnable):
155
159
  self.input_prompt = ""
156
160
  self.system_prompt = ""
157
161
  self.inline = False
162
+ self.extra_prompt: Optional[str] = None
163
+ self.video_id = None
158
164
  self.raw = False
159
165
  self.num = 1
160
166
 
@@ -169,6 +175,7 @@ class VideoWorker(QRunnable):
169
175
  @Slot()
170
176
  def run(self):
171
177
  try:
178
+ kernel = self.window.controller.kernel
172
179
  # Optional prompt enhancement via app default LLM
173
180
  if not self.raw and not self.inline and self.input_prompt:
174
181
  try:
@@ -189,6 +196,14 @@ class VideoWorker(QRunnable):
189
196
  self.signals.error.emit(e)
190
197
  self.signals.status.emit(trans('vid.status.prompt.error') + ": " + str(e))
191
198
 
199
+ # Negative prompt fallback: inject constraints into the text prompt (Sora has no native negative_prompt field)
200
+ if self.extra_prompt and str(self.extra_prompt).strip():
201
+ try:
202
+ self.input_prompt = self._merge_negative_prompt(self.input_prompt, self.extra_prompt)
203
+ except Exception:
204
+ # do not fail generation if merge fails
205
+ pass
206
+
192
207
  # Sora API accepts a single video per create call; honor app's num but cap to 1 per job
193
208
  _ = max(1, min(self.num, self.max_per_job))
194
209
 
@@ -199,42 +214,88 @@ class VideoWorker(QRunnable):
199
214
  # Image-to-video: first image attachment as input_reference, must match "size"
200
215
  image_path = self._first_image_attachment(self.attachments) if self.mode == Video.MODE_IMAGE_TO_VIDEO else None
201
216
 
202
- self.signals.status.emit(trans('vid.status.generating') + f": {self.input_prompt}...")
217
+ # If remix requested, ignore any image input_reference
218
+ is_remix = bool(self.video_id)
219
+ if is_remix:
220
+ image_path = None # enforce remix over image-to-video
221
+
222
+ label = trans('vid.status.generating')
223
+ if is_remix:
224
+ label += " (remix)"
225
+ self.signals.status.emit(label + f": {self.input_prompt}...")
203
226
 
204
227
  # Create job
205
- create_kwargs: Dict[str, Any] = {
206
- "model": self.model or "sora-2",
207
- "prompt": self.input_prompt or "",
208
- "seconds": str(seconds),
209
- "size": size,
210
- }
211
-
212
- # Attach image as input_reference; auto-resize to match requested size if needed
228
+ job = None
213
229
  file_handle = None
214
- if image_path:
215
- prepared = self._prepare_input_reference(image_path, size)
216
- if prepared is not None:
217
- # tuple (filename, bytes, mime) supported by OpenAI Python SDK
218
- create_kwargs["input_reference"] = prepared
219
- else:
220
- # Fallback: use original file (may fail if size/mime mismatch)
230
+
231
+ if is_remix:
232
+ # Primary path: dedicated remix endpoint, inherits the original's length and size
233
+ last_exc = None
234
+ try:
235
+ job = self.client.videos.remix(
236
+ video_id=str(self.video_id),
237
+ prompt=self.input_prompt or "",
238
+ )
239
+ except Exception as e1:
240
+ # Fallbacks for older SDKs: pass remix id via create()
241
+ last_exc = e1
221
242
  try:
222
- file_handle = open(image_path, "rb")
223
- create_kwargs["input_reference"] = file_handle
224
- except Exception as e:
225
- self.signals.error.emit(e)
243
+ job = self.client.videos.create(
244
+ model=self.model or "sora-2",
245
+ prompt=self.input_prompt or "",
246
+ remix_id=str(self.video_id),
247
+ )
248
+ except Exception as e2:
249
+ last_exc = e2
250
+ try:
251
+ job = self.client.videos.create(
252
+ model=self.model or "sora-2",
253
+ prompt=self.input_prompt or "",
254
+ remix_video_id=str(self.video_id),
255
+ )
256
+ except Exception as e3:
257
+ last_exc = e3
258
+ if job is None:
259
+ raise last_exc or RuntimeError("Unable to start remix job.")
260
+ else:
261
+ create_kwargs: Dict[str, Any] = {
262
+ "model": self.model or "sora-2",
263
+ "prompt": self.input_prompt or "",
264
+ "seconds": str(seconds),
265
+ "size": size,
266
+ }
267
+
268
+ # Attach image as input_reference; auto-resize to match requested size if needed
269
+ if image_path:
270
+ prepared = self._prepare_input_reference(image_path, size)
271
+ if prepared is not None:
272
+ create_kwargs["input_reference"] = prepared
273
+ else:
274
+ try:
275
+ file_handle = open(image_path, "rb")
276
+ create_kwargs["input_reference"] = file_handle
277
+ except Exception as e:
278
+ self.signals.error.emit(e)
279
+
280
+ job = self.client.videos.create(**create_kwargs)
226
281
 
227
- job = self.client.videos.create(**create_kwargs)
228
282
  video_id = self._safe_get(job, "id")
229
283
  if not video_id:
230
- # include raw payload for debugging
231
284
  raise RuntimeError("Video job ID missing in create response.")
232
285
 
233
286
  # Poll until completed (or failed/canceled)
287
+ if not isinstance(self.ctx.extra, dict):
288
+ self.ctx.extra = {}
289
+ self.ctx.extra['video_id'] = video_id # store video_id in ctx extra
290
+ self.window.core.ctx.update_item(self.ctx)
234
291
  last_progress = None
235
292
  last_status = None
236
293
  while True:
294
+ if kernel.stopped():
295
+ break
237
296
  time.sleep(5)
297
+ if kernel.stopped():
298
+ break
238
299
  job = self.client.videos.retrieve(video_id)
239
300
  status = self._safe_get(job, "status") or ""
240
301
  progress = self._safe_get(job, "progress")
@@ -559,4 +620,18 @@ class VideoWorker(QRunnable):
559
620
  except Exception:
560
621
  pass
561
622
 
562
- return " ".join(parts).strip()
623
+ return " ".join(parts).strip()
624
+
625
+ # ---------- prompt utilities ----------
626
+
627
+ @staticmethod
628
+ def _merge_negative_prompt(prompt: str, negative: Optional[str]) -> str:
629
+ """
630
+ Append a negative prompt to the main text prompt for providers without a native negative_prompt field.
631
+ """
632
+ base = (prompt or "").strip()
633
+ neg = (negative or "").strip()
634
+ if not neg:
635
+ return base
636
+ # Keep the user's original prompt intact and add clear constraint instructions.
637
+ return (base + ("\n" if base else "") + f"Negative prompt: {neg}").strip()
@@ -6,7 +6,7 @@
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.09.05 01:00:00 #
9
+ # Updated Date: 2025.12.31 16:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import base64
@@ -49,6 +49,7 @@ class Image:
49
49
  prompt = context.prompt
50
50
  num = int(extra.get("num", 1))
51
51
  inline = bool(extra.get("inline", False))
52
+ extra_prompt = extra.get("extra_prompt", "")
52
53
 
53
54
  # Optional prompt enhancement model (same as in your Google path)
54
55
  prompt_model = self.window.core.models.from_defaults()
@@ -66,6 +67,7 @@ class Image:
66
67
  worker.raw = self.window.core.config.get('img_raw')
67
68
  worker.num = num
68
69
  worker.inline = inline
70
+ worker.extra_prompt = extra_prompt
69
71
 
70
72
  self.worker = worker
71
73
  self.worker.signals.finished.connect(self.window.core.image.handle_finished)
@@ -102,6 +104,7 @@ class ImageWorker(QRunnable):
102
104
  self.input_prompt = ""
103
105
  self.system_prompt = ""
104
106
  self.inline = False
107
+ self.extra_prompt: Optional[str] = None
105
108
  self.raw = False
106
109
  self.num = 1
107
110
 
@@ -131,6 +134,13 @@ class ImageWorker(QRunnable):
131
134
  self.signals.error.emit(e)
132
135
  self.signals.status.emit(trans('img.status.prompt.error') + ": " + str(e))
133
136
 
137
+ # Negative prompt fallback: append as textual instruction (xAI has no native field for it)
138
+ if self.extra_prompt and str(self.extra_prompt).strip():
139
+ try:
140
+ self.input_prompt = self._merge_negative_prompt(self.input_prompt or "", self.extra_prompt)
141
+ except Exception:
142
+ pass
143
+
134
144
  self.signals.status.emit(trans('img.status.generating') + f": {self.input_prompt}...")
135
145
 
136
146
  cfg = self.window.core.config
@@ -205,4 +215,17 @@ class ImageWorker(QRunnable):
205
215
  try:
206
216
  sig.deleteLater()
207
217
  except RuntimeError:
208
- pass
218
+ pass
219
+
220
+ # ---------- prompt utilities ----------
221
+
222
+ @staticmethod
223
+ def _merge_negative_prompt(prompt: str, negative: Optional[str]) -> str:
224
+ """
225
+ Append a negative prompt to the main text prompt for providers without a native negative_prompt field.
226
+ """
227
+ base = (prompt or "").strip()
228
+ neg = (negative or "").strip()
229
+ if not neg:
230
+ return base
231
+ return (base + ("\n" if base else "") + f"Negative prompt: {neg}").strip()
@@ -6,7 +6,7 @@
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.12.27 02:00:00 #
9
+ # Updated Date: 2025.12.30 22:00:00 #
10
10
  # ================================================== #
11
11
 
12
12
  import copy
@@ -207,6 +207,22 @@ class Patch:
207
207
  patch_css('style.dark.css', True)
208
208
  updated = True
209
209
 
210
+ # < 2.7.3
211
+ if old < parse_version("2.7.3"):
212
+ print("Migrating config from < 2.7.3...")
213
+ if "video.remix" not in data:
214
+ data["video.remix"] = False
215
+ if "img.remix" not in data:
216
+ data["img.remix"] = False
217
+ updated = True
218
+
219
+ # < 2.7.4
220
+ if old < parse_version("2.7.4"):
221
+ print("Migrating config from < 2.7.4...")
222
+ patch_css('web-chatgpt.css', True)
223
+ patch_css('web-blocks.css', True)
224
+ updated = True
225
+
210
226
  # update file
211
227
  migrated = False
212
228
  if updated: