codex-image-generation 0.3.0__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.
- codex_image_generation/__init__.py +10 -0
- codex_image_generation/__main__.py +6 -0
- codex_image_generation/cli.py +73 -0
- codex_image_generation/config.py +395 -0
- codex_image_generation/errors.py +5 -0
- codex_image_generation/generator.py +627 -0
- codex_image_generation/server.py +240 -0
- codex_image_generation/storage.py +526 -0
- codex_image_generation-0.3.0.dist-info/METADATA +185 -0
- codex_image_generation-0.3.0.dist-info/RECORD +13 -0
- codex_image_generation-0.3.0.dist-info/WHEEL +4 -0
- codex_image_generation-0.3.0.dist-info/entry_points.txt +3 -0
- codex_image_generation-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
"""Validate local image bytes and preserve each successfully saved asset."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import errno
|
|
6
|
+
import hashlib
|
|
7
|
+
import io
|
|
8
|
+
import os
|
|
9
|
+
import re
|
|
10
|
+
import shutil
|
|
11
|
+
import stat
|
|
12
|
+
import tempfile
|
|
13
|
+
import unicodedata
|
|
14
|
+
import warnings
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any
|
|
19
|
+
from uuid import uuid4
|
|
20
|
+
|
|
21
|
+
from PIL import Image, UnidentifiedImageError
|
|
22
|
+
|
|
23
|
+
from .errors import ImageToolError
|
|
24
|
+
|
|
25
|
+
MAX_INPUT_BYTES = 50_000_000
|
|
26
|
+
MAX_OUTPUT_BYTES = 100 * 1024 * 1024
|
|
27
|
+
FORMATS = {"png": "image/png", "jpeg": "image/jpeg", "webp": "image/webp"}
|
|
28
|
+
EXTENSIONS = {"png": ".png", "jpeg": ".jpg", "webp": ".webp"}
|
|
29
|
+
_RESERVED_NAMES = {"CON", "PRN", "AUX", "NUL", "CONIN$", "CONOUT$"}
|
|
30
|
+
_RESERVED_NAMES.update(f"{prefix}{number}" for prefix in ("COM", "LPT") for number in range(1, 10))
|
|
31
|
+
_RESERVED_NAMES.update(f"{prefix}{number}" for prefix in ("COM", "LPT") for number in "¹²³")
|
|
32
|
+
_UNSUPPORTED_LINK_ERRORS = {
|
|
33
|
+
errno.EPERM, errno.EXDEV, errno.ENOSYS,
|
|
34
|
+
getattr(errno, "ENOTSUP", errno.EPERM),
|
|
35
|
+
getattr(errno, "EOPNOTSUPP", errno.EPERM),
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class ImageInfo:
|
|
41
|
+
format: str
|
|
42
|
+
width: int
|
|
43
|
+
height: int
|
|
44
|
+
has_alpha: bool
|
|
45
|
+
has_transparency: bool = False
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def mime_type(self) -> str:
|
|
49
|
+
return FORMATS[self.format]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class PreparedImage:
|
|
54
|
+
data: bytes
|
|
55
|
+
info: ImageInfo
|
|
56
|
+
revised_prompt: str | None = None
|
|
57
|
+
call_id: str | None = None
|
|
58
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class GeneratedImage:
|
|
63
|
+
path: Path
|
|
64
|
+
data: bytes
|
|
65
|
+
mime_type: str
|
|
66
|
+
width: int
|
|
67
|
+
height: int
|
|
68
|
+
revised_prompt: str | None = None
|
|
69
|
+
call_id: str | None = None
|
|
70
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass
|
|
74
|
+
class SaveResult:
|
|
75
|
+
images: list[GeneratedImage] = field(default_factory=list)
|
|
76
|
+
errors: list[dict[str, Any]] = field(default_factory=list)
|
|
77
|
+
warnings: list[str] = field(default_factory=list)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _identity(path: Path) -> tuple[int, int] | None:
|
|
81
|
+
try:
|
|
82
|
+
info = path.lstat()
|
|
83
|
+
except FileNotFoundError:
|
|
84
|
+
return None
|
|
85
|
+
if not stat.S_ISREG(info.st_mode):
|
|
86
|
+
return None
|
|
87
|
+
return info.st_dev, info.st_ino
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _utf16_length(value: str) -> int:
|
|
91
|
+
try:
|
|
92
|
+
return len(value.encode("utf-16-le")) // 2
|
|
93
|
+
except UnicodeError:
|
|
94
|
+
raise ImageToolError("filename 包含无效 Unicode 字符。") from None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _filename_limit(directory: Path) -> int | None:
|
|
98
|
+
if os.name == "nt" or not hasattr(os, "pathconf"):
|
|
99
|
+
return None
|
|
100
|
+
try:
|
|
101
|
+
limit = os.pathconf(directory, "PC_NAME_MAX")
|
|
102
|
+
return limit if limit > 0 else None
|
|
103
|
+
except (OSError, ValueError):
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
@dataclass
|
|
108
|
+
class SavePlan:
|
|
109
|
+
cwd: Path
|
|
110
|
+
output_dir: Path
|
|
111
|
+
filename: str
|
|
112
|
+
output_format: str
|
|
113
|
+
_locks: dict[Path, str] = field(default_factory=dict, repr=False)
|
|
114
|
+
_temporary_paths: dict[Path, tuple[int, int]] = field(default_factory=dict, repr=False)
|
|
115
|
+
_lock_identities: dict[Path, tuple[int, int]] = field(default_factory=dict, repr=False)
|
|
116
|
+
_complete_locks: set[Path] = field(default_factory=set, repr=False)
|
|
117
|
+
_released: bool = field(default=False, repr=False)
|
|
118
|
+
_next_ordinal: int = field(default=1, repr=False)
|
|
119
|
+
_name_limit: int | None = field(default=None, repr=False)
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def project_dir(self) -> Path:
|
|
123
|
+
"""Compatibility alias for callers migrating from the former API."""
|
|
124
|
+
return self.cwd
|
|
125
|
+
|
|
126
|
+
def _lock_path(self, filename: str) -> Path:
|
|
127
|
+
# Locks live in the target directory, so only the stem identifies them.
|
|
128
|
+
# Conservatively share reservations on case/normalization equivalents,
|
|
129
|
+
# including macOS volumes whose rules differ from os.path.normcase().
|
|
130
|
+
canonical = unicodedata.normalize("NFC", Path(filename).stem)
|
|
131
|
+
canonical = unicodedata.normalize("NFC", canonical.casefold())
|
|
132
|
+
digest = hashlib.sha256(canonical.encode("utf-8")).hexdigest()[:32]
|
|
133
|
+
return self.output_dir / f".codex-image-{digest}.lock"
|
|
134
|
+
|
|
135
|
+
def _acquire(self, filename: str) -> Path:
|
|
136
|
+
if self._released:
|
|
137
|
+
raise ImageToolError("图片保存预留已经释放,不能继续使用。")
|
|
138
|
+
lock_path = self._lock_path(filename)
|
|
139
|
+
token = f"{os.getpid()}:{uuid4().hex}"
|
|
140
|
+
with lock_path.open("xb") as handle:
|
|
141
|
+
self._locks[lock_path] = token
|
|
142
|
+
identity = _identity(lock_path)
|
|
143
|
+
if identity is not None:
|
|
144
|
+
self._lock_identities[lock_path] = identity
|
|
145
|
+
encoded = token.encode("ascii")
|
|
146
|
+
if handle.write(encoded) != len(encoded):
|
|
147
|
+
raise OSError(errno.ENOSPC, "Incomplete lock write")
|
|
148
|
+
handle.flush()
|
|
149
|
+
self._complete_locks.add(lock_path)
|
|
150
|
+
return lock_path
|
|
151
|
+
|
|
152
|
+
def _release_lock(self, lock_path: Path) -> None:
|
|
153
|
+
token = self._locks.get(lock_path)
|
|
154
|
+
if token is None:
|
|
155
|
+
return
|
|
156
|
+
try:
|
|
157
|
+
# A replaced lock is no longer ours. Never clear another request's lock.
|
|
158
|
+
identity = _identity(lock_path)
|
|
159
|
+
if identity != self._lock_identities.get(lock_path) or (
|
|
160
|
+
lock_path in self._complete_locks and lock_path.read_bytes() != token.encode("ascii")
|
|
161
|
+
):
|
|
162
|
+
self._locks.pop(lock_path, None)
|
|
163
|
+
self._lock_identities.pop(lock_path, None)
|
|
164
|
+
self._complete_locks.discard(lock_path)
|
|
165
|
+
return
|
|
166
|
+
lock_path.unlink()
|
|
167
|
+
except FileNotFoundError:
|
|
168
|
+
pass
|
|
169
|
+
except OSError:
|
|
170
|
+
return
|
|
171
|
+
self._locks.pop(lock_path, None)
|
|
172
|
+
self._lock_identities.pop(lock_path, None)
|
|
173
|
+
self._complete_locks.discard(lock_path)
|
|
174
|
+
|
|
175
|
+
def _remember_temporary(self, path: Path) -> None:
|
|
176
|
+
identity = _identity(path)
|
|
177
|
+
if identity is not None:
|
|
178
|
+
self._temporary_paths[path] = identity
|
|
179
|
+
|
|
180
|
+
def _cleanup_temporary(self, path: Path) -> None:
|
|
181
|
+
identity = self._temporary_paths.get(path)
|
|
182
|
+
if identity is None:
|
|
183
|
+
return
|
|
184
|
+
try:
|
|
185
|
+
if _identity(path) == identity:
|
|
186
|
+
path.unlink()
|
|
187
|
+
except FileNotFoundError:
|
|
188
|
+
pass
|
|
189
|
+
except OSError:
|
|
190
|
+
return
|
|
191
|
+
self._temporary_paths.pop(path, None)
|
|
192
|
+
|
|
193
|
+
def _probe_target(self, target: Path) -> None:
|
|
194
|
+
# Check the exact destination name, including filesystem length rules.
|
|
195
|
+
# The cooperating request lock remains held after this empty probe is removed.
|
|
196
|
+
with target.open("xb"):
|
|
197
|
+
self._remember_temporary(target)
|
|
198
|
+
target.unlink()
|
|
199
|
+
self._temporary_paths.pop(target, None)
|
|
200
|
+
|
|
201
|
+
def _numbered_name(self, ordinal: int, actual_format: str | None = None) -> str:
|
|
202
|
+
name = Path(self.filename)
|
|
203
|
+
suffix = name.suffix
|
|
204
|
+
if actual_format is not None and normalize_format(suffix[1:]) != actual_format:
|
|
205
|
+
suffix = EXTENSIONS[actual_format]
|
|
206
|
+
if ordinal == 1 and suffix == name.suffix:
|
|
207
|
+
return self.filename
|
|
208
|
+
tail = (f"_{ordinal}" if ordinal > 1 else "") + suffix
|
|
209
|
+
stem = name.stem
|
|
210
|
+
while stem:
|
|
211
|
+
candidate = stem + tail
|
|
212
|
+
if _utf16_length(candidate) <= 255 and (
|
|
213
|
+
self._name_limit is None or len(os.fsencode(candidate)) <= self._name_limit
|
|
214
|
+
):
|
|
215
|
+
return candidate
|
|
216
|
+
stem = stem[:-1]
|
|
217
|
+
raise ImageToolError("输出文件名无法容纳图片序号,请使用更短的 filename。")
|
|
218
|
+
|
|
219
|
+
def paths(self, count: int) -> list[Path]:
|
|
220
|
+
"""Preview nominal names; actual later names may skip occupied numbers."""
|
|
221
|
+
return [self.output_dir / self._numbered_name(index + 1) for index in range(count)]
|
|
222
|
+
|
|
223
|
+
def _next_target(self, actual_format: str) -> Path:
|
|
224
|
+
if self._released:
|
|
225
|
+
raise ImageToolError("图片保存预留已经释放,不能继续使用。")
|
|
226
|
+
ordinal = self._next_ordinal
|
|
227
|
+
if ordinal == 1:
|
|
228
|
+
self._next_ordinal = 2
|
|
229
|
+
name = self._numbered_name(1, actual_format)
|
|
230
|
+
target = self.output_dir / name
|
|
231
|
+
if not target.exists() and not target.is_symlink():
|
|
232
|
+
try:
|
|
233
|
+
if name != self.filename:
|
|
234
|
+
self._probe_target(target)
|
|
235
|
+
return target
|
|
236
|
+
except FileExistsError:
|
|
237
|
+
if name == self.filename:
|
|
238
|
+
raise
|
|
239
|
+
elif name == self.filename:
|
|
240
|
+
raise FileExistsError(errno.EEXIST, "Reserved destination already exists")
|
|
241
|
+
# A corrected-format destination may already exist; preserve it and
|
|
242
|
+
# choose a numbered name for this valid, already generated image.
|
|
243
|
+
ordinal = 2
|
|
244
|
+
for ordinal in range(ordinal, ordinal + 10000):
|
|
245
|
+
name = self._numbered_name(ordinal, actual_format)
|
|
246
|
+
target = self.output_dir / name
|
|
247
|
+
if target.exists() or target.is_symlink():
|
|
248
|
+
continue
|
|
249
|
+
try:
|
|
250
|
+
lock_path = self._acquire(name)
|
|
251
|
+
except FileExistsError:
|
|
252
|
+
continue
|
|
253
|
+
try:
|
|
254
|
+
self._probe_target(target)
|
|
255
|
+
except FileExistsError:
|
|
256
|
+
self._release_lock(lock_path)
|
|
257
|
+
continue
|
|
258
|
+
except BaseException:
|
|
259
|
+
self._release_lock(lock_path)
|
|
260
|
+
raise
|
|
261
|
+
self._next_ordinal = ordinal + 1
|
|
262
|
+
return target
|
|
263
|
+
raise ImageToolError("无法找到可用的图片序号,请更换 filename。")
|
|
264
|
+
|
|
265
|
+
def release(self) -> list[dict[str, Any]]:
|
|
266
|
+
"""Release only this request's reservations and unfinished temporary files.
|
|
267
|
+
|
|
268
|
+
Successful images are never tracked as temporary files. Cleanup is idempotent
|
|
269
|
+
and best-effort so that a cleanup problem cannot replace a successful result.
|
|
270
|
+
Calling it again retries any cleanup that the filesystem temporarily rejected.
|
|
271
|
+
"""
|
|
272
|
+
self._released = True
|
|
273
|
+
for path in list(self._temporary_paths):
|
|
274
|
+
self._cleanup_temporary(path)
|
|
275
|
+
for path in list(self._locks):
|
|
276
|
+
self._release_lock(path)
|
|
277
|
+
if self._temporary_paths or self._locks:
|
|
278
|
+
return [{
|
|
279
|
+
"code": "cleanup_incomplete",
|
|
280
|
+
"message": "部分本请求的临时文件或预留锁无法清理,请检查输出目录;已保存图片不受影响。",
|
|
281
|
+
"call_id": None,
|
|
282
|
+
}]
|
|
283
|
+
return []
|
|
284
|
+
|
|
285
|
+
def __enter__(self) -> SavePlan:
|
|
286
|
+
return self
|
|
287
|
+
|
|
288
|
+
def __exit__(self, *args: Any) -> None:
|
|
289
|
+
self.release()
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def normalize_format(value: str) -> str:
|
|
293
|
+
if not isinstance(value, str):
|
|
294
|
+
raise ImageToolError("output_format 必须是 png、jpeg 或 webp。")
|
|
295
|
+
result = value.strip().lower()
|
|
296
|
+
if result == "jpg":
|
|
297
|
+
result = "jpeg"
|
|
298
|
+
if result not in FORMATS:
|
|
299
|
+
raise ImageToolError("只支持 PNG、JPEG/JPG 和 WebP 图片格式,不支持 SVG 或 GIF 输出。")
|
|
300
|
+
return result
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _validate_filename(filename: str) -> None:
|
|
304
|
+
if not isinstance(filename, str) or not filename or filename in {".", ".."}:
|
|
305
|
+
raise ImageToolError("filename 必须是非空文件名。")
|
|
306
|
+
if re.search(r'[<>:"/\\|?*\x00-\x1f\x7f]', filename) or filename.endswith((".", " ")):
|
|
307
|
+
raise ImageToolError("filename 只能是文件名,不能包含目录、控制字符或系统不允许的字符。")
|
|
308
|
+
# Windows also treats "NUL .png" and superscript COM/LPT digits as device names.
|
|
309
|
+
if filename.split(".", 1)[0].rstrip(" .").upper() in _RESERVED_NAMES:
|
|
310
|
+
raise ImageToolError("filename 不能使用 Windows 保留名称。")
|
|
311
|
+
if _utf16_length(filename) > 255:
|
|
312
|
+
raise ImageToolError("filename 太长,不能超过 255 个 UTF-16 单位;表情等字符通常占两个单位。")
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def resolve_format(output_format: str | None, filename: str | None) -> str:
|
|
316
|
+
inferred = None
|
|
317
|
+
if filename is not None:
|
|
318
|
+
_validate_filename(filename)
|
|
319
|
+
suffix = Path(filename).suffix
|
|
320
|
+
if suffix:
|
|
321
|
+
inferred = normalize_format(suffix[1:])
|
|
322
|
+
requested = normalize_format(output_format) if output_format is not None else None
|
|
323
|
+
if requested and inferred and requested != inferred:
|
|
324
|
+
raise ImageToolError("filename 的扩展名与 output_format 不一致,请调整其中一项。")
|
|
325
|
+
return requested or inferred or "png"
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def resolve_cwd(cwd: str) -> Path:
|
|
329
|
+
if not isinstance(cwd, str) or not cwd.strip():
|
|
330
|
+
raise ImageToolError("请由 Codex 传入当前任务的绝对路径 cwd。")
|
|
331
|
+
try:
|
|
332
|
+
directory = Path(cwd).expanduser()
|
|
333
|
+
if not directory.is_absolute():
|
|
334
|
+
raise ImageToolError("cwd 必须是当前任务的绝对路径。")
|
|
335
|
+
directory = directory.resolve(strict=True)
|
|
336
|
+
if not directory.is_dir():
|
|
337
|
+
raise ImageToolError("cwd 必须指向已有的任务目录。")
|
|
338
|
+
except (OSError, RuntimeError, ValueError):
|
|
339
|
+
raise ImageToolError("无法访问 cwd,请检查任务目录是否存在。") from None
|
|
340
|
+
return directory
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def resolve_project(project_dir: str) -> Path:
|
|
344
|
+
"""Compatibility alias; paths now refer to the current task directory."""
|
|
345
|
+
return resolve_cwd(project_dir)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def resolve_project_path(project_dir: Path, value: str, *, label: str) -> Path:
|
|
349
|
+
if not isinstance(value, str) or not value.strip():
|
|
350
|
+
raise ImageToolError(f"{label} 必须是非空路径。")
|
|
351
|
+
try:
|
|
352
|
+
path = Path(value).expanduser()
|
|
353
|
+
if path.is_absolute():
|
|
354
|
+
return path.resolve()
|
|
355
|
+
path = (project_dir / path).resolve()
|
|
356
|
+
if not path.is_relative_to(project_dir):
|
|
357
|
+
raise ImageToolError(f"相对 {label} 不能指向当前任务目录之外;请明确提供绝对路径。")
|
|
358
|
+
return path
|
|
359
|
+
except (OSError, RuntimeError, ValueError):
|
|
360
|
+
raise ImageToolError(f"{label} 不是可用的文件系统路径。") from None
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def prepare_save(
|
|
364
|
+
cwd: str,
|
|
365
|
+
output_dir: str | None = None,
|
|
366
|
+
filename: str | None = None,
|
|
367
|
+
output_format: str | None = None,
|
|
368
|
+
) -> SavePlan:
|
|
369
|
+
directory_root = resolve_cwd(cwd)
|
|
370
|
+
image_format = resolve_format(output_format, filename)
|
|
371
|
+
directory = resolve_project_path(
|
|
372
|
+
directory_root, "." if output_dir is None else output_dir, label="output_dir"
|
|
373
|
+
)
|
|
374
|
+
if filename is None:
|
|
375
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
|
376
|
+
filename = f"image_{stamp}_{uuid4().hex[:12]}{EXTENSIONS[image_format]}"
|
|
377
|
+
elif not Path(filename).suffix:
|
|
378
|
+
filename += EXTENSIONS[image_format]
|
|
379
|
+
_validate_filename(filename)
|
|
380
|
+
plan = SavePlan(directory_root, directory, filename, image_format)
|
|
381
|
+
try:
|
|
382
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
383
|
+
plan._name_limit = _filename_limit(directory)
|
|
384
|
+
target = directory / filename
|
|
385
|
+
if target.exists() or target.is_symlink():
|
|
386
|
+
raise ImageToolError("目标文件已存在,请更换 filename;工具不会覆盖已有图片。")
|
|
387
|
+
try:
|
|
388
|
+
plan._acquire(filename)
|
|
389
|
+
except FileExistsError:
|
|
390
|
+
raise ImageToolError("目标文件已由另一个生成请求预留,请等待其完成;不会删除其他请求的锁。") from None
|
|
391
|
+
plan._probe_target(target)
|
|
392
|
+
except BaseException as exc:
|
|
393
|
+
plan.release()
|
|
394
|
+
if isinstance(exc, ImageToolError):
|
|
395
|
+
raise
|
|
396
|
+
if isinstance(exc, (OSError, ValueError)):
|
|
397
|
+
raise ImageToolError("无法预留真实目标文件名,请检查文件名长度、输出路径和目录权限。") from None
|
|
398
|
+
raise
|
|
399
|
+
return plan
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def inspect_image(data: bytes, *, input_image: bool = False) -> ImageInfo:
|
|
403
|
+
limit = MAX_INPUT_BYTES if input_image else MAX_OUTPUT_BYTES
|
|
404
|
+
if not data or len(data) >= limit:
|
|
405
|
+
label = "输入图片必须小于 50 MB" if input_image else "返回图片必须小于 100 MiB"
|
|
406
|
+
raise ImageToolError(f"{label},且不能是空文件。")
|
|
407
|
+
try:
|
|
408
|
+
with warnings.catch_warnings():
|
|
409
|
+
warnings.simplefilter("error", Image.DecompressionBombWarning)
|
|
410
|
+
with Image.open(io.BytesIO(data)) as image:
|
|
411
|
+
image_format = (image.format or "").lower()
|
|
412
|
+
if image_format not in FORMATS:
|
|
413
|
+
raise ImageToolError("图片内容必须是 PNG、JPEG 或 WebP,修改扩展名不能转换格式。")
|
|
414
|
+
if getattr(image, "n_frames", 1) != 1:
|
|
415
|
+
raise ImageToolError("仅支持静态图片,请先将动画转换为单帧图片。")
|
|
416
|
+
width, height = image.size
|
|
417
|
+
has_alpha = "A" in image.getbands() or "transparency" in image.info
|
|
418
|
+
image.verify()
|
|
419
|
+
# Decode pixels as well: verify() alone does not catch every truncated JPEG.
|
|
420
|
+
with Image.open(io.BytesIO(data)) as image:
|
|
421
|
+
image.load()
|
|
422
|
+
has_transparency = (
|
|
423
|
+
has_alpha and image.convert("RGBA").getchannel("A").getextrema()[0] < 255
|
|
424
|
+
)
|
|
425
|
+
except ImageToolError:
|
|
426
|
+
raise
|
|
427
|
+
except (UnidentifiedImageError, OSError, ValueError, SyntaxError, Image.DecompressionBombError, Image.DecompressionBombWarning):
|
|
428
|
+
raise ImageToolError("图片内容损坏、无法解码或像素数量过大。") from None
|
|
429
|
+
return ImageInfo(image_format, width, height, has_alpha, has_transparency)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def read_input_image(project_dir: Path, path: str) -> tuple[bytes, ImageInfo]:
|
|
433
|
+
source = resolve_project_path(project_dir, path, label="参考图片路径")
|
|
434
|
+
try:
|
|
435
|
+
if source.stat().st_size >= MAX_INPUT_BYTES:
|
|
436
|
+
raise ImageToolError("参考图片或蒙版必须小于 50 MB。")
|
|
437
|
+
with source.open("rb") as stream:
|
|
438
|
+
data = stream.read(MAX_INPUT_BYTES)
|
|
439
|
+
except OSError:
|
|
440
|
+
raise ImageToolError("无法读取参考图片或蒙版,请检查路径及文件权限。") from None
|
|
441
|
+
return data, inspect_image(data, input_image=True)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def _write_temporary(plan: SavePlan, data: bytes) -> Path:
|
|
445
|
+
with tempfile.NamedTemporaryFile(
|
|
446
|
+
mode="wb", prefix=".codex-image-", suffix=".tmp", dir=plan.output_dir, delete=False
|
|
447
|
+
) as stream:
|
|
448
|
+
path = Path(stream.name)
|
|
449
|
+
plan._remember_temporary(path)
|
|
450
|
+
if stream.write(data) != len(data):
|
|
451
|
+
raise OSError(errno.ENOSPC, "Incomplete image write")
|
|
452
|
+
stream.flush()
|
|
453
|
+
os.fsync(stream.fileno())
|
|
454
|
+
return path
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
def _publish_temporary(plan: SavePlan, temporary: Path, target: Path) -> None:
|
|
458
|
+
if os.name == "nt":
|
|
459
|
+
# On Windows os.rename fails if the destination already exists.
|
|
460
|
+
os.rename(temporary, target)
|
|
461
|
+
plan._temporary_paths.pop(temporary, None)
|
|
462
|
+
return
|
|
463
|
+
try:
|
|
464
|
+
# Hard-link publication is atomic and never replaces an existing destination.
|
|
465
|
+
os.link(temporary, target)
|
|
466
|
+
except FileExistsError:
|
|
467
|
+
raise
|
|
468
|
+
except OSError as exc:
|
|
469
|
+
if exc.errno not in _UNSUPPORTED_LINK_ERRORS:
|
|
470
|
+
raise
|
|
471
|
+
# Filesystems without hard links still get a complete staged image first,
|
|
472
|
+
# followed by an exclusive target write, with partial targets cleaned up.
|
|
473
|
+
with target.open("xb") as output:
|
|
474
|
+
plan._remember_temporary(target)
|
|
475
|
+
with temporary.open("rb") as source:
|
|
476
|
+
shutil.copyfileobj(source, output)
|
|
477
|
+
output.flush()
|
|
478
|
+
os.fsync(output.fileno())
|
|
479
|
+
plan._temporary_paths.pop(target, None)
|
|
480
|
+
plan._cleanup_temporary(temporary)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def save_images(plan: SavePlan, images: list[PreparedImage]) -> SaveResult:
|
|
484
|
+
"""Save independent completed items; later failures never remove earlier assets."""
|
|
485
|
+
if plan._released:
|
|
486
|
+
raise ImageToolError("图片保存预留已经释放,不能继续使用。")
|
|
487
|
+
result = SaveResult()
|
|
488
|
+
for index, image in enumerate(images):
|
|
489
|
+
if image.info.format not in FORMATS:
|
|
490
|
+
result.errors.append({
|
|
491
|
+
"code": "unsupported_format",
|
|
492
|
+
"message": "返回图片不是可保存的 PNG、JPEG 或 WebP 格式,该图片未保存。",
|
|
493
|
+
"call_id": image.call_id,
|
|
494
|
+
"index": index,
|
|
495
|
+
})
|
|
496
|
+
continue
|
|
497
|
+
try:
|
|
498
|
+
metadata = dict(image.metadata)
|
|
499
|
+
target = plan._next_target(image.info.format)
|
|
500
|
+
temporary = _write_temporary(plan, image.data)
|
|
501
|
+
_publish_temporary(plan, temporary, target)
|
|
502
|
+
except (OSError, ImageToolError, ValueError, TypeError) as exc:
|
|
503
|
+
# Failed writes can leave owned temporary/partial files, but successful
|
|
504
|
+
# final assets were removed from that tracking set before this point.
|
|
505
|
+
for path in list(plan._temporary_paths):
|
|
506
|
+
plan._cleanup_temporary(path)
|
|
507
|
+
result.errors.append({
|
|
508
|
+
"code": "target_exists" if isinstance(exc, FileExistsError) else "save_failed",
|
|
509
|
+
"message": (
|
|
510
|
+
"目标文件已存在,该图片未保存,原文件未覆盖。"
|
|
511
|
+
if isinstance(exc, FileExistsError)
|
|
512
|
+
else "该图片保存失败,请检查输出目录及可用空间;其他已保存图片仍保留。"
|
|
513
|
+
),
|
|
514
|
+
"call_id": image.call_id,
|
|
515
|
+
"index": index,
|
|
516
|
+
})
|
|
517
|
+
continue
|
|
518
|
+
if image.info.format != plan.output_format:
|
|
519
|
+
result.warnings.append(
|
|
520
|
+
f"第 {index + 1} 张图片的实际格式为 {image.info.format},与请求的 {plan.output_format} 不同;已按真实格式另存为 {target.name}。"
|
|
521
|
+
)
|
|
522
|
+
result.images.append(GeneratedImage(
|
|
523
|
+
target, image.data, image.info.mime_type, image.info.width, image.info.height,
|
|
524
|
+
image.revised_prompt, image.call_id, metadata,
|
|
525
|
+
))
|
|
526
|
+
return result
|