img-cli 0.1.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.
img_cli/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """img-cli: CLI tool for OpenAI-compatible Image Generation and Editing."""
2
+
3
+ try:
4
+ from img_cli._version import __version__, __version_tuple__
5
+ except ImportError:
6
+ try:
7
+ from importlib.metadata import version
8
+
9
+ __version__ = version("img-cli")
10
+ __version_tuple__ = (0, 0, 0)
11
+ except Exception:
12
+ __version__ = "0.0.0.dev0"
13
+ __version_tuple__ = (0, 0, 0, "dev0")
img_cli/_version.py ADDED
@@ -0,0 +1,24 @@
1
+ # file generated by vcs-versioning
2
+ # don't change, don't track in version control
3
+ from __future__ import annotations
4
+
5
+ __all__ = [
6
+ "__version__",
7
+ "__version_tuple__",
8
+ "version",
9
+ "version_tuple",
10
+ "__commit_id__",
11
+ "commit_id",
12
+ ]
13
+
14
+ version: str
15
+ __version__: str
16
+ __version_tuple__: tuple[int | str, ...]
17
+ version_tuple: tuple[int | str, ...]
18
+ commit_id: str | None
19
+ __commit_id__: str | None
20
+
21
+ __version__ = version = '0.1.0'
22
+ __version_tuple__ = version_tuple = (0, 1, 0)
23
+
24
+ __commit_id__ = commit_id = None
img_cli/cli.py ADDED
@@ -0,0 +1,264 @@
1
+ """Command line interface for img-cli."""
2
+
3
+ import sys
4
+ import time
5
+
6
+ import click
7
+ from rich.console import Console
8
+
9
+ from img_cli import __version__
10
+ from img_cli.client import ImageClient
11
+ from img_cli.config import AuthError, load_auth_config
12
+ from img_cli.image_utils import save_images
13
+ from img_cli.logger import create_audit_record, log_audit
14
+
15
+ console = Console()
16
+ err_console = Console(stderr=True)
17
+
18
+
19
+ @click.group(context_settings={"help_option_names": ["-h", "--help"]})
20
+ @click.version_option(version=__version__, message="img-cli %(version)s")
21
+ def main():
22
+ """img-cli: 通过 OpenAI Images API 生成或编辑图片。"""
23
+
24
+
25
+ @main.command(name="generate", help="根据文字描述生成图片。")
26
+ @click.argument("prompt", type=str)
27
+ @click.option(
28
+ "-m",
29
+ "--model",
30
+ default="gpt-image-2",
31
+ show_default=True,
32
+ help="模型名称(如 gpt-image-2, dall-e-3)",
33
+ )
34
+ @click.option(
35
+ "-s",
36
+ "--size",
37
+ default="1024x1024",
38
+ show_default=True,
39
+ help="图片尺寸(如 1024x1024, 1536x1024, 1024x1536, auto)",
40
+ )
41
+ @click.option(
42
+ "-q", "--quality", default=None, help="质量设置(low/medium/high/auto 或 standard/hd)"
43
+ )
44
+ @click.option("-n", "--n", default=1, type=int, show_default=True, help="生成数量")
45
+ @click.option("-o", "--output", default=".", show_default=True, help="输出目录")
46
+ @click.option(
47
+ "--format",
48
+ "format_",
49
+ default="png",
50
+ show_default=True,
51
+ type=click.Choice(["png", "jpeg", "webp"], case_sensitive=False),
52
+ help="输出格式",
53
+ )
54
+ def generate(
55
+ prompt: str,
56
+ model: str,
57
+ size: str,
58
+ quality: str | None,
59
+ n: int,
60
+ output: str,
61
+ format_: str,
62
+ ):
63
+ """根据文字描述生成图片。"""
64
+ start_time = time.time()
65
+ saved_files: list[str] = []
66
+ error_msg: str | None = None
67
+ revised_prompt: str | None = None
68
+
69
+ try:
70
+ auth = load_auth_config()
71
+ except AuthError as e:
72
+ err_console.print(f"[bold red]{e}[/bold red]")
73
+ sys.exit(1)
74
+
75
+ try:
76
+ console.print(f"[cyan]正在请求模型 [bold]{model}[/bold] 生成图片...[/cyan]")
77
+ client = ImageClient(auth)
78
+ result = client.generate(
79
+ prompt=prompt,
80
+ model=model,
81
+ size=size,
82
+ quality=quality,
83
+ n=n,
84
+ )
85
+ revised_prompt = result.revised_prompt
86
+
87
+ # Save images
88
+ saved_paths = save_images(result.images_bytes, output_dir=output, format=format_)
89
+ saved_files = [str(p) for p in saved_paths]
90
+
91
+ duration = time.time() - start_time
92
+
93
+ # Print revised prompt if available
94
+ if revised_prompt:
95
+ console.print("\n[bold yellow]优化后的 Prompt (revised_prompt):[/bold yellow]")
96
+ console.print(f"[dim]{revised_prompt}[/dim]")
97
+
98
+ # Print success info
99
+ console.print(f"\n[bold green]✓ 成功生成 {len(saved_files)} 张图片:[/bold green]")
100
+ for f in saved_files:
101
+ console.print(f" - [underline]{f}[/underline]")
102
+ console.print(f"[dim]耗时: {duration:.2f} 秒[/dim]")
103
+
104
+ # Log success audit
105
+ audit = create_audit_record(
106
+ command="generate",
107
+ prompt=prompt,
108
+ model=model,
109
+ duration_seconds=duration,
110
+ status="success",
111
+ size=size,
112
+ quality=quality,
113
+ n=n,
114
+ output_dir=output,
115
+ files=saved_files,
116
+ revised_prompt=revised_prompt,
117
+ )
118
+ log_audit(audit)
119
+
120
+ except Exception as e:
121
+ duration = time.time() - start_time
122
+ error_msg = str(e)
123
+ err_console.print(f"[bold red]生成图片失败:[/bold red] {error_msg}")
124
+
125
+ # Log error audit
126
+ audit = create_audit_record(
127
+ command="generate",
128
+ prompt=prompt,
129
+ model=model,
130
+ duration_seconds=duration,
131
+ status="error",
132
+ size=size,
133
+ quality=quality,
134
+ n=n,
135
+ output_dir=output,
136
+ error=error_msg,
137
+ )
138
+ log_audit(audit)
139
+ sys.exit(1)
140
+
141
+
142
+ @main.command(name="edit", help="根据文字描述修改已有图片。支持本地文件路径和网络图片 URL。")
143
+ @click.argument("prompt", type=str)
144
+ @click.option("-i", "--image", "image_path", required=True, help="原图路径或 HTTP URL")
145
+ @click.option("--mask", default=None, help="蒙版图路径或 HTTP URL(PNG,透明区域为待编辑区域)")
146
+ @click.option(
147
+ "-m",
148
+ "--model",
149
+ default="gpt-image-2",
150
+ show_default=True,
151
+ help="模型名称(如 gpt-image-2, dall-e-2)",
152
+ )
153
+ @click.option("-s", "--size", default="auto", show_default=True, help="尺寸(如 auto, 1024x1024)")
154
+ @click.option(
155
+ "-q", "--quality", default="auto", show_default=True, help="质量(如 auto, low, medium, high)"
156
+ )
157
+ @click.option("-n", "--n", default=1, type=int, show_default=True, help="生成数量")
158
+ @click.option("-o", "--output", default=".", show_default=True, help="输出目录")
159
+ @click.option(
160
+ "--format",
161
+ "format_",
162
+ default="png",
163
+ show_default=True,
164
+ type=click.Choice(["png", "jpeg", "webp"], case_sensitive=False),
165
+ help="输出格式",
166
+ )
167
+ def edit(
168
+ prompt: str,
169
+ image_path: str,
170
+ mask: str | None,
171
+ model: str,
172
+ size: str,
173
+ quality: str,
174
+ n: int,
175
+ output: str,
176
+ format_: str,
177
+ ):
178
+ """根据文字描述修改已有图片。"""
179
+ start_time = time.time()
180
+ saved_files: list[str] = []
181
+ error_msg: str | None = None
182
+ revised_prompt: str | None = None
183
+
184
+ try:
185
+ auth = load_auth_config()
186
+ except AuthError as e:
187
+ err_console.print(f"[bold red]{e}[/bold red]")
188
+ sys.exit(1)
189
+
190
+ try:
191
+ console.print(f"[cyan]正在使用模型 [bold]{model}[/bold] 编辑图片...[/cyan]")
192
+ client = ImageClient(auth)
193
+ result = client.edit(
194
+ prompt=prompt,
195
+ image_source=image_path,
196
+ mask_source=mask,
197
+ model=model,
198
+ size=size,
199
+ quality=quality,
200
+ n=n,
201
+ )
202
+ revised_prompt = result.revised_prompt
203
+
204
+ # Save images
205
+ saved_paths = save_images(result.images_bytes, output_dir=output, format=format_)
206
+ saved_files = [str(p) for p in saved_paths]
207
+
208
+ duration = time.time() - start_time
209
+
210
+ # Print revised prompt if available
211
+ if revised_prompt:
212
+ console.print("\n[bold yellow]优化后的 Prompt (revised_prompt):[/bold yellow]")
213
+ console.print(f"[dim]{revised_prompt}[/dim]")
214
+
215
+ # Print success info
216
+ console.print(f"\n[bold green]✓ 成功编辑并保存 {len(saved_files)} 张图片:[/bold green]")
217
+ for f in saved_files:
218
+ console.print(f" - [underline]{f}[/underline]")
219
+ console.print(f"[dim]耗时: {duration:.2f} 秒[/dim]")
220
+
221
+ # Log success audit
222
+ audit = create_audit_record(
223
+ command="edit",
224
+ prompt=prompt,
225
+ model=model,
226
+ duration_seconds=duration,
227
+ status="success",
228
+ size=size,
229
+ quality=quality,
230
+ n=n,
231
+ output_dir=output,
232
+ input_image=image_path,
233
+ mask=mask,
234
+ files=saved_files,
235
+ revised_prompt=revised_prompt,
236
+ )
237
+ log_audit(audit)
238
+
239
+ except Exception as e:
240
+ duration = time.time() - start_time
241
+ error_msg = str(e)
242
+ err_console.print(f"[bold red]编辑图片失败:[/bold red] {error_msg}")
243
+
244
+ # Log error audit
245
+ audit = create_audit_record(
246
+ command="edit",
247
+ prompt=prompt,
248
+ model=model,
249
+ duration_seconds=duration,
250
+ status="error",
251
+ size=size,
252
+ quality=quality,
253
+ n=n,
254
+ output_dir=output,
255
+ input_image=image_path,
256
+ mask=mask,
257
+ error=error_msg,
258
+ )
259
+ log_audit(audit)
260
+ sys.exit(1)
261
+
262
+
263
+ if __name__ == "__main__":
264
+ main()
img_cli/client.py ADDED
@@ -0,0 +1,189 @@
1
+ """OpenAI Images API client wrapper for generate and edit operations."""
2
+
3
+ import base64
4
+ import time
5
+ from dataclasses import dataclass
6
+
7
+ from openai import OpenAI
8
+
9
+ from img_cli.config import AuthConfig
10
+ from img_cli.image_utils import ensure_png_bytes, fetch_image_data
11
+
12
+
13
+ @dataclass
14
+ class GenerationResult:
15
+ images_bytes: list[bytes]
16
+ revised_prompt: str | None = None
17
+ duration_seconds: float = 0.0
18
+
19
+
20
+ class ImageClient:
21
+ """Wrapper around OpenAI Images API."""
22
+
23
+ def __init__(self, auth: AuthConfig, timeout: float = 300.0):
24
+ self.auth = auth
25
+ self.client = OpenAI(
26
+ api_key=auth.api_key,
27
+ base_url=auth.api_base,
28
+ timeout=timeout,
29
+ )
30
+
31
+ def _extract_images(self, response_data: list) -> tuple[list[bytes], str | None]:
32
+ """Extract raw image bytes and revised_prompt from API response data."""
33
+ images_bytes: list[bytes] = []
34
+ revised_prompt: str | None = None
35
+
36
+ for item in response_data:
37
+ if hasattr(item, "revised_prompt") and item.revised_prompt:
38
+ revised_prompt = item.revised_prompt
39
+
40
+ if getattr(item, "b64_json", None):
41
+ images_bytes.append(base64.b64decode(item.b64_json))
42
+ elif getattr(item, "url", None):
43
+ # Download image from URL
44
+ data = fetch_image_data(item.url)
45
+ images_bytes.append(data)
46
+ else:
47
+ raise ValueError("API 响应中未包含有效的 b64_json 或 url 数据")
48
+
49
+ return images_bytes, revised_prompt
50
+
51
+ def generate(
52
+ self,
53
+ prompt: str,
54
+ model: str = "gpt-image-2",
55
+ size: str | None = "1024x1024",
56
+ quality: str | None = None,
57
+ n: int = 1,
58
+ response_format: str = "b64_json",
59
+ ) -> GenerationResult:
60
+ """Generate images from prompt.
61
+
62
+ Args:
63
+ prompt: Text description of the image.
64
+ model: Model name (gpt-image-2, dall-e-3, etc.).
65
+ size: Image dimensions (e.g. 1024x1024, auto).
66
+ quality: Quality setting (low/medium/high/auto or standard/hd).
67
+ n: Number of images to generate.
68
+ response_format: 'b64_json' or 'url'.
69
+ """
70
+ start_time = time.time()
71
+ kwargs: dict = {
72
+ "model": model,
73
+ "prompt": prompt,
74
+ "n": n,
75
+ }
76
+
77
+ # Size handling
78
+ if size and size.lower() != "auto":
79
+ kwargs["size"] = size
80
+ elif model.startswith("gpt-image"):
81
+ # gpt-image models support 'auto' size
82
+ kwargs["size"] = size or "1024x1024"
83
+ elif size and size.lower() == "auto":
84
+ # For dall-e models, 'auto' is not accepted, fallback to 1024x1024
85
+ kwargs["size"] = "1024x1024"
86
+
87
+ # Quality handling
88
+ if quality:
89
+ if model == "dall-e-3":
90
+ # dall-e-3 only accepts standard or hd
91
+ if quality.lower() in ("hd", "high"):
92
+ kwargs["quality"] = "hd"
93
+ else:
94
+ kwargs["quality"] = "standard"
95
+ elif model == "dall-e-2":
96
+ # dall-e-2 does not support quality
97
+ pass
98
+ else:
99
+ kwargs["quality"] = quality
100
+
101
+ # Response format
102
+ if response_format:
103
+ kwargs["response_format"] = response_format
104
+
105
+ # For dall-e-3, n must be 1
106
+ if model == "dall-e-3":
107
+ kwargs["n"] = 1
108
+
109
+ response = self.client.images.generate(**kwargs)
110
+ duration = time.time() - start_time
111
+
112
+ images_bytes, revised_prompt = self._extract_images(response.data)
113
+ return GenerationResult(
114
+ images_bytes=images_bytes,
115
+ revised_prompt=revised_prompt,
116
+ duration_seconds=duration,
117
+ )
118
+
119
+ def edit(
120
+ self,
121
+ prompt: str,
122
+ image_source: str,
123
+ mask_source: str | None = None,
124
+ model: str = "gpt-image-2",
125
+ size: str | None = "auto",
126
+ quality: str | None = "auto",
127
+ n: int = 1,
128
+ response_format: str = "b64_json",
129
+ ) -> GenerationResult:
130
+ """Edit an existing image using text prompt and optional mask.
131
+
132
+ Args:
133
+ prompt: Text description of the modification.
134
+ image_source: Local path or HTTP URL of the input image.
135
+ mask_source: Optional local path or HTTP URL of the mask image.
136
+ model: Model name.
137
+ size: Size parameter.
138
+ quality: Quality parameter.
139
+ n: Number of images.
140
+ response_format: 'b64_json' or 'url'.
141
+ """
142
+ start_time = time.time()
143
+
144
+ # Fetch and prepare base image
145
+ raw_image = fetch_image_data(image_source)
146
+ png_image = ensure_png_bytes(raw_image)
147
+
148
+ kwargs: dict = {
149
+ "model": model,
150
+ "prompt": prompt,
151
+ "image": ("image.png", png_image, "image/png"),
152
+ "n": n,
153
+ }
154
+
155
+ # Mask handling
156
+ if mask_source:
157
+ raw_mask = fetch_image_data(mask_source)
158
+ png_mask = ensure_png_bytes(raw_mask, force_rgba=True)
159
+ kwargs["mask"] = ("mask.png", png_mask, "image/png")
160
+
161
+ # Size handling
162
+ if size and size.lower() != "auto":
163
+ kwargs["size"] = size
164
+ elif model.startswith("gpt-image"):
165
+ if size:
166
+ kwargs["size"] = size
167
+ elif size and size.lower() == "auto":
168
+ # For dall-e-2, size must be explicit
169
+ kwargs["size"] = "1024x1024"
170
+
171
+ # Quality handling
172
+ if quality and quality.lower() != "auto":
173
+ if model != "dall-e-2":
174
+ kwargs["quality"] = quality
175
+ elif quality and model.startswith("gpt-image"):
176
+ kwargs["quality"] = quality
177
+
178
+ if response_format:
179
+ kwargs["response_format"] = response_format
180
+
181
+ response = self.client.images.edit(**kwargs)
182
+ duration = time.time() - start_time
183
+
184
+ images_bytes, revised_prompt = self._extract_images(response.data)
185
+ return GenerationResult(
186
+ images_bytes=images_bytes,
187
+ revised_prompt=revised_prompt,
188
+ duration_seconds=duration,
189
+ )
img_cli/config.py ADDED
@@ -0,0 +1,71 @@
1
+ """Configuration and authentication management for img-cli."""
2
+
3
+ import json
4
+ import os
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ AUTH_DIR = Path.home() / ".img_gen"
9
+ AUTH_FILE = AUTH_DIR / "auth.json"
10
+ DEFAULT_API_BASE = "https://api.openai.com/v1"
11
+
12
+
13
+ class AuthError(Exception):
14
+ """Raised when authentication credentials cannot be found or are invalid."""
15
+
16
+
17
+ @dataclass
18
+ class AuthConfig:
19
+ api_key: str
20
+ api_base: str = DEFAULT_API_BASE
21
+
22
+
23
+ def load_auth_config(custom_auth_file: Path | None = None) -> AuthConfig:
24
+ """Load authentication credentials from auth.json or environment variables.
25
+
26
+ Loading precedence:
27
+ 1. ~/.img_gen/auth.json (or custom_auth_file if specified)
28
+ 2. Environment variables (OPENAI_API_KEY / IMG_GEN_API_KEY, OPENAI_BASE_URL / OPENAI_API_BASE)
29
+
30
+ Raises:
31
+ AuthError: If no valid credentials can be found.
32
+ """
33
+ target_file = custom_auth_file or AUTH_FILE
34
+ api_key: str | None = None
35
+ api_base: str | None = None
36
+
37
+ if target_file.exists() and target_file.is_file():
38
+ try:
39
+ with open(target_file, encoding="utf-8") as f:
40
+ data = json.load(f)
41
+ if isinstance(data, dict):
42
+ api_key = data.get("api_key")
43
+ api_base = data.get("api_base") or data.get("base_url")
44
+ except Exception as e:
45
+ raise AuthError(f"读取认证文件失败 ({target_file}): {e}") from e
46
+
47
+ # Fallback to environment variables if not found in file
48
+ if not api_key:
49
+ api_key = os.environ.get("IMG_GEN_API_KEY") or os.environ.get("OPENAI_API_KEY")
50
+
51
+ if not api_base:
52
+ api_base = (
53
+ os.environ.get("IMG_GEN_API_BASE")
54
+ or os.environ.get("OPENAI_BASE_URL")
55
+ or os.environ.get("OPENAI_API_BASE")
56
+ or DEFAULT_API_BASE
57
+ )
58
+
59
+ if not api_key or not api_key.strip():
60
+ error_msg = (
61
+ "未找到认证文件\n"
62
+ "请创建配置文件 ~/.img_gen/auth.json:\n"
63
+ " mkdir -p ~/.img_gen\n"
64
+ ' echo \'{"api_key":"sk-xxx","api_base":"https://api.openai.com/v1"}\' > ~/.img_gen/auth.json'
65
+ )
66
+ raise AuthError(error_msg)
67
+
68
+ # Normalize api_base (strip trailing slash)
69
+ api_base = api_base.rstrip("/")
70
+
71
+ return AuthConfig(api_key=api_key.strip(), api_base=api_base)
img_cli/image_utils.py ADDED
@@ -0,0 +1,159 @@
1
+ """Image utilities for fetching, format conversion, and saving."""
2
+
3
+ import io
4
+ from datetime import datetime
5
+ from pathlib import Path
6
+
7
+ import httpx
8
+ from PIL import Image
9
+
10
+
11
+ class ImageFetchError(Exception):
12
+ """Raised when an image cannot be fetched or read."""
13
+
14
+
15
+ def fetch_image_data(source: str, timeout: float = 60.0) -> bytes:
16
+ """Fetch image bytes from a local path or a remote HTTP/HTTPS URL.
17
+
18
+ Args:
19
+ source: Local file path (e.g. ~/Desktop/img.png) or URL (http/https).
20
+ timeout: Request timeout for URL downloads in seconds.
21
+
22
+ Returns:
23
+ Image bytes.
24
+
25
+ Raises:
26
+ ImageFetchError: If the file does not exist or URL download fails.
27
+ """
28
+ if source.startswith(("http://", "https://")):
29
+ try:
30
+ with httpx.Client(timeout=timeout, follow_redirects=True) as client:
31
+ response = client.get(source)
32
+ response.raise_for_status()
33
+ return response.content
34
+ except httpx.HTTPStatusError as e:
35
+ raise ImageFetchError(f"下载图片失败 (HTTP {e.response.status_code}): {source}") from e
36
+ except Exception as e:
37
+ raise ImageFetchError(f"下载图片失败: {source} ({e})") from e
38
+ else:
39
+ path = Path(source).expanduser().resolve()
40
+ if not path.exists():
41
+ raise ImageFetchError(f"图片文件不存在: {source}")
42
+ if not path.is_file():
43
+ raise ImageFetchError(f"指定路径不是文件: {source}")
44
+ try:
45
+ return path.read_bytes()
46
+ except Exception as e:
47
+ raise ImageFetchError(f"读取图片文件失败 ({path}): {e}") from e
48
+
49
+
50
+ def ensure_png_bytes(image_data: bytes, force_rgba: bool = False) -> bytes:
51
+ """Ensure the given image data is in valid PNG format (required by OpenAI Edit API).
52
+
53
+ Args:
54
+ image_data: Raw image bytes.
55
+ force_rgba: If True, convert image mode to RGBA.
56
+
57
+ Returns:
58
+ PNG image bytes.
59
+ """
60
+ try:
61
+ img = Image.open(io.BytesIO(image_data))
62
+ # If it's already PNG and mode matches, check if conversion needed
63
+ if img.format == "PNG" and (not force_rgba or img.mode == "RGBA"):
64
+ return image_data
65
+
66
+ target_mode = "RGBA" if (force_rgba or "A" in img.mode) else "RGB"
67
+ if img.mode != target_mode:
68
+ img = img.convert(target_mode)
69
+
70
+ buf = io.BytesIO()
71
+ img.save(buf, format="PNG")
72
+ return buf.getvalue()
73
+ except Exception as e:
74
+ raise ValueError(f"处理图片格式失败: {e}") from e
75
+
76
+
77
+ def convert_and_save_image(
78
+ image_data: bytes,
79
+ target_path: Path,
80
+ target_format: str = "png",
81
+ ) -> Path:
82
+ """Save image bytes to target_path, converting format if requested."""
83
+ fmt = target_format.lower().strip(".")
84
+ if fmt == "jpg":
85
+ fmt = "jpeg"
86
+
87
+ try:
88
+ img = Image.open(io.BytesIO(image_data))
89
+ orig_fmt = (img.format or "").lower()
90
+ if orig_fmt == "jpg":
91
+ orig_fmt = "jpeg"
92
+
93
+ # If formats already match, write directly
94
+ if orig_fmt == fmt:
95
+ target_path.write_bytes(image_data)
96
+ return target_path
97
+
98
+ # Handle RGBA conversion for JPEG
99
+ if fmt == "jpeg" and img.mode in ("RGBA", "LA", "P"):
100
+ background = Image.new("RGB", img.size, (255, 255, 255))
101
+ if img.mode == "RGBA":
102
+ background.paste(img, mask=img.split()[3])
103
+ else:
104
+ background.paste(img.convert("RGBA"))
105
+ background.save(target_path, format="JPEG", quality=95)
106
+ else:
107
+ save_format = "JPEG" if fmt == "jpeg" else fmt.upper()
108
+ img.save(target_path, format=save_format)
109
+
110
+ return target_path
111
+ except Exception:
112
+ # Fallback to direct bytes write if Pillow fails
113
+ target_path.write_bytes(image_data)
114
+ return target_path
115
+
116
+
117
+ def save_images(
118
+ images_data: list[bytes],
119
+ output_dir: str | Path = ".",
120
+ format: str = "png",
121
+ ) -> list[Path]:
122
+ """Save a list of image byte buffers to output directory with standard naming.
123
+
124
+ File naming format: img_<timestamp>_<index>.<format>
125
+ Example: img_20260829_210700_1.png
126
+
127
+ Args:
128
+ images_data: List of raw image data bytes.
129
+ output_dir: Directory path to save files into.
130
+ format: Target image extension/format ('png', 'jpeg', 'webp').
131
+
132
+ Returns:
133
+ List of absolute Paths of the saved images.
134
+ """
135
+ out_dir = Path(output_dir).expanduser().resolve()
136
+ out_dir.mkdir(parents=True, exist_ok=True)
137
+
138
+ fmt = format.lower().strip(".")
139
+ ext = "jpg" if fmt == "jpeg" else fmt
140
+
141
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
142
+ saved_paths: list[Path] = []
143
+
144
+ for i, data in enumerate(images_data, start=1):
145
+ filename = f"img_{timestamp}_{i}.{ext}"
146
+ target_path = out_dir / filename
147
+
148
+ # Handle conflict if file already exists in current second
149
+ if target_path.exists():
150
+ counter = 1
151
+ while target_path.exists():
152
+ filename = f"img_{timestamp}_{i}_{counter}.{ext}"
153
+ target_path = out_dir / filename
154
+ counter += 1
155
+
156
+ saved_file = convert_and_save_image(data, target_path, target_format=fmt)
157
+ saved_paths.append(saved_file)
158
+
159
+ return saved_paths
img_cli/logger.py ADDED
@@ -0,0 +1,90 @@
1
+ """Audit logging for img-cli operations (JSON Lines format)."""
2
+
3
+ import json
4
+ from dataclasses import asdict, dataclass
5
+ from datetime import datetime, timezone
6
+ from pathlib import Path
7
+
8
+ LOG_DIR = Path.home() / ".img_gen"
9
+ LOG_FILE = LOG_DIR / "img.log"
10
+
11
+
12
+ @dataclass
13
+ class AuditRecord:
14
+ timestamp: str
15
+ command: str
16
+ prompt: str
17
+ model: str
18
+ size: str | None = None
19
+ quality: str | None = None
20
+ n: int = 1
21
+ output_dir: str = "."
22
+ input_image: str | None = None
23
+ mask: str | None = None
24
+ status: str = "success"
25
+ duration_seconds: float = 0.0
26
+ files: list[str] | None = None
27
+ revised_prompt: str | None = None
28
+ error: str | None = None
29
+
30
+
31
+ def log_audit(
32
+ record: AuditRecord,
33
+ custom_log_file: Path | None = None,
34
+ ) -> None:
35
+ """Append an audit record to the JSON Lines log file.
36
+
37
+ Args:
38
+ record: The AuditRecord to serialize and write.
39
+ custom_log_file: Optional path override for testing.
40
+ """
41
+ target_file = custom_log_file or LOG_FILE
42
+ try:
43
+ target_file.parent.mkdir(parents=True, exist_ok=True)
44
+ data = asdict(record)
45
+ # Ensure files is a list
46
+ if data.get("files") is None:
47
+ data["files"] = []
48
+
49
+ line = json.dumps(data, ensure_ascii=False)
50
+ with open(target_file, "a", encoding="utf-8") as f:
51
+ f.write(line + "\n")
52
+ except Exception:
53
+ # Non-fatal: logging failure should not crash the command
54
+ pass
55
+
56
+
57
+ def create_audit_record(
58
+ command: str,
59
+ prompt: str,
60
+ model: str,
61
+ duration_seconds: float,
62
+ status: str = "success",
63
+ size: str | None = None,
64
+ quality: str | None = None,
65
+ n: int = 1,
66
+ output_dir: str = ".",
67
+ input_image: str | None = None,
68
+ mask: str | None = None,
69
+ files: list[str] | None = None,
70
+ revised_prompt: str | None = None,
71
+ error: str | None = None,
72
+ ) -> AuditRecord:
73
+ """Helper to create an AuditRecord with current UTC timestamp."""
74
+ return AuditRecord(
75
+ timestamp=datetime.now(timezone.utc).isoformat(),
76
+ command=command,
77
+ prompt=prompt,
78
+ model=model,
79
+ size=size,
80
+ quality=quality,
81
+ n=n,
82
+ output_dir=output_dir,
83
+ input_image=input_image,
84
+ mask=mask,
85
+ status=status,
86
+ duration_seconds=round(duration_seconds, 2),
87
+ files=files or [],
88
+ revised_prompt=revised_prompt,
89
+ error=error,
90
+ )
@@ -0,0 +1,139 @@
1
+ Metadata-Version: 2.5
2
+ Name: img-cli
3
+ Version: 0.1.0
4
+ Summary: CLI tool for OpenAI-compatible Image Generation and Editing
5
+ License-File: LICENSE
6
+ Requires-Python: >=3.10
7
+ Requires-Dist: click>=8.1.7
8
+ Requires-Dist: httpx>=0.27.0
9
+ Requires-Dist: openai>=1.50.0
10
+ Requires-Dist: pillow>=10.2.0
11
+ Requires-Dist: pydantic>=2.6.0
12
+ Requires-Dist: rich>=13.7.0
13
+ Description-Content-Type: text/markdown
14
+
15
+ # img-cli
16
+
17
+ 命令行图片生成与编辑工具,基于 Python + uv 开发,封装 OpenAI 兼容的 Images API(文生图 `generate` 与图生图编辑 `edit`)。
18
+
19
+ <p align="center">
20
+ <img src="assets/poster.png" alt="img-cli Poster" width="600" />
21
+ </p>
22
+
23
+ ---
24
+
25
+ ## 特性
26
+
27
+ - 🚀 **简单易用**:一行命令完成图片生成和图片编辑。
28
+ - 🔑 **灵活认证**:支持 `~/.img_gen/auth.json` 配置文件及环境变量。
29
+ - 📝 **审计日志**:自动在 `~/.img_gen/img.log` 记录每次请求详情(JSON Lines 格式)。
30
+ - 🖼️ **格式与尺寸自适应**:支持 PNG / JPEG / WEBP,自动进行 RGBA 蒙版和格式转换。
31
+ - 🌐 **网络与本地图片支持**:编辑模式下原图和蒙版均支持本地路径(含 `~`)及 HTTP/HTTPS URL。
32
+ - 🤖 **Agent 友好**:清晰的标准输出与结构化错误码,便于自动化工作流接入。
33
+
34
+ ---
35
+
36
+ ## 安装与环境准备
37
+
38
+ ### 通过 PyPI 安装
39
+
40
+ ```bash
41
+ # 使用 uv 工具安装(推荐)
42
+ uv tool install img-cli
43
+
44
+ # 或通过 pip 安装
45
+ pip install img-cli
46
+ ```
47
+
48
+ ### 本地开发与运行
49
+
50
+ ```bash
51
+ # 1. 运行测试 / 调试
52
+ uv run img-cli --help
53
+
54
+ # 2. 从源码安装 CLI
55
+ uv tool install .
56
+ ```
57
+
58
+ ---
59
+
60
+ ## 认证配置
61
+
62
+ 首次使用前,请创建配置文件 `~/.img_gen/auth.json`:
63
+
64
+ ```bash
65
+ mkdir -p ~/.img_gen
66
+ echo '{"api_key":"sk-xxx","api_base":"https://api.openai.com/v1"}' > ~/.img_gen/auth.json
67
+ ```
68
+
69
+ 也可通过环境变量传入:
70
+ - `OPENAI_API_KEY`(或 `IMG_GEN_API_KEY`)
71
+ - `OPENAI_BASE_URL`(或 `OPENAI_API_BASE` / `IMG_GEN_API_BASE`)
72
+
73
+ ---
74
+
75
+ ## 快速使用
76
+
77
+ ### 1. 文生图 (generate)
78
+
79
+ ```bash
80
+ # 基础生成(默认模型 gpt-image-2,保存至当前目录)
81
+ img-cli generate "一只戴墨镜的猫坐在沙滩上"
82
+
83
+ # 指定模型、尺寸与输出目录
84
+ img-cli generate "a futuristic city at sunset" -m dall-e-3 -s 1792x1024 -q hd -o ~/Desktop
85
+
86
+ # 指定输出格式为 JPEG
87
+ img-cli generate "产品白底宣传图,极简风格" -m gpt-image-1 -q high --format jpeg -o ./output
88
+ ```
89
+
90
+ ### 2. 图片编辑 (edit)
91
+
92
+ ```bash
93
+ # 本地图片全图编辑
94
+ img-cli edit "给猫戴上一顶红色圣诞帽" --image ./cat.png -o ./output
95
+
96
+ # 网络图片直接编辑
97
+ img-cli edit "替换背景为星空" --image https://example.com/photo.jpg -o ./output
98
+
99
+ # 带蒙版的局部编辑(蒙版透明区域为待修改区域)
100
+ img-cli edit "用一朵玫瑰替换这里" --image ./photo.png --mask ./mask.png -o ./output
101
+ ```
102
+
103
+ ---
104
+
105
+ ## 命令参数参考
106
+
107
+ ### `img-cli generate <prompt>`
108
+
109
+ | 参数 | 默认值 | 说明 |
110
+ |---|---|---|
111
+ | `prompt` | 必填 | 提示词(支持中文与英文) |
112
+ | `-m`, `--model` | `gpt-image-2` | 模型名称(如 `gpt-image-2`, `dall-e-3`, `gpt-image-1`) |
113
+ | `-s`, `--size` | `1024x1024` | 尺寸(如 `1024x1024`, `1536x1024`, `1024x1536`, `auto`) |
114
+ | `-q`, `--quality` | 无 | 质量(`low` / `medium` / `high` / `auto` / `standard` / `hd`) |
115
+ | `-n`, `--n` | `1` | 生成张数 |
116
+ | `-o`, `--output` | `.` | 图片输出目录 |
117
+ | `--format` | `png` | 输出格式:`png` / `jpeg` / `webp` |
118
+
119
+ ### `img-cli edit <prompt>`
120
+
121
+ | 参数 | 默认值 | 说明 |
122
+ |---|---|---|
123
+ | `prompt` | 必填 | 修改描述 |
124
+ | `-i`, `--image` | 必填 | 原图:本地路径或 HTTP/HTTPS URL |
125
+ | `--mask` | 无 | 蒙版:本地路径或 URL(PNG 透明区域待修改) |
126
+ | `-m`, `--model` | `gpt-image-2` | 模型名称 |
127
+ | `-s`, `--size` | `auto` | 尺寸 |
128
+ | `-q`, `--quality` | `auto` | 质量 |
129
+ | `-n`, `--n` | `1` | 生成张数 |
130
+ | `-o`, `--output` | `.` | 图片输出目录 |
131
+ | `--format` | `png` | 输出格式:`png` / `jpeg` / `webp` |
132
+
133
+ ---
134
+
135
+ ## 运行测试
136
+
137
+ ```bash
138
+ uv run pytest -v
139
+ ```
@@ -0,0 +1,12 @@
1
+ img_cli/__init__.py,sha256=vBugPxeKBKESQ9GPph61LPDDw7RdQ05XZ0v8Z11tcL8,405
2
+ img_cli/_version.py,sha256=n_5vdJsPNu7wZ57LGuRL585uvll-hiuvZUBWzdG0RQU,520
3
+ img_cli/cli.py,sha256=ej4rW3hF224tVVk6CYflyfY1EW-UE17_mYVNpvh9UaY,7963
4
+ img_cli/client.py,sha256=af0RU21tXlQrkj5vrztTona1ktnajWqyX4jA0ie-Zfg,6227
5
+ img_cli/config.py,sha256=a7M6U2Dk-5I9kUPA2Yq9cm87YaIW_lchCADcQTJdqZM,2350
6
+ img_cli/image_utils.py,sha256=XPRcw55fe3byixDwxxeHYP9TcQ54S1UZHstywRXXd-E,5309
7
+ img_cli/logger.py,sha256=InI38rc7PIJtPi7vQ76DF5yZ3uRzISuwO5jz4ghc4GA,2419
8
+ img_cli-0.1.0.dist-info/METADATA,sha256=Y_BAsVTtPzahxFMFrt3DAzSrXUU9TDEnEilIbXJT0b0,4027
9
+ img_cli-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
10
+ img_cli-0.1.0.dist-info/entry_points.txt,sha256=VRZPH6rNa4O0BkTUOmX2KcX6vHMi8Wj2mSKqhO5U4wE,45
11
+ img_cli-0.1.0.dist-info/licenses/LICENSE,sha256=DWcolN0n9Am669uQA1E04dsdunFqNcqXk5YsOR8xS5o,1065
12
+ img_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ img-cli = img_cli.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 8DE4732A
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.