pygpt-net 2.6.65__py3-none-any.whl → 2.6.66__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 +11 -0
- pygpt_net/__init__.py +3 -3
- pygpt_net/app.py +2 -0
- pygpt_net/controller/chat/chat.py +0 -0
- pygpt_net/controller/chat/handler/openai_stream.py +137 -7
- pygpt_net/controller/chat/render.py +0 -0
- pygpt_net/controller/config/field/checkbox_list.py +34 -1
- pygpt_net/controller/media/media.py +20 -1
- pygpt_net/controller/presets/presets.py +4 -1
- pygpt_net/controller/ui/mode.py +14 -10
- pygpt_net/controller/ui/ui.py +18 -1
- pygpt_net/core/image/image.py +34 -1
- pygpt_net/core/tabs/tabs.py +0 -0
- pygpt_net/core/types/image.py +61 -3
- pygpt_net/data/config/config.json +4 -3
- pygpt_net/data/config/models.json +629 -41
- pygpt_net/data/locale/locale.de.ini +4 -0
- pygpt_net/data/locale/locale.en.ini +4 -0
- pygpt_net/data/locale/locale.es.ini +4 -0
- pygpt_net/data/locale/locale.fr.ini +4 -0
- pygpt_net/data/locale/locale.it.ini +4 -0
- pygpt_net/data/locale/locale.pl.ini +4 -0
- pygpt_net/data/locale/locale.uk.ini +4 -0
- pygpt_net/data/locale/locale.zh.ini +4 -0
- pygpt_net/item/model.py +15 -19
- pygpt_net/provider/agents/openai/agent.py +0 -0
- pygpt_net/provider/api/google/__init__.py +20 -9
- pygpt_net/provider/api/google/image.py +161 -28
- pygpt_net/provider/api/google/video.py +73 -36
- pygpt_net/provider/api/openai/__init__.py +21 -11
- pygpt_net/provider/api/openai/agents/client.py +0 -0
- pygpt_net/provider/api/openai/video.py +562 -0
- pygpt_net/provider/core/config/patch.py +7 -0
- pygpt_net/provider/core/model/patch.py +29 -3
- pygpt_net/provider/vector_stores/qdrant.py +117 -0
- pygpt_net/ui/layout/toolbox/raw.py +7 -1
- pygpt_net/ui/widget/option/checkbox_list.py +14 -2
- {pygpt_net-2.6.65.dist-info → pygpt_net-2.6.66.dist-info}/METADATA +66 -25
- {pygpt_net-2.6.65.dist-info → pygpt_net-2.6.66.dist-info}/RECORD +37 -35
- {pygpt_net-2.6.65.dist-info → pygpt_net-2.6.66.dist-info}/LICENSE +0 -0
- {pygpt_net-2.6.65.dist-info → pygpt_net-2.6.66.dist-info}/WHEEL +0 -0
- {pygpt_net-2.6.65.dist-info → pygpt_net-2.6.66.dist-info}/entry_points.txt +0 -0
|
@@ -0,0 +1,562 @@
|
|
|
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: 2025.12.25 20:00:00 #
|
|
10
|
+
# ================================================== #
|
|
11
|
+
|
|
12
|
+
import datetime
|
|
13
|
+
import io
|
|
14
|
+
import json
|
|
15
|
+
import mimetypes
|
|
16
|
+
import os
|
|
17
|
+
import time
|
|
18
|
+
from typing import Optional, Dict, Any, List, Tuple
|
|
19
|
+
|
|
20
|
+
from openai import OpenAI
|
|
21
|
+
|
|
22
|
+
from PySide6.QtCore import QObject, Signal, QRunnable, Slot
|
|
23
|
+
|
|
24
|
+
from pygpt_net.core.events import KernelEvent
|
|
25
|
+
from pygpt_net.core.bridge.context import BridgeContext
|
|
26
|
+
from pygpt_net.item.ctx import CtxItem
|
|
27
|
+
from pygpt_net.utils import trans
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class Video:
|
|
31
|
+
"""
|
|
32
|
+
OpenAI Sora 2 video generation wrapper.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
MODE_GENERATE = "generate"
|
|
36
|
+
MODE_IMAGE_TO_VIDEO = "image2video"
|
|
37
|
+
|
|
38
|
+
def __init__(self, window=None):
|
|
39
|
+
self.window = window
|
|
40
|
+
self.worker: Optional[VideoWorker] = None
|
|
41
|
+
|
|
42
|
+
def generate(
|
|
43
|
+
self,
|
|
44
|
+
context: BridgeContext,
|
|
45
|
+
extra: Optional[Dict[str, Any]] = None,
|
|
46
|
+
sync: bool = True,
|
|
47
|
+
) -> bool:
|
|
48
|
+
"""
|
|
49
|
+
Generate video(s) using OpenAI Sora 2 API.
|
|
50
|
+
|
|
51
|
+
:param context: BridgeContext with prompt, model, attachments
|
|
52
|
+
:param extra: extra parameters (num, inline, aspect_ratio, duration, resolution)
|
|
53
|
+
:param sync: run synchronously (blocking) if True
|
|
54
|
+
:return: True if started
|
|
55
|
+
"""
|
|
56
|
+
extra = extra or {}
|
|
57
|
+
ctx = context.ctx or CtxItem()
|
|
58
|
+
model = context.model
|
|
59
|
+
prompt = context.prompt
|
|
60
|
+
num = int(extra.get("num", 1))
|
|
61
|
+
inline = bool(extra.get("inline", False))
|
|
62
|
+
|
|
63
|
+
# decide sub-mode based on attachments (image-to-video when image is attached)
|
|
64
|
+
sub_mode = self.MODE_GENERATE
|
|
65
|
+
attachments = context.attachments or {}
|
|
66
|
+
if self._has_image_attachment(attachments):
|
|
67
|
+
sub_mode = self.MODE_IMAGE_TO_VIDEO
|
|
68
|
+
|
|
69
|
+
# model used to improve the prompt (not video model)
|
|
70
|
+
prompt_model = self.window.core.models.from_defaults()
|
|
71
|
+
tmp = self.window.core.config.get('video.prompt_model')
|
|
72
|
+
if self.window.core.models.has(tmp):
|
|
73
|
+
prompt_model = self.window.core.models.get(tmp)
|
|
74
|
+
|
|
75
|
+
worker = VideoWorker()
|
|
76
|
+
worker.window = self.window
|
|
77
|
+
worker.client = self.window.core.api.openai.get_client() # configured client
|
|
78
|
+
worker.ctx = ctx
|
|
79
|
+
worker.mode = sub_mode
|
|
80
|
+
worker.attachments = attachments
|
|
81
|
+
worker.model = (model.id if model and getattr(model, "id", None) else "sora-2")
|
|
82
|
+
worker.input_prompt = prompt or ""
|
|
83
|
+
worker.model_prompt = prompt_model # LLM for prompt rewriting
|
|
84
|
+
worker.system_prompt = self.window.core.prompt.get('video')
|
|
85
|
+
worker.raw = self.window.core.config.get('img_raw')
|
|
86
|
+
worker.num = num
|
|
87
|
+
worker.inline = inline
|
|
88
|
+
|
|
89
|
+
# optional params (app-level options)
|
|
90
|
+
worker.aspect_ratio = str(extra.get("aspect_ratio") or self.window.core.config.get('video.aspect_ratio') or "16:9")
|
|
91
|
+
worker.duration_seconds = int(extra.get("duration") or self.window.core.config.get('video.duration') or 8)
|
|
92
|
+
worker.resolution = (extra.get("resolution") or self.window.core.config.get('video.resolution') or "720p")
|
|
93
|
+
|
|
94
|
+
# Sora limits; one output per job
|
|
95
|
+
worker.max_per_job = 1
|
|
96
|
+
|
|
97
|
+
self.worker = worker
|
|
98
|
+
self.worker.signals.finished.connect(self.window.core.video.handle_finished)
|
|
99
|
+
self.worker.signals.finished_inline.connect(self.window.core.video.handle_finished_inline)
|
|
100
|
+
self.worker.signals.status.connect(self.window.core.video.handle_status)
|
|
101
|
+
self.worker.signals.error.connect(self.window.core.video.handle_error)
|
|
102
|
+
|
|
103
|
+
if sync or not self.window.controller.kernel.async_allowed(ctx):
|
|
104
|
+
self.worker.run()
|
|
105
|
+
return True
|
|
106
|
+
|
|
107
|
+
self.window.dispatch(KernelEvent(KernelEvent.STATE_BUSY, {"id": "video"}))
|
|
108
|
+
self.window.threadpool.start(self.worker)
|
|
109
|
+
return True
|
|
110
|
+
|
|
111
|
+
def _has_image_attachment(self, attachments: Dict[str, Any]) -> bool:
|
|
112
|
+
"""Check if at least one image attachment is present."""
|
|
113
|
+
for _, att in (attachments or {}).items():
|
|
114
|
+
try:
|
|
115
|
+
p = getattr(att, "path", None)
|
|
116
|
+
if p and os.path.exists(p):
|
|
117
|
+
mt, _ = mimetypes.guess_type(p)
|
|
118
|
+
if mt and mt.startswith("image/"):
|
|
119
|
+
return True
|
|
120
|
+
except Exception:
|
|
121
|
+
continue
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class VideoSignals(QObject):
|
|
126
|
+
finished = Signal(object, list, str) # ctx, paths, prompt
|
|
127
|
+
finished_inline = Signal(object, list, str) # ctx, paths, prompt
|
|
128
|
+
status = Signal(object) # message
|
|
129
|
+
error = Signal(object) # exception
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
class VideoWorker(QRunnable):
|
|
133
|
+
"""
|
|
134
|
+
Worker that encapsulates Sora 2 async job lifecycle:
|
|
135
|
+
- POST /videos to create job
|
|
136
|
+
- Poll GET /videos/{id} until status=completed or failed
|
|
137
|
+
- GET /videos/{id}/content to download MP4 bytes
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
# Allowed MIME types for input_reference per API
|
|
141
|
+
ALLOWED_MIME = {"image/jpeg", "image/png", "image/webp"}
|
|
142
|
+
|
|
143
|
+
def __init__(self, *args, **kwargs):
|
|
144
|
+
super().__init__()
|
|
145
|
+
self.signals = VideoSignals()
|
|
146
|
+
self.window = None
|
|
147
|
+
self.client: Optional[OpenAI] = None
|
|
148
|
+
self.ctx: Optional[CtxItem] = None
|
|
149
|
+
|
|
150
|
+
# params
|
|
151
|
+
self.mode = Video.MODE_GENERATE
|
|
152
|
+
self.attachments: Dict[str, Any] = {}
|
|
153
|
+
self.model = "sora-2"
|
|
154
|
+
self.model_prompt = None
|
|
155
|
+
self.input_prompt = ""
|
|
156
|
+
self.system_prompt = ""
|
|
157
|
+
self.inline = False
|
|
158
|
+
self.raw = False
|
|
159
|
+
self.num = 1
|
|
160
|
+
|
|
161
|
+
# video generation params (mapped to Sora API)
|
|
162
|
+
self.aspect_ratio = "16:9"
|
|
163
|
+
self.duration_seconds = 8
|
|
164
|
+
self.resolution: str = "720p"
|
|
165
|
+
|
|
166
|
+
# Sora limits
|
|
167
|
+
self.max_per_job = 1
|
|
168
|
+
|
|
169
|
+
@Slot()
|
|
170
|
+
def run(self):
|
|
171
|
+
try:
|
|
172
|
+
# Optional prompt enhancement via app default LLM
|
|
173
|
+
if not self.raw and not self.inline and self.input_prompt:
|
|
174
|
+
try:
|
|
175
|
+
self.signals.status.emit(trans('vid.status.prompt.wait'))
|
|
176
|
+
bridge_context = BridgeContext(
|
|
177
|
+
prompt=self.input_prompt,
|
|
178
|
+
system_prompt=self.system_prompt,
|
|
179
|
+
model=self.model_prompt,
|
|
180
|
+
max_tokens=200,
|
|
181
|
+
temperature=1.0,
|
|
182
|
+
)
|
|
183
|
+
ev = KernelEvent(KernelEvent.CALL, {'context': bridge_context, 'extra': {}})
|
|
184
|
+
self.window.dispatch(ev)
|
|
185
|
+
resp = ev.data.get('response')
|
|
186
|
+
if resp:
|
|
187
|
+
self.input_prompt = resp
|
|
188
|
+
except Exception as e:
|
|
189
|
+
self.signals.error.emit(e)
|
|
190
|
+
self.signals.status.emit(trans('vid.status.prompt.error') + ": " + str(e))
|
|
191
|
+
|
|
192
|
+
# Sora API accepts a single video per create call; honor app's num but cap to 1 per job
|
|
193
|
+
_ = max(1, min(self.num, self.max_per_job))
|
|
194
|
+
|
|
195
|
+
# Build request parameters
|
|
196
|
+
seconds = self._clamp_seconds(self.duration_seconds)
|
|
197
|
+
size = self._resolve_size(self.aspect_ratio, self.resolution, self.model)
|
|
198
|
+
|
|
199
|
+
# Image-to-video: first image attachment as input_reference, must match "size"
|
|
200
|
+
image_path = self._first_image_attachment(self.attachments) if self.mode == Video.MODE_IMAGE_TO_VIDEO else None
|
|
201
|
+
|
|
202
|
+
self.signals.status.emit(trans('vid.status.generating') + f": {self.input_prompt}...")
|
|
203
|
+
|
|
204
|
+
# 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
|
|
213
|
+
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)
|
|
221
|
+
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)
|
|
226
|
+
|
|
227
|
+
job = self.client.videos.create(**create_kwargs)
|
|
228
|
+
video_id = self._safe_get(job, "id")
|
|
229
|
+
if not video_id:
|
|
230
|
+
# include raw payload for debugging
|
|
231
|
+
raise RuntimeError("Video job ID missing in create response.")
|
|
232
|
+
|
|
233
|
+
# Poll until completed (or failed/canceled)
|
|
234
|
+
last_progress = None
|
|
235
|
+
last_status = None
|
|
236
|
+
while True:
|
|
237
|
+
time.sleep(5)
|
|
238
|
+
job = self.client.videos.retrieve(video_id)
|
|
239
|
+
status = self._safe_get(job, "status") or ""
|
|
240
|
+
progress = self._safe_get(job, "progress")
|
|
241
|
+
|
|
242
|
+
if status != last_status or (progress is not None and progress != last_progress):
|
|
243
|
+
try:
|
|
244
|
+
pr_txt = f" [{int(progress)}%]" if isinstance(progress, (int, float)) else ""
|
|
245
|
+
self.signals.status.emit(f"{trans('vid.status.generating')} {status}{pr_txt}")
|
|
246
|
+
except Exception:
|
|
247
|
+
pass
|
|
248
|
+
last_progress = progress
|
|
249
|
+
last_status = status
|
|
250
|
+
|
|
251
|
+
if status in ("completed", "failed", "canceled"):
|
|
252
|
+
break
|
|
253
|
+
|
|
254
|
+
if file_handle is not None:
|
|
255
|
+
try:
|
|
256
|
+
file_handle.close()
|
|
257
|
+
except Exception:
|
|
258
|
+
pass
|
|
259
|
+
|
|
260
|
+
if status != "completed":
|
|
261
|
+
# Extract detailed reason, surface policy hints if present
|
|
262
|
+
reason = self._format_job_error(job)
|
|
263
|
+
if reason:
|
|
264
|
+
self.signals.status.emit(reason)
|
|
265
|
+
raise RuntimeError(f"Video generation did not complete: {status}. {reason or ''}".strip())
|
|
266
|
+
|
|
267
|
+
# Download content
|
|
268
|
+
response = self.client.videos.download_content(video_id=video_id)
|
|
269
|
+
data = response.read() # bytes stream per OpenAI Python SDK
|
|
270
|
+
|
|
271
|
+
# Save file to user video dir
|
|
272
|
+
paths: List[str] = []
|
|
273
|
+
p = self._save(0, data)
|
|
274
|
+
if p:
|
|
275
|
+
paths.append(p)
|
|
276
|
+
|
|
277
|
+
if self.inline:
|
|
278
|
+
self.signals.finished_inline.emit(self.ctx, paths, self.input_prompt)
|
|
279
|
+
else:
|
|
280
|
+
self.signals.finished.emit(self.ctx, paths, self.input_prompt)
|
|
281
|
+
|
|
282
|
+
except Exception as e:
|
|
283
|
+
self.signals.error.emit(e)
|
|
284
|
+
finally:
|
|
285
|
+
self._cleanup()
|
|
286
|
+
|
|
287
|
+
# ---------- helpers ----------
|
|
288
|
+
|
|
289
|
+
@staticmethod
|
|
290
|
+
def _clamp_seconds(requested: int) -> int:
|
|
291
|
+
"""
|
|
292
|
+
Clamp duration to Sora allowed values (4, 8, 12).
|
|
293
|
+
"""
|
|
294
|
+
allowed = [4, 8, 12]
|
|
295
|
+
try:
|
|
296
|
+
r = int(requested or 8)
|
|
297
|
+
except Exception:
|
|
298
|
+
r = 8
|
|
299
|
+
return min(allowed, key=lambda x: abs(x - r))
|
|
300
|
+
|
|
301
|
+
@staticmethod
|
|
302
|
+
def _parse_size(size: str) -> Tuple[int, int]:
|
|
303
|
+
"""
|
|
304
|
+
Parse "WxH" string into integers; returns (W, H). Raises ValueError on invalid format.
|
|
305
|
+
"""
|
|
306
|
+
parts = str(size or "").lower().split("x")
|
|
307
|
+
if len(parts) != 2:
|
|
308
|
+
raise ValueError(f"Invalid size format: {size}")
|
|
309
|
+
w = int(parts[0].strip())
|
|
310
|
+
h = int(parts[1].strip())
|
|
311
|
+
if w <= 0 or h <= 0:
|
|
312
|
+
raise ValueError(f"Invalid size values: {size}")
|
|
313
|
+
return w, h
|
|
314
|
+
|
|
315
|
+
@staticmethod
|
|
316
|
+
def _resolve_size(aspect_ratio: str, resolution: str, model_id: str) -> str:
|
|
317
|
+
"""
|
|
318
|
+
Map app-level aspect ratio and resolution to Sora size enumerations.
|
|
319
|
+
Supported sizes:
|
|
320
|
+
- sora-2: 1280x720, 720x1280
|
|
321
|
+
- sora-2-pro: 1280x720, 720x1280, 1024x1792, 1792x1024
|
|
322
|
+
|
|
323
|
+
Fallback to 1280x720 or 720x1280 depending on orientation.
|
|
324
|
+
"""
|
|
325
|
+
ar = (aspect_ratio or "16:9").strip().lower()
|
|
326
|
+
res = (resolution or "720p").lower().strip()
|
|
327
|
+
model = (model_id or "").lower()
|
|
328
|
+
portrait = ar in ("9:16", "9x16", "portrait")
|
|
329
|
+
|
|
330
|
+
if "sora-2-pro" in model:
|
|
331
|
+
if "1024" in res or "1792" in res or "hd" in res:
|
|
332
|
+
return "1024x1792" if portrait else "1792x1024"
|
|
333
|
+
|
|
334
|
+
return "720x1280" if portrait else "1280x720"
|
|
335
|
+
|
|
336
|
+
def _first_image_attachment(self, attachments: Dict[str, Any]) -> Optional[str]:
|
|
337
|
+
"""Return path of the first image attachment, if any."""
|
|
338
|
+
for _, att in (attachments or {}).items():
|
|
339
|
+
try:
|
|
340
|
+
p = getattr(att, "path", None)
|
|
341
|
+
if p and os.path.exists(p):
|
|
342
|
+
mt, _ = mimetypes.guess_type(p)
|
|
343
|
+
if mt and mt.startswith("image/"):
|
|
344
|
+
return p
|
|
345
|
+
except Exception:
|
|
346
|
+
continue
|
|
347
|
+
return None
|
|
348
|
+
|
|
349
|
+
def _prepare_input_reference(self, image_path: str, size: str) -> Optional[Tuple[str, bytes, str]]:
|
|
350
|
+
"""
|
|
351
|
+
Ensure the input image matches required WxH and mime. Returns a (filename, bytes, mime) tuple.
|
|
352
|
+
- Converts on the fly using Pillow if dimensions do not match.
|
|
353
|
+
- Strips EXIF metadata and normalizes orientation.
|
|
354
|
+
- Fit mode configurable via `video.image_to_video.fit`: crop | pad | stretch (default crop).
|
|
355
|
+
"""
|
|
356
|
+
try:
|
|
357
|
+
w, h = self._parse_size(size)
|
|
358
|
+
except Exception:
|
|
359
|
+
self.signals.status.emit(f"Invalid target size: {size}, using default 1280x720")
|
|
360
|
+
w, h = 1280, 720
|
|
361
|
+
|
|
362
|
+
# Try Pillow import lazily
|
|
363
|
+
try:
|
|
364
|
+
from PIL import Image, ImageOps
|
|
365
|
+
except Exception:
|
|
366
|
+
# Fallback: pass original file; may still fail if size mismatches.
|
|
367
|
+
try:
|
|
368
|
+
with open(image_path, "rb") as f:
|
|
369
|
+
data = f.read()
|
|
370
|
+
mime = (mimetypes.guess_type(image_path)[0] or "image/jpeg")
|
|
371
|
+
if mime not in self.ALLOWED_MIME:
|
|
372
|
+
mime = "image/jpeg"
|
|
373
|
+
filename = os.path.basename(image_path)
|
|
374
|
+
return filename, data, mime
|
|
375
|
+
except Exception:
|
|
376
|
+
return None
|
|
377
|
+
|
|
378
|
+
try:
|
|
379
|
+
img = Image.open(image_path)
|
|
380
|
+
img = ImageOps.exif_transpose(img) # honor EXIF orientation
|
|
381
|
+
|
|
382
|
+
# If already exact size and mime allowed -> pass-through with re-encode to strip EXIF
|
|
383
|
+
if img.width == w and img.height == h:
|
|
384
|
+
buf = io.BytesIO()
|
|
385
|
+
# choose output mime based on original extension but restrict to allowed set
|
|
386
|
+
target_mime = (mimetypes.guess_type(image_path)[0] or "image/jpeg")
|
|
387
|
+
if target_mime not in self.ALLOWED_MIME:
|
|
388
|
+
target_mime = "image/jpeg"
|
|
389
|
+
|
|
390
|
+
fmt = "WEBP" if target_mime == "image/webp" else "PNG" if target_mime == "image/png" else "JPEG"
|
|
391
|
+
if fmt == "JPEG" and img.mode not in ("RGB", "L"):
|
|
392
|
+
img = img.convert("RGB")
|
|
393
|
+
save_kwargs = {"optimize": True}
|
|
394
|
+
if fmt == "JPEG":
|
|
395
|
+
save_kwargs.update({"quality": 95})
|
|
396
|
+
img.save(buf, format=fmt, **save_kwargs)
|
|
397
|
+
data = buf.getvalue()
|
|
398
|
+
buf.close()
|
|
399
|
+
filename = os.path.basename(image_path) if fmt != "JPEG" else "input.jpg"
|
|
400
|
+
return filename, data, target_mime
|
|
401
|
+
|
|
402
|
+
fit_mode = str(self.window.core.config.get('video.image_to_video.fit') or "crop").lower()
|
|
403
|
+
if fit_mode not in ("crop", "pad", "stretch"):
|
|
404
|
+
fit_mode = "crop"
|
|
405
|
+
|
|
406
|
+
# Convert to RGB for JPEG output by default
|
|
407
|
+
if img.mode not in ("RGB", "L"):
|
|
408
|
+
img = img.convert("RGB")
|
|
409
|
+
|
|
410
|
+
if fit_mode == "stretch":
|
|
411
|
+
resized = img.resize((w, h), Image.Resampling.LANCZOS)
|
|
412
|
+
elif fit_mode == "pad":
|
|
413
|
+
scale = min(w / img.width, h / img.height)
|
|
414
|
+
new_w = max(1, int(img.width * scale))
|
|
415
|
+
new_h = max(1, int(img.height * scale))
|
|
416
|
+
tmp = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
|
|
417
|
+
canvas = Image.new("RGB", (w, h), color=(0, 0, 0))
|
|
418
|
+
left = (w - new_w) // 2
|
|
419
|
+
top = (h - new_h) // 2
|
|
420
|
+
canvas.paste(tmp, (left, top))
|
|
421
|
+
resized = canvas
|
|
422
|
+
else: # crop (fill then center-crop)
|
|
423
|
+
scale = max(w / img.width, h / img.height)
|
|
424
|
+
new_w = max(1, int(img.width * scale))
|
|
425
|
+
new_h = max(1, int(img.height * scale))
|
|
426
|
+
tmp = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
|
|
427
|
+
left = max(0, (new_w - w) // 2)
|
|
428
|
+
top = max(0, (new_h - h) // 2)
|
|
429
|
+
resized = tmp.crop((left, top, left + w, top + h))
|
|
430
|
+
|
|
431
|
+
# Encode as JPEG (safe default per API)
|
|
432
|
+
buf = io.BytesIO()
|
|
433
|
+
resized.save(buf, format="JPEG", quality=95, optimize=True)
|
|
434
|
+
data = buf.getvalue()
|
|
435
|
+
buf.close()
|
|
436
|
+
|
|
437
|
+
self.signals.status.emit(f"Auto-resized input image to {w}x{h} (mode={fit_mode}).")
|
|
438
|
+
return "input.jpg", data, "image/jpeg"
|
|
439
|
+
|
|
440
|
+
except Exception:
|
|
441
|
+
# As a last resort, send original image
|
|
442
|
+
try:
|
|
443
|
+
with open(image_path, "rb") as f:
|
|
444
|
+
data = f.read()
|
|
445
|
+
mime = (mimetypes.guess_type(image_path)[0] or "image/jpeg")
|
|
446
|
+
if mime not in self.ALLOWED_MIME:
|
|
447
|
+
mime = "image/jpeg"
|
|
448
|
+
filename = os.path.basename(image_path)
|
|
449
|
+
self.signals.status.emit("Image preprocessing failed; sending original file (may fail if size/policy mismatch).")
|
|
450
|
+
return filename, data, mime
|
|
451
|
+
except Exception:
|
|
452
|
+
return None
|
|
453
|
+
|
|
454
|
+
def _save(self, idx: int, data: Optional[bytes]) -> Optional[str]:
|
|
455
|
+
"""Save video bytes to file and return path."""
|
|
456
|
+
if not data:
|
|
457
|
+
return None
|
|
458
|
+
name = (
|
|
459
|
+
datetime.date.today().strftime("%Y-%m-%d") + "_" +
|
|
460
|
+
datetime.datetime.now().strftime("%H-%M-%S") + "-" +
|
|
461
|
+
self.window.core.video.make_safe_filename(self.input_prompt) + "-" +
|
|
462
|
+
str(idx + 1) + ".mp4"
|
|
463
|
+
)
|
|
464
|
+
path = os.path.join(self.window.core.config.get_user_dir("video"), name)
|
|
465
|
+
self.signals.status.emit(trans('vid.status.downloading') + f" ({idx + 1} / 1) -> {path}")
|
|
466
|
+
|
|
467
|
+
if self.window.core.video.save_video(path, data):
|
|
468
|
+
return str(path)
|
|
469
|
+
|
|
470
|
+
try:
|
|
471
|
+
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
472
|
+
with open(path, "wb") as f:
|
|
473
|
+
f.write(data)
|
|
474
|
+
return str(path)
|
|
475
|
+
except Exception:
|
|
476
|
+
return None
|
|
477
|
+
|
|
478
|
+
def _cleanup(self):
|
|
479
|
+
"""Cleanup resources."""
|
|
480
|
+
sig = self.signals
|
|
481
|
+
self.signals = None
|
|
482
|
+
if sig is not None:
|
|
483
|
+
try:
|
|
484
|
+
sig.deleteLater()
|
|
485
|
+
except RuntimeError:
|
|
486
|
+
pass
|
|
487
|
+
|
|
488
|
+
# ---------- error utilities ----------
|
|
489
|
+
|
|
490
|
+
@staticmethod
|
|
491
|
+
def _safe_get(obj: Any, key: str, default=None):
|
|
492
|
+
"""
|
|
493
|
+
Access attribute or dict key safely.
|
|
494
|
+
"""
|
|
495
|
+
try:
|
|
496
|
+
if isinstance(obj, dict):
|
|
497
|
+
return obj.get(key, default)
|
|
498
|
+
if hasattr(obj, key):
|
|
499
|
+
return getattr(obj, key)
|
|
500
|
+
except Exception:
|
|
501
|
+
return default
|
|
502
|
+
return default
|
|
503
|
+
|
|
504
|
+
def _format_job_error(self, job: Any) -> str:
|
|
505
|
+
"""
|
|
506
|
+
Build a human-readable error message from the video job.
|
|
507
|
+
Includes error.message/type/code and common policy hints.
|
|
508
|
+
"""
|
|
509
|
+
# Extract error payload
|
|
510
|
+
err = self._safe_get(job, "error")
|
|
511
|
+
details: Dict[str, Any] = {}
|
|
512
|
+
message = ""
|
|
513
|
+
code = ""
|
|
514
|
+
etype = ""
|
|
515
|
+
|
|
516
|
+
# Normalize to dict
|
|
517
|
+
if err is not None:
|
|
518
|
+
try:
|
|
519
|
+
if isinstance(err, dict):
|
|
520
|
+
details = err
|
|
521
|
+
elif hasattr(err, "model_dump"):
|
|
522
|
+
details = err.model_dump() # pydantic v2
|
|
523
|
+
elif hasattr(err, "to_dict"):
|
|
524
|
+
details = err.to_dict()
|
|
525
|
+
else:
|
|
526
|
+
# last resort string
|
|
527
|
+
message = str(err)
|
|
528
|
+
except Exception:
|
|
529
|
+
message = str(err)
|
|
530
|
+
|
|
531
|
+
message = details.get("message", message) if isinstance(details, dict) else message
|
|
532
|
+
code = details.get("code", code) if isinstance(details, dict) else code
|
|
533
|
+
etype = details.get("type", etype) if isinstance(details, dict) else etype
|
|
534
|
+
|
|
535
|
+
# Policy-related hints
|
|
536
|
+
hint = ""
|
|
537
|
+
lower = (message or "").lower()
|
|
538
|
+
if any(x in lower for x in ("public figure", "likeness", "real person", "people", "face", "biometric")):
|
|
539
|
+
hint = "Policy: input images with real people or public figures are blocked. Use a non-real/persona or generated image."
|
|
540
|
+
elif any(x in lower for x in ("copyright", "trademark", "brand", "ip infringement")):
|
|
541
|
+
hint = "Policy: potential IP restriction detected. Avoid brand logos or copyrighted assets."
|
|
542
|
+
elif "inpaint" in lower and "width and height" in lower:
|
|
543
|
+
hint = "The reference image must exactly match the requested size (size=WxH). The app auto-resizes now; verify your aspect and chosen model allow this size."
|
|
544
|
+
|
|
545
|
+
parts = []
|
|
546
|
+
if message:
|
|
547
|
+
parts.append(f"reason: {message}")
|
|
548
|
+
if etype:
|
|
549
|
+
parts.append(f"type: {etype}")
|
|
550
|
+
if code:
|
|
551
|
+
parts.append(f"code: {code}")
|
|
552
|
+
if hint:
|
|
553
|
+
parts.append(hint)
|
|
554
|
+
|
|
555
|
+
# Include minimal structured fallback if nothing above was present
|
|
556
|
+
if not parts and isinstance(details, dict) and details:
|
|
557
|
+
try:
|
|
558
|
+
parts.append(json.dumps(details, ensure_ascii=False))
|
|
559
|
+
except Exception:
|
|
560
|
+
pass
|
|
561
|
+
|
|
562
|
+
return " ".join(parts).strip()
|
|
@@ -176,6 +176,13 @@ class Patch:
|
|
|
176
176
|
patch_css('style.dark.css', True)
|
|
177
177
|
updated = True
|
|
178
178
|
|
|
179
|
+
# < 2.6.66
|
|
180
|
+
if old < parse_version("2.6.66"):
|
|
181
|
+
print("Migrating config from < 2.6.66...")
|
|
182
|
+
if "img_mode" not in data:
|
|
183
|
+
data["img_mode"] = "image"
|
|
184
|
+
updated = True
|
|
185
|
+
|
|
179
186
|
# update file
|
|
180
187
|
migrated = False
|
|
181
188
|
if updated:
|
|
@@ -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.
|
|
9
|
+
# Updated Date: 2025.12.25 20:00:00 #
|
|
10
10
|
# ================================================== #
|
|
11
11
|
|
|
12
12
|
from packaging.version import parse as parse_version, Version
|
|
@@ -52,11 +52,37 @@ class Patch:
|
|
|
52
52
|
data, updated = patcher.execute(version)
|
|
53
53
|
# --------------------------------------------
|
|
54
54
|
|
|
55
|
-
#
|
|
56
|
-
|
|
55
|
+
# < 2.6.66 <--- add models
|
|
56
|
+
if old < parse_version("2.6.66"):
|
|
57
|
+
print("Migrating models from < 2.6.66...")
|
|
58
|
+
models_to_add = [
|
|
59
|
+
"claude-opus-4-5",
|
|
60
|
+
"claude-sonnet-4-5",
|
|
61
|
+
"gemini-3-flash-preview",
|
|
62
|
+
"gemini-3-pro-image-preview",
|
|
63
|
+
"gemini-3-pro-preview",
|
|
64
|
+
"gpt-5.2-low",
|
|
65
|
+
"gpt-5.2-medium",
|
|
66
|
+
"gpt-5.2-high",
|
|
67
|
+
"gpt-image-1.5",
|
|
68
|
+
"nano-banana-pro-preview",
|
|
69
|
+
"sora-2",
|
|
70
|
+
"veo-3.1-fast-generate-preview",
|
|
71
|
+
"veo-3.1-generate-preview"
|
|
72
|
+
]
|
|
73
|
+
for model in models_to_add:
|
|
74
|
+
if model not in data:
|
|
75
|
+
base_model = from_base(model)
|
|
76
|
+
if base_model:
|
|
77
|
+
data[model] = base_model
|
|
78
|
+
updated = True
|
|
57
79
|
|
|
58
80
|
# update file
|
|
59
81
|
if updated:
|
|
82
|
+
# fix empty/broken data
|
|
83
|
+
for key in list(data.keys()):
|
|
84
|
+
if not data[key]:
|
|
85
|
+
del data[key]
|
|
60
86
|
data = dict(sorted(data.items()))
|
|
61
87
|
self.window.core.models.items = data
|
|
62
88
|
self.window.core.models.save()
|