limn 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.
- limn/__init__.py +3 -0
- limn/__main__.py +6 -0
- limn/cli.py +150 -0
- limn/config.py +199 -0
- limn/core.py +111 -0
- limn/providers/__init__.py +47 -0
- limn/providers/base.py +66 -0
- limn/providers/gemini.py +119 -0
- limn/providers/openai_compat.py +134 -0
- limn/providers/swarmui.py +89 -0
- limn-0.1.0.dist-info/METADATA +130 -0
- limn-0.1.0.dist-info/RECORD +14 -0
- limn-0.1.0.dist-info/WHEEL +4 -0
- limn-0.1.0.dist-info/entry_points.txt +2 -0
limn/__init__.py
ADDED
limn/__main__.py
ADDED
limn/cli.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Limn CLI: `limn "a red bicycle" -o bike.png`."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
|
|
7
|
+
import typer
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from limn import __version__
|
|
11
|
+
from limn.config import ConfigurationError, load_config, resolve_settings
|
|
12
|
+
from limn.config import save_example_config as _save_example_config
|
|
13
|
+
from limn.core import generate, save_images
|
|
14
|
+
from limn.providers import ProviderError
|
|
15
|
+
|
|
16
|
+
console = Console()
|
|
17
|
+
err_console = Console(stderr=True, style="bold red")
|
|
18
|
+
|
|
19
|
+
app = typer.Typer(
|
|
20
|
+
add_completion=False,
|
|
21
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
_SIZE_RE = re.compile(r"^(\d+)\s*[xX]\s*(\d+)$")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def parse_size(value: str) -> tuple[int, int]:
|
|
28
|
+
"""Parse '1024x1024' (or a bare '1024' meaning square) into (w, h)."""
|
|
29
|
+
value = value.strip()
|
|
30
|
+
if value.isdigit():
|
|
31
|
+
side = int(value)
|
|
32
|
+
return side, side
|
|
33
|
+
match = _SIZE_RE.match(value)
|
|
34
|
+
if not match:
|
|
35
|
+
raise typer.BadParameter(
|
|
36
|
+
f"Size must look like 1024x1024 (got {value!r})"
|
|
37
|
+
)
|
|
38
|
+
return int(match.group(1)), int(match.group(2))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _version_callback(value: bool) -> None:
|
|
42
|
+
if value:
|
|
43
|
+
console.print(f"limn {__version__}")
|
|
44
|
+
raise typer.Exit()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@app.command()
|
|
48
|
+
def main(
|
|
49
|
+
prompt: str = typer.Argument(
|
|
50
|
+
None,
|
|
51
|
+
help="Text description of the image to generate.",
|
|
52
|
+
show_default=False,
|
|
53
|
+
),
|
|
54
|
+
provider: str = typer.Option(
|
|
55
|
+
None,
|
|
56
|
+
"--provider",
|
|
57
|
+
"-p",
|
|
58
|
+
help="Image provider: swarmui, openai-compatible, openai, gemini.",
|
|
59
|
+
show_default=False,
|
|
60
|
+
),
|
|
61
|
+
model: str = typer.Option(
|
|
62
|
+
None, "--model", "-m", help="Model name (provider-specific).",
|
|
63
|
+
show_default=False,
|
|
64
|
+
),
|
|
65
|
+
size: str = typer.Option(
|
|
66
|
+
None, "--size", "-s", help="Image size, e.g. 1024x1024.",
|
|
67
|
+
show_default=False,
|
|
68
|
+
),
|
|
69
|
+
count: int = typer.Option(
|
|
70
|
+
None, "--count", "-n", min=1, max=10, help="Number of images.",
|
|
71
|
+
show_default=False,
|
|
72
|
+
),
|
|
73
|
+
seed: int = typer.Option(
|
|
74
|
+
None, "--seed", help="Seed for reproducibility (if supported).",
|
|
75
|
+
show_default=False,
|
|
76
|
+
),
|
|
77
|
+
negative: str = typer.Option(
|
|
78
|
+
None, "--negative", help="Negative prompt (if supported).",
|
|
79
|
+
show_default=False,
|
|
80
|
+
),
|
|
81
|
+
out: str = typer.Option(
|
|
82
|
+
None, "--out", "-o", help="Output file (default: derived from prompt).",
|
|
83
|
+
show_default=False,
|
|
84
|
+
),
|
|
85
|
+
config: str = typer.Option(
|
|
86
|
+
None, "--config", "-c", help="Config file (default: ./limn.yaml).",
|
|
87
|
+
show_default=False,
|
|
88
|
+
),
|
|
89
|
+
init_config: bool = typer.Option(
|
|
90
|
+
False, "--init-config", help="Write an example ./limn.yaml and exit."
|
|
91
|
+
),
|
|
92
|
+
version: bool = typer.Option(
|
|
93
|
+
False,
|
|
94
|
+
"--version",
|
|
95
|
+
"-V",
|
|
96
|
+
callback=_version_callback,
|
|
97
|
+
is_eager=True,
|
|
98
|
+
help="Print version and exit.",
|
|
99
|
+
),
|
|
100
|
+
) -> None:
|
|
101
|
+
"""Turn a text description into an image, with the provider you chose.
|
|
102
|
+
|
|
103
|
+
Configure once in ~/.limn.yaml (provider, server URL / API key), then:
|
|
104
|
+
|
|
105
|
+
limn "a red bicycle against a brick wall" -o bike.png
|
|
106
|
+
"""
|
|
107
|
+
del version # handled by the eager callback
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
if init_config:
|
|
111
|
+
path = _save_example_config()
|
|
112
|
+
console.print(f"Wrote example config: [bold]{path}[/bold]")
|
|
113
|
+
raise typer.Exit()
|
|
114
|
+
|
|
115
|
+
if not prompt:
|
|
116
|
+
err_console.print("Give me a prompt, e.g.: limn \"a red bicycle\"")
|
|
117
|
+
raise typer.Exit(code=2)
|
|
118
|
+
|
|
119
|
+
cfg = load_config(config)
|
|
120
|
+
provider_name = provider or cfg.get("provider")
|
|
121
|
+
if not provider_name:
|
|
122
|
+
err_console.print(
|
|
123
|
+
"No provider configured. Set 'provider:' in ~/.limn.yaml "
|
|
124
|
+
"(limn --init-config writes a template) or pass --provider."
|
|
125
|
+
)
|
|
126
|
+
raise typer.Exit(code=2)
|
|
127
|
+
|
|
128
|
+
settings = resolve_settings(cfg, str(provider_name))
|
|
129
|
+
if model is not None:
|
|
130
|
+
settings["model"] = model
|
|
131
|
+
if size is not None:
|
|
132
|
+
settings["size"] = list(parse_size(size))
|
|
133
|
+
if count is not None:
|
|
134
|
+
settings["count"] = count
|
|
135
|
+
if seed is not None:
|
|
136
|
+
settings["seed"] = seed
|
|
137
|
+
if negative is not None:
|
|
138
|
+
settings["negative"] = negative
|
|
139
|
+
|
|
140
|
+
with console.status(
|
|
141
|
+
f"Generating with {provider_name}...", spinner="dots"
|
|
142
|
+
):
|
|
143
|
+
images = generate(prompt, settings)
|
|
144
|
+
paths = save_images(images, prompt, out)
|
|
145
|
+
for path in paths:
|
|
146
|
+
console.print(f"Saved: [bold green]{path}[/bold green]")
|
|
147
|
+
|
|
148
|
+
except (ConfigurationError, ProviderError, ValueError) as e:
|
|
149
|
+
err_console.print(str(e))
|
|
150
|
+
raise typer.Exit(code=1) from None
|
limn/config.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""Layered configuration for Limn.
|
|
2
|
+
|
|
3
|
+
Layers, later wins:
|
|
4
|
+
|
|
5
|
+
1. Built-in defaults
|
|
6
|
+
2. ``~/.limn.yaml`` β personal provider + keys, set once
|
|
7
|
+
3. ``./limn.yaml`` β per-directory overrides (or an explicit ``--config`` path)
|
|
8
|
+
4. CLI flags β applied by the CLI on top of the loaded config
|
|
9
|
+
|
|
10
|
+
Secrets are referenced as ``${VAR}`` and expanded from the environment, so no
|
|
11
|
+
plaintext keys need to live in config files.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import copy
|
|
17
|
+
import os
|
|
18
|
+
import re
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
import yaml
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ConfigurationError(Exception):
|
|
26
|
+
"""Raised when configuration is invalid or missing."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Flat on purpose: Limn does one thing. An optional ``providers:`` mapping
|
|
30
|
+
# holds per-provider settings (base_url, model, api_key) so several providers
|
|
31
|
+
# can stay configured at once and be swapped with ``provider:`` / --provider.
|
|
32
|
+
DEFAULT_CONFIG: dict[str, Any] = {
|
|
33
|
+
"provider": None,
|
|
34
|
+
"base_url": None,
|
|
35
|
+
"model": None,
|
|
36
|
+
"api_key": None,
|
|
37
|
+
"size": [1024, 1024],
|
|
38
|
+
"count": 1,
|
|
39
|
+
"seed": None,
|
|
40
|
+
"negative": None,
|
|
41
|
+
"timeout": 180,
|
|
42
|
+
"providers": {},
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
_ENV_VAR_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def expand_env_vars(value: Any) -> Any:
|
|
49
|
+
"""Recursively expand ``${VAR}`` references in configuration values."""
|
|
50
|
+
if isinstance(value, str):
|
|
51
|
+
return _ENV_VAR_RE.sub(lambda m: os.getenv(m.group(1), ""), value)
|
|
52
|
+
if isinstance(value, dict):
|
|
53
|
+
return {k: expand_env_vars(v) for k, v in value.items()}
|
|
54
|
+
if isinstance(value, list):
|
|
55
|
+
return [expand_env_vars(item) for item in value]
|
|
56
|
+
return value
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _first_existing(*candidates: Path) -> Path | None:
|
|
60
|
+
for path in candidates:
|
|
61
|
+
if path.exists():
|
|
62
|
+
return path
|
|
63
|
+
return None
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def find_home_config() -> Path | None:
|
|
67
|
+
"""The user-level config (~/.limn.yaml) β provider + keys, set once."""
|
|
68
|
+
return _first_existing(
|
|
69
|
+
Path.home() / ".limn.yaml",
|
|
70
|
+
Path.home() / ".limn.yml",
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def find_project_config() -> Path | None:
|
|
75
|
+
"""The directory-level config (./limn.yaml)."""
|
|
76
|
+
return _first_existing(
|
|
77
|
+
Path("./limn.yaml"),
|
|
78
|
+
Path("./limn.yml"),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _read_config_file(config_file: Path) -> dict[str, Any] | None:
|
|
83
|
+
try:
|
|
84
|
+
with open(config_file, encoding="utf-8") as f:
|
|
85
|
+
data = yaml.safe_load(f)
|
|
86
|
+
except yaml.YAMLError as e:
|
|
87
|
+
raise ConfigurationError(f"Invalid YAML in {config_file}: {e}") from e
|
|
88
|
+
except OSError as e:
|
|
89
|
+
raise ConfigurationError(f"Error reading {config_file}: {e}") from e
|
|
90
|
+
if data is None:
|
|
91
|
+
return None
|
|
92
|
+
if not isinstance(data, dict):
|
|
93
|
+
raise ConfigurationError(
|
|
94
|
+
f"Config file {config_file} must contain a YAML mapping"
|
|
95
|
+
)
|
|
96
|
+
return data
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def merge_configs(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
|
100
|
+
"""Deep-merge two config dicts (override wins)."""
|
|
101
|
+
result = base.copy()
|
|
102
|
+
for key, value in override.items():
|
|
103
|
+
if key in result and isinstance(result[key], dict) and isinstance(value, dict):
|
|
104
|
+
result[key] = merge_configs(result[key], value)
|
|
105
|
+
else:
|
|
106
|
+
result[key] = value
|
|
107
|
+
return result
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def load_config(config_path: str | None = None) -> dict[str, Any]:
|
|
111
|
+
"""Load configuration by layering defaults, home, and project files.
|
|
112
|
+
|
|
113
|
+
An explicit ``config_path`` replaces the auto-discovered ./limn.yaml but
|
|
114
|
+
still sits on top of ~/.limn.yaml.
|
|
115
|
+
"""
|
|
116
|
+
config = copy.deepcopy(DEFAULT_CONFIG)
|
|
117
|
+
|
|
118
|
+
sources: list[Path] = []
|
|
119
|
+
if home_config := find_home_config():
|
|
120
|
+
sources.append(home_config)
|
|
121
|
+
if config_path:
|
|
122
|
+
explicit = Path(config_path)
|
|
123
|
+
if not explicit.exists():
|
|
124
|
+
raise ConfigurationError(f"Configuration file not found: {config_path}")
|
|
125
|
+
sources.append(explicit)
|
|
126
|
+
elif project_config := find_project_config():
|
|
127
|
+
sources.append(project_config)
|
|
128
|
+
|
|
129
|
+
for source in sources:
|
|
130
|
+
file_config = _read_config_file(source)
|
|
131
|
+
if file_config:
|
|
132
|
+
config = merge_configs(config, file_config)
|
|
133
|
+
|
|
134
|
+
return expand_env_vars(config)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def resolve_settings(config: dict[str, Any], provider: str) -> dict[str, Any]:
|
|
138
|
+
"""Effective flat settings for one provider.
|
|
139
|
+
|
|
140
|
+
Top-level keys are the base; an entry under ``providers.<name>`` overrides
|
|
141
|
+
them, so a config can keep swarmui, gemini, ... configured side by side.
|
|
142
|
+
"""
|
|
143
|
+
settings = {k: v for k, v in config.items() if k != "providers"}
|
|
144
|
+
per_provider = config.get("providers") or {}
|
|
145
|
+
override = per_provider.get(provider)
|
|
146
|
+
if isinstance(override, dict):
|
|
147
|
+
settings = merge_configs(settings, override)
|
|
148
|
+
settings["provider"] = provider
|
|
149
|
+
return settings
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
EXAMPLE_CONFIG = """\
|
|
153
|
+
# Limn configuration
|
|
154
|
+
#
|
|
155
|
+
# Layered, later wins:
|
|
156
|
+
# 1. built-in defaults
|
|
157
|
+
# 2. ~/.limn.yaml (personal: provider, server URL, API keys β set once)
|
|
158
|
+
# 3. ./limn.yaml (per-directory overrides; or pass --config FILE)
|
|
159
|
+
# 4. CLI flags (e.g. --provider, --size)
|
|
160
|
+
#
|
|
161
|
+
# Secrets: reference environment variables as ${VAR} β no plaintext keys.
|
|
162
|
+
|
|
163
|
+
provider: swarmui # swarmui | openai-compatible | openai | gemini
|
|
164
|
+
|
|
165
|
+
# Settings shared by whichever provider is active:
|
|
166
|
+
size: [1024, 1024]
|
|
167
|
+
# count: 1
|
|
168
|
+
# seed: 42
|
|
169
|
+
# negative: "text, watermark"
|
|
170
|
+
|
|
171
|
+
# Per-provider settings (only the active provider's block is used, so you can
|
|
172
|
+
# keep several configured and swap with `provider:` or --provider):
|
|
173
|
+
providers:
|
|
174
|
+
swarmui:
|
|
175
|
+
base_url: https://image.example.org
|
|
176
|
+
model: juggernautXL_v9
|
|
177
|
+
# api_key: "${SWARMUI_TOKEN}" # only if fronted by auth
|
|
178
|
+
|
|
179
|
+
openai-compatible: # LocalAI, or any /v1/images endpoint
|
|
180
|
+
base_url: http://localhost:8080/v1
|
|
181
|
+
model: sdxl
|
|
182
|
+
|
|
183
|
+
openai: # cloud OpenAI Images
|
|
184
|
+
model: gpt-image-1 # or dall-e-3
|
|
185
|
+
# api_key: "${OPENAI_API_KEY}" # defaults to the env var anyway
|
|
186
|
+
|
|
187
|
+
gemini: # Google Imagen (~$0.02/img on Fast)
|
|
188
|
+
model: imagen-4.0-fast-generate-001
|
|
189
|
+
# api_key: "${GEMINI_API_KEY}" # defaults to the env var anyway
|
|
190
|
+
"""
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def save_example_config(path: str = "limn.yaml") -> Path:
|
|
194
|
+
"""Write an example configuration file and return its path."""
|
|
195
|
+
target = Path(path)
|
|
196
|
+
if target.exists():
|
|
197
|
+
raise ConfigurationError(f"Refusing to overwrite existing {target}")
|
|
198
|
+
target.write_text(EXAMPLE_CONFIG, encoding="utf-8")
|
|
199
|
+
return target
|
limn/core.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Core: turn settings + prompt into image files on disk."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from limn.providers import GeneratedImage, GenerateRequest, get_provider
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_request(prompt: str, settings: dict[str, Any]) -> GenerateRequest:
|
|
13
|
+
"""Build a provider request from resolved settings."""
|
|
14
|
+
size = settings.get("size") or [1024, 1024]
|
|
15
|
+
if isinstance(size, (list, tuple)) and len(size) == 2:
|
|
16
|
+
width, height = int(size[0]), int(size[1])
|
|
17
|
+
else:
|
|
18
|
+
raise ValueError(f"size must be [width, height], got {size!r}")
|
|
19
|
+
|
|
20
|
+
seed = settings.get("seed")
|
|
21
|
+
return GenerateRequest(
|
|
22
|
+
prompt=prompt,
|
|
23
|
+
size=(width, height),
|
|
24
|
+
count=int(settings.get("count") or 1),
|
|
25
|
+
seed=int(seed) if seed is not None else None,
|
|
26
|
+
negative=settings.get("negative") or None,
|
|
27
|
+
model=settings.get("model") or None,
|
|
28
|
+
base_url=settings.get("base_url") or None,
|
|
29
|
+
api_key=settings.get("api_key") or None,
|
|
30
|
+
timeout=float(settings.get("timeout") or 180),
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def generate(prompt: str, settings: dict[str, Any]) -> list[GeneratedImage]:
|
|
35
|
+
"""Generate images for a prompt using the provider named in settings."""
|
|
36
|
+
provider_name = settings.get("provider")
|
|
37
|
+
if not provider_name:
|
|
38
|
+
raise ValueError(
|
|
39
|
+
"No provider configured. Set 'provider:' in ~/.limn.yaml "
|
|
40
|
+
"(run 'limn --init-config' for a template) or pass --provider."
|
|
41
|
+
)
|
|
42
|
+
provider = get_provider(str(provider_name))
|
|
43
|
+
return provider.generate(build_request(prompt, settings))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def image_extension(data: bytes) -> str:
|
|
47
|
+
"""File extension from magic bytes (providers return raw image blobs)."""
|
|
48
|
+
if data.startswith(b"\x89PNG\r\n\x1a\n"):
|
|
49
|
+
return ".png"
|
|
50
|
+
if data.startswith(b"\xff\xd8"):
|
|
51
|
+
return ".jpg"
|
|
52
|
+
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
|
53
|
+
return ".webp"
|
|
54
|
+
if data[:6] in (b"GIF87a", b"GIF89a"):
|
|
55
|
+
return ".gif"
|
|
56
|
+
return ".png"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def slugify(text: str, max_length: int = 40) -> str:
|
|
60
|
+
"""Filesystem-safe slug of a prompt for default output names."""
|
|
61
|
+
slug = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-")
|
|
62
|
+
slug = slug[:max_length].rstrip("-")
|
|
63
|
+
return slug or "image"
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _unique(path: Path) -> Path:
|
|
67
|
+
"""Avoid clobbering existing files by appending -2, -3, ..."""
|
|
68
|
+
if not path.exists():
|
|
69
|
+
return path
|
|
70
|
+
for i in range(2, 1000):
|
|
71
|
+
candidate = path.with_name(f"{path.stem}-{i}{path.suffix}")
|
|
72
|
+
if not candidate.exists():
|
|
73
|
+
return candidate
|
|
74
|
+
raise FileExistsError(f"Could not find a free filename near {path}")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def save_images(
|
|
78
|
+
images: list[GeneratedImage], prompt: str, out: str | None = None
|
|
79
|
+
) -> list[Path]:
|
|
80
|
+
"""Write image bytes to disk and return the saved paths.
|
|
81
|
+
|
|
82
|
+
- no --out: <prompt-slug>.png in the cwd (never overwrites; -2, -3, ...)
|
|
83
|
+
- --out with one image: exactly that path
|
|
84
|
+
- --out with several: stem-1.ext, stem-2.ext, ...
|
|
85
|
+
"""
|
|
86
|
+
paths: list[Path] = []
|
|
87
|
+
if out is None:
|
|
88
|
+
base = Path(slugify(prompt))
|
|
89
|
+
for image in images:
|
|
90
|
+
target = _unique(base.with_suffix(image_extension(image.data)))
|
|
91
|
+
target.write_bytes(image.data)
|
|
92
|
+
paths.append(target)
|
|
93
|
+
return paths
|
|
94
|
+
|
|
95
|
+
out_path = Path(out)
|
|
96
|
+
if out_path.parent != Path("."):
|
|
97
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
98
|
+
|
|
99
|
+
if len(images) == 1:
|
|
100
|
+
target = out_path if out_path.suffix else out_path.with_suffix(
|
|
101
|
+
image_extension(images[0].data)
|
|
102
|
+
)
|
|
103
|
+
target.write_bytes(images[0].data)
|
|
104
|
+
return [target]
|
|
105
|
+
|
|
106
|
+
for i, image in enumerate(images, start=1):
|
|
107
|
+
suffix = out_path.suffix or image_extension(image.data)
|
|
108
|
+
target = out_path.with_name(f"{out_path.stem}-{i}{suffix}")
|
|
109
|
+
target.write_bytes(image.data)
|
|
110
|
+
paths.append(target)
|
|
111
|
+
return paths
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Provider registry: name -> thin image-generation client."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from limn.providers.base import (
|
|
6
|
+
GeneratedImage,
|
|
7
|
+
GenerateRequest,
|
|
8
|
+
ImageProvider,
|
|
9
|
+
ProviderError,
|
|
10
|
+
)
|
|
11
|
+
from limn.providers.gemini import GeminiProvider
|
|
12
|
+
from limn.providers.openai_compat import OpenAICompatibleProvider, OpenAIProvider
|
|
13
|
+
from limn.providers.swarmui import SwarmUIProvider
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"GeneratedImage",
|
|
17
|
+
"GenerateRequest",
|
|
18
|
+
"ImageProvider",
|
|
19
|
+
"ProviderError",
|
|
20
|
+
"PROVIDERS",
|
|
21
|
+
"get_provider",
|
|
22
|
+
"SwarmUIProvider",
|
|
23
|
+
"OpenAIProvider",
|
|
24
|
+
"OpenAICompatibleProvider",
|
|
25
|
+
"GeminiProvider",
|
|
26
|
+
]
|
|
27
|
+
|
|
28
|
+
PROVIDERS: dict[str, type[ImageProvider]] = {
|
|
29
|
+
"swarmui": SwarmUIProvider,
|
|
30
|
+
"openai": OpenAIProvider,
|
|
31
|
+
"dalle": OpenAIProvider,
|
|
32
|
+
"openai-compatible": OpenAICompatibleProvider,
|
|
33
|
+
"gemini": GeminiProvider,
|
|
34
|
+
"imagen": GeminiProvider,
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_provider(name: str) -> ImageProvider:
|
|
39
|
+
"""Instantiate a provider by name (aliases: dalle -> openai, imagen -> gemini)."""
|
|
40
|
+
try:
|
|
41
|
+
provider_cls = PROVIDERS[name.lower()]
|
|
42
|
+
except KeyError:
|
|
43
|
+
known = ", ".join(sorted(set(PROVIDERS)))
|
|
44
|
+
raise ProviderError(
|
|
45
|
+
f"Unknown provider '{name}'. Choose from: {known}"
|
|
46
|
+
) from None
|
|
47
|
+
return provider_cls()
|
limn/providers/base.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Provider abstraction: a prompt goes in, image bytes come out.
|
|
2
|
+
|
|
3
|
+
Providers are thin clients over one backend each. They raise ProviderError on
|
|
4
|
+
any failure β Limn is an image tool, so it fails loudly rather than degrading
|
|
5
|
+
to a placeholder.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from abc import ABC, abstractmethod
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
from rich.console import Console
|
|
14
|
+
|
|
15
|
+
err_console = Console(stderr=True, style="yellow")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ProviderError(Exception):
|
|
19
|
+
"""Raised when a provider cannot generate images."""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class GenerateRequest:
|
|
24
|
+
"""Everything a provider needs for one generation."""
|
|
25
|
+
|
|
26
|
+
prompt: str
|
|
27
|
+
size: tuple[int, int] = (1024, 1024)
|
|
28
|
+
count: int = 1
|
|
29
|
+
seed: int | None = None
|
|
30
|
+
negative: str | None = None
|
|
31
|
+
model: str | None = None
|
|
32
|
+
base_url: str | None = None
|
|
33
|
+
api_key: str | None = None
|
|
34
|
+
timeout: float = 180.0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass
|
|
38
|
+
class GeneratedImage:
|
|
39
|
+
"""One generated image plus whatever the backend reported about it."""
|
|
40
|
+
|
|
41
|
+
data: bytes
|
|
42
|
+
seed: int | None = None
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ImageProvider(ABC):
|
|
46
|
+
"""One backend: turn a GenerateRequest into image bytes."""
|
|
47
|
+
|
|
48
|
+
name: str = "base"
|
|
49
|
+
|
|
50
|
+
@abstractmethod
|
|
51
|
+
def generate(self, request: GenerateRequest) -> list[GeneratedImage]:
|
|
52
|
+
"""Generate ``request.count`` images. Raises ProviderError on failure."""
|
|
53
|
+
raise NotImplementedError
|
|
54
|
+
|
|
55
|
+
def warn_unsupported(self, request: GenerateRequest) -> None:
|
|
56
|
+
"""Tell the user (stderr) about request fields this backend ignores."""
|
|
57
|
+
for label, value in self.unsupported(request):
|
|
58
|
+
if value is not None:
|
|
59
|
+
err_console.print(
|
|
60
|
+
f"Note: provider '{self.name}' does not support "
|
|
61
|
+
f"{label}; ignoring."
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def unsupported(self, request: GenerateRequest) -> list[tuple[str, object]]:
|
|
65
|
+
"""(label, value) pairs of request fields this backend ignores."""
|
|
66
|
+
return []
|
limn/providers/gemini.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Google Imagen provider via the Gemini API's REST predict endpoint.
|
|
2
|
+
|
|
3
|
+
Plain HTTP instead of the google-genai SDK keeps Limn dependency-light.
|
|
4
|
+
Imagen takes an aspect ratio rather than exact pixels, so the requested size
|
|
5
|
+
is mapped to the nearest supported ratio.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import math
|
|
12
|
+
import os
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import requests
|
|
16
|
+
|
|
17
|
+
from limn.providers.base import (
|
|
18
|
+
GeneratedImage,
|
|
19
|
+
GenerateRequest,
|
|
20
|
+
ImageProvider,
|
|
21
|
+
ProviderError,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
GEMINI_API_URL = "https://generativelanguage.googleapis.com/v1beta"
|
|
25
|
+
DEFAULT_MODEL = "imagen-4.0-fast-generate-001"
|
|
26
|
+
|
|
27
|
+
# Aspect ratios the Imagen API accepts.
|
|
28
|
+
ASPECT_RATIOS = {
|
|
29
|
+
"1:1": 1 / 1,
|
|
30
|
+
"4:3": 4 / 3,
|
|
31
|
+
"3:4": 3 / 4,
|
|
32
|
+
"16:9": 16 / 9,
|
|
33
|
+
"9:16": 9 / 16,
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def nearest_aspect_ratio(size: tuple[int, int]) -> str:
|
|
38
|
+
"""Map an exact WxH to the closest ratio Imagen supports."""
|
|
39
|
+
width, height = size
|
|
40
|
+
if width <= 0 or height <= 0:
|
|
41
|
+
raise ProviderError(f"Invalid size {width}x{height}")
|
|
42
|
+
target = width / height
|
|
43
|
+
return min(
|
|
44
|
+
ASPECT_RATIOS,
|
|
45
|
+
key=lambda name: abs(math.log(ASPECT_RATIOS[name]) - math.log(target)),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class GeminiProvider(ImageProvider):
|
|
50
|
+
name = "gemini"
|
|
51
|
+
|
|
52
|
+
def unsupported(self, request: GenerateRequest) -> list[tuple[str, object]]:
|
|
53
|
+
# The Gemini API's Imagen endpoint accepts neither seed nor negative
|
|
54
|
+
# prompt (both are Vertex-only features).
|
|
55
|
+
return [("--seed", request.seed), ("--negative", request.negative)]
|
|
56
|
+
|
|
57
|
+
def generate(self, request: GenerateRequest) -> list[GeneratedImage]:
|
|
58
|
+
self.warn_unsupported(request)
|
|
59
|
+
|
|
60
|
+
api_key = (
|
|
61
|
+
request.api_key
|
|
62
|
+
or os.getenv("GEMINI_API_KEY")
|
|
63
|
+
or os.getenv("GOOGLE_API_KEY")
|
|
64
|
+
)
|
|
65
|
+
if not api_key:
|
|
66
|
+
raise ProviderError(
|
|
67
|
+
"Gemini needs an API key (config 'api_key', GEMINI_API_KEY, "
|
|
68
|
+
"or GOOGLE_API_KEY)."
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
base_url = (request.base_url or GEMINI_API_URL).rstrip("/")
|
|
72
|
+
model = request.model or DEFAULT_MODEL
|
|
73
|
+
|
|
74
|
+
payload: dict[str, Any] = {
|
|
75
|
+
"instances": [{"prompt": request.prompt}],
|
|
76
|
+
"parameters": {
|
|
77
|
+
"sampleCount": request.count,
|
|
78
|
+
"aspectRatio": nearest_aspect_ratio(request.size),
|
|
79
|
+
},
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
try:
|
|
83
|
+
response = requests.post(
|
|
84
|
+
f"{base_url}/models/{model}:predict",
|
|
85
|
+
json=payload,
|
|
86
|
+
headers={
|
|
87
|
+
"x-goog-api-key": api_key,
|
|
88
|
+
"Content-Type": "application/json",
|
|
89
|
+
},
|
|
90
|
+
timeout=request.timeout,
|
|
91
|
+
)
|
|
92
|
+
if not response.ok:
|
|
93
|
+
detail = _error_detail(response)
|
|
94
|
+
raise ProviderError(
|
|
95
|
+
f"Imagen returned {response.status_code}: {detail}"
|
|
96
|
+
)
|
|
97
|
+
predictions = response.json().get("predictions") or []
|
|
98
|
+
images = [
|
|
99
|
+
GeneratedImage(data=base64.b64decode(p["bytesBase64Encoded"]))
|
|
100
|
+
for p in predictions
|
|
101
|
+
if p.get("bytesBase64Encoded")
|
|
102
|
+
]
|
|
103
|
+
if not images:
|
|
104
|
+
raise ProviderError("Imagen returned no images")
|
|
105
|
+
return images
|
|
106
|
+
|
|
107
|
+
except ProviderError:
|
|
108
|
+
raise
|
|
109
|
+
except (requests.RequestException, KeyError, ValueError) as e:
|
|
110
|
+
raise ProviderError(f"Imagen error: {e}") from e
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _error_detail(response: requests.Response) -> str:
|
|
114
|
+
"""Best-effort human-readable error from a Google API response."""
|
|
115
|
+
try:
|
|
116
|
+
body = response.json()
|
|
117
|
+
return body.get("error", {}).get("message") or str(body)
|
|
118
|
+
except ValueError:
|
|
119
|
+
return response.text[:300]
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""OpenAI Images providers: the real API and any compatible /v1/images endpoint.
|
|
2
|
+
|
|
3
|
+
Both talk plain HTTP to ``{base_url}/images/generations`` β no SDK needed.
|
|
4
|
+
``openai`` defaults base_url to the real API and requires a key;
|
|
5
|
+
``openai-compatible`` requires a base_url (LocalAI, a1111 shims, ...) and
|
|
6
|
+
treats the key as optional, exactly the split slide-stream uses so a stray
|
|
7
|
+
OPENAI_API_KEY never silently sends prompts to the wrong server.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import os
|
|
14
|
+
|
|
15
|
+
import requests
|
|
16
|
+
|
|
17
|
+
from limn.providers.base import (
|
|
18
|
+
GeneratedImage,
|
|
19
|
+
GenerateRequest,
|
|
20
|
+
ImageProvider,
|
|
21
|
+
ProviderError,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
OPENAI_API_URL = "https://api.openai.com/v1"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class OpenAIProvider(ImageProvider):
|
|
28
|
+
name = "openai"
|
|
29
|
+
|
|
30
|
+
default_base_url: str | None = OPENAI_API_URL
|
|
31
|
+
default_model = "gpt-image-1"
|
|
32
|
+
require_api_key = True
|
|
33
|
+
|
|
34
|
+
def unsupported(self, request: GenerateRequest) -> list[tuple[str, object]]:
|
|
35
|
+
# The /v1/images API has no seed or negative-prompt parameters.
|
|
36
|
+
return [("--seed", request.seed), ("--negative", request.negative)]
|
|
37
|
+
|
|
38
|
+
def _base_url(self, request: GenerateRequest) -> str:
|
|
39
|
+
base_url = request.base_url or self.default_base_url
|
|
40
|
+
if not base_url:
|
|
41
|
+
raise ProviderError(
|
|
42
|
+
f"Provider '{self.name}' needs a base_url (config 'base_url', "
|
|
43
|
+
f"providers.{self.name}.base_url, or OPENAI_BASE_URL)."
|
|
44
|
+
)
|
|
45
|
+
return base_url.rstrip("/")
|
|
46
|
+
|
|
47
|
+
def _api_key(self, request: GenerateRequest) -> str | None:
|
|
48
|
+
api_key = request.api_key or os.getenv("OPENAI_API_KEY")
|
|
49
|
+
if not api_key and self.require_api_key:
|
|
50
|
+
raise ProviderError(
|
|
51
|
+
"OpenAI needs an API key (config 'api_key' or OPENAI_API_KEY)."
|
|
52
|
+
)
|
|
53
|
+
return api_key
|
|
54
|
+
|
|
55
|
+
def generate(self, request: GenerateRequest) -> list[GeneratedImage]:
|
|
56
|
+
self.warn_unsupported(request)
|
|
57
|
+
base_url = self._base_url(request)
|
|
58
|
+
api_key = self._api_key(request)
|
|
59
|
+
|
|
60
|
+
headers = {"Content-Type": "application/json"}
|
|
61
|
+
if api_key:
|
|
62
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
63
|
+
|
|
64
|
+
width, height = request.size
|
|
65
|
+
payload = {
|
|
66
|
+
"model": request.model or self.default_model,
|
|
67
|
+
"prompt": request.prompt,
|
|
68
|
+
"n": request.count,
|
|
69
|
+
"size": f"{width}x{height}",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
response = requests.post(
|
|
74
|
+
f"{base_url}/images/generations",
|
|
75
|
+
json=payload,
|
|
76
|
+
headers=headers,
|
|
77
|
+
timeout=request.timeout,
|
|
78
|
+
)
|
|
79
|
+
if not response.ok:
|
|
80
|
+
detail = _error_detail(response)
|
|
81
|
+
raise ProviderError(
|
|
82
|
+
f"{self.name} returned {response.status_code}: {detail}"
|
|
83
|
+
)
|
|
84
|
+
items = response.json().get("data") or []
|
|
85
|
+
if not items:
|
|
86
|
+
raise ProviderError(f"{self.name} returned no image data")
|
|
87
|
+
|
|
88
|
+
images: list[GeneratedImage] = []
|
|
89
|
+
for item in items:
|
|
90
|
+
b64 = item.get("b64_json")
|
|
91
|
+
url = item.get("url")
|
|
92
|
+
if b64:
|
|
93
|
+
images.append(GeneratedImage(data=base64.b64decode(b64)))
|
|
94
|
+
elif url:
|
|
95
|
+
img = requests.get(url, timeout=request.timeout)
|
|
96
|
+
img.raise_for_status()
|
|
97
|
+
images.append(GeneratedImage(data=img.content))
|
|
98
|
+
else:
|
|
99
|
+
raise ProviderError(
|
|
100
|
+
f"{self.name} returned neither b64_json nor url"
|
|
101
|
+
)
|
|
102
|
+
return images
|
|
103
|
+
|
|
104
|
+
except ProviderError:
|
|
105
|
+
raise
|
|
106
|
+
except (requests.RequestException, ValueError) as e:
|
|
107
|
+
raise ProviderError(f"{self.name} error: {e}") from e
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class OpenAICompatibleProvider(OpenAIProvider):
|
|
111
|
+
name = "openai-compatible"
|
|
112
|
+
|
|
113
|
+
default_base_url = None # must be configured; never default to real OpenAI
|
|
114
|
+
default_model = "stable-diffusion"
|
|
115
|
+
require_api_key = False # local servers usually don't check
|
|
116
|
+
|
|
117
|
+
def _base_url(self, request: GenerateRequest) -> str:
|
|
118
|
+
base_url = request.base_url or os.getenv("OPENAI_BASE_URL")
|
|
119
|
+
if not base_url:
|
|
120
|
+
raise ProviderError(
|
|
121
|
+
"openai-compatible needs a base_url (config 'base_url', "
|
|
122
|
+
"providers.openai-compatible.base_url, or OPENAI_BASE_URL). "
|
|
123
|
+
"For the real OpenAI API use provider 'openai'."
|
|
124
|
+
)
|
|
125
|
+
return base_url.rstrip("/")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _error_detail(response: requests.Response) -> str:
|
|
129
|
+
"""Best-effort human-readable error from an OpenAI-style response."""
|
|
130
|
+
try:
|
|
131
|
+
body = response.json()
|
|
132
|
+
return body.get("error", {}).get("message") or str(body)
|
|
133
|
+
except ValueError:
|
|
134
|
+
return response.text[:300]
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""SwarmUI native-API provider (self-hosted).
|
|
2
|
+
|
|
3
|
+
SwarmUI does not speak the OpenAI /v1/images shape, so this uses its
|
|
4
|
+
GetNewSession -> GenerateText2Image -> fetch-path flow. Same approach as
|
|
5
|
+
slide-stream's SwarmUI provider, reduced to prompt-in / bytes-out.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
import requests
|
|
14
|
+
|
|
15
|
+
from limn.providers.base import (
|
|
16
|
+
GeneratedImage,
|
|
17
|
+
GenerateRequest,
|
|
18
|
+
ImageProvider,
|
|
19
|
+
ProviderError,
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class SwarmUIProvider(ImageProvider):
|
|
24
|
+
name = "swarmui"
|
|
25
|
+
|
|
26
|
+
def generate(self, request: GenerateRequest) -> list[GeneratedImage]:
|
|
27
|
+
base_url = request.base_url or os.getenv("SWARMUI_BASE_URL")
|
|
28
|
+
if not base_url:
|
|
29
|
+
raise ProviderError(
|
|
30
|
+
"SwarmUI needs a base_url (config 'base_url', "
|
|
31
|
+
"providers.swarmui.base_url, or SWARMUI_BASE_URL)."
|
|
32
|
+
)
|
|
33
|
+
base_url = base_url.rstrip("/")
|
|
34
|
+
|
|
35
|
+
api_key = request.api_key or os.getenv("SWARMUI_TOKEN")
|
|
36
|
+
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
session = requests.post(
|
|
40
|
+
f"{base_url}/API/GetNewSession",
|
|
41
|
+
json={},
|
|
42
|
+
headers=headers,
|
|
43
|
+
timeout=30,
|
|
44
|
+
)
|
|
45
|
+
session.raise_for_status()
|
|
46
|
+
session_id = session.json()["session_id"]
|
|
47
|
+
|
|
48
|
+
width, height = request.size
|
|
49
|
+
payload: dict[str, Any] = {
|
|
50
|
+
"session_id": session_id,
|
|
51
|
+
"prompt": request.prompt,
|
|
52
|
+
"images": request.count,
|
|
53
|
+
"width": width,
|
|
54
|
+
"height": height,
|
|
55
|
+
}
|
|
56
|
+
if request.model:
|
|
57
|
+
payload["model"] = request.model
|
|
58
|
+
if request.seed is not None:
|
|
59
|
+
payload["seed"] = request.seed
|
|
60
|
+
if request.negative:
|
|
61
|
+
payload["negativeprompt"] = request.negative
|
|
62
|
+
|
|
63
|
+
gen = requests.post(
|
|
64
|
+
f"{base_url}/API/GenerateText2Image",
|
|
65
|
+
json=payload,
|
|
66
|
+
headers=headers,
|
|
67
|
+
timeout=request.timeout,
|
|
68
|
+
)
|
|
69
|
+
gen.raise_for_status()
|
|
70
|
+
data = gen.json()
|
|
71
|
+
paths = data.get("images")
|
|
72
|
+
if not paths:
|
|
73
|
+
raise ProviderError(f"SwarmUI returned no images ({data})")
|
|
74
|
+
|
|
75
|
+
images: list[GeneratedImage] = []
|
|
76
|
+
for path in paths:
|
|
77
|
+
img = requests.get(
|
|
78
|
+
f"{base_url}/{str(path).lstrip('/')}",
|
|
79
|
+
headers=headers,
|
|
80
|
+
timeout=request.timeout,
|
|
81
|
+
)
|
|
82
|
+
img.raise_for_status()
|
|
83
|
+
images.append(GeneratedImage(data=img.content, seed=request.seed))
|
|
84
|
+
return images
|
|
85
|
+
|
|
86
|
+
except ProviderError:
|
|
87
|
+
raise
|
|
88
|
+
except (requests.RequestException, KeyError, ValueError) as e:
|
|
89
|
+
raise ProviderError(f"SwarmUI error: {e}") from e
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: limn
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A small, privacy-first text-to-image tool: type a prompt, get an image. Bring your own provider.
|
|
5
|
+
Project-URL: Homepage, https://github.com/michael-borck/limn
|
|
6
|
+
Project-URL: Repository, https://github.com/michael-borck/limn
|
|
7
|
+
Project-URL: Issues, https://github.com/michael-borck/limn/issues
|
|
8
|
+
Author-email: Michael Borck <michael.borck@curtin.edu.au>
|
|
9
|
+
License: MIT
|
|
10
|
+
Keywords: ai,cli,dall-e,image,imagen,swarmui,text-to-image
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
15
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
16
|
+
Classifier: Operating System :: OS Independent
|
|
17
|
+
Classifier: Programming Language :: Python :: 3
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
23
|
+
Requires-Python: >=3.10
|
|
24
|
+
Requires-Dist: pyyaml>=6.0.0
|
|
25
|
+
Requires-Dist: requests>=2.31.0
|
|
26
|
+
Requires-Dist: rich>=13.0.0
|
|
27
|
+
Requires-Dist: typer>=0.9.0
|
|
28
|
+
Provides-Extra: serve
|
|
29
|
+
Requires-Dist: fastapi>=0.110.0; extra == 'serve'
|
|
30
|
+
Requires-Dist: uvicorn>=0.29.0; extra == 'serve'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# πΌοΈ Limn
|
|
34
|
+
|
|
35
|
+
> *to **limn**: to depict or describe; to paint.*
|
|
36
|
+
|
|
37
|
+
A small, privacy-first tool that turns a text description into an image. Type a
|
|
38
|
+
prompt, get a picture β no account, minimal options, bring your own provider.
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
pip install limn
|
|
42
|
+
|
|
43
|
+
limn "a red bicycle against a brick wall" -o bike.png
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
- **Type a description β get an image.** That's the whole thing.
|
|
47
|
+
- **No account, private by default.** Your prompts and images are yours. The
|
|
48
|
+
only network egress is the generation request to the provider *you* chose.
|
|
49
|
+
- **Bring your own provider** β self-hosted (SwarmUI / any OpenAI-compatible
|
|
50
|
+
endpoint) or a cloud key (Google Imagen, OpenAI / DALLΒ·E). Swap with one
|
|
51
|
+
setting.
|
|
52
|
+
- **Minimal by design.** Prompt, size, count, seed. Want power? Install
|
|
53
|
+
something bigger β Limn stays lean.
|
|
54
|
+
|
|
55
|
+
## Quick start
|
|
56
|
+
|
|
57
|
+
Pick whichever provider you already have:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
# Google Imagen (cheap, ~$0.02/image on the Fast tier)
|
|
61
|
+
export GEMINI_API_KEY=...
|
|
62
|
+
limn "watercolour fox" --provider gemini
|
|
63
|
+
|
|
64
|
+
# OpenAI Images
|
|
65
|
+
export OPENAI_API_KEY=...
|
|
66
|
+
limn "watercolour fox" --provider openai
|
|
67
|
+
|
|
68
|
+
# Self-hosted SwarmUI
|
|
69
|
+
export SWARMUI_BASE_URL=https://image.example.org
|
|
70
|
+
limn "watercolour fox" --provider swarmui
|
|
71
|
+
|
|
72
|
+
# Any OpenAI-compatible /v1/images endpoint (LocalAI, ...)
|
|
73
|
+
export OPENAI_BASE_URL=http://localhost:8080/v1
|
|
74
|
+
limn "watercolour fox" --provider openai-compatible
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Then set your provider once so you never pass `--provider` again:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
limn --init-config # writes a commented ./limn.yaml template
|
|
81
|
+
mv limn.yaml ~/.limn.yaml # personal defaults, used everywhere
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
## Usage
|
|
85
|
+
|
|
86
|
+
```
|
|
87
|
+
limn "a red bicycle against a brick wall" -o bike.png
|
|
88
|
+
limn "watercolour fox" --provider swarmui --size 1024x1024 --count 4 --seed 42
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
| Option | What |
|
|
92
|
+
|---|---|
|
|
93
|
+
| `-p, --provider` | `swarmui`, `openai-compatible`, `openai`, `gemini` |
|
|
94
|
+
| `-m, --model` | Model name (provider-specific) |
|
|
95
|
+
| `-s, --size` | e.g. `1024x1024` (Imagen maps to the nearest aspect ratio) |
|
|
96
|
+
| `-n, --count` | Number of images (1β10) |
|
|
97
|
+
| `--seed` | Reproducibility, where the backend supports it (SwarmUI) |
|
|
98
|
+
| `--negative` | Negative prompt, where supported (SwarmUI) |
|
|
99
|
+
| `-o, --out` | Output file; default is a slug of the prompt, never overwritten |
|
|
100
|
+
| `-c, --config` | Explicit config file |
|
|
101
|
+
|
|
102
|
+
## Config
|
|
103
|
+
|
|
104
|
+
Layered, later wins: built-in defaults β `~/.limn.yaml` (your provider + keys,
|
|
105
|
+
set once) β `./limn.yaml` β CLI flags. Secrets are referenced as `${VAR}` so no
|
|
106
|
+
plaintext keys live in files:
|
|
107
|
+
|
|
108
|
+
```yaml
|
|
109
|
+
provider: swarmui
|
|
110
|
+
size: [1024, 1024]
|
|
111
|
+
|
|
112
|
+
providers: # keep several configured; swap with `provider:`
|
|
113
|
+
swarmui:
|
|
114
|
+
base_url: https://image.example.org
|
|
115
|
+
model: juggernautXL_v9
|
|
116
|
+
api_key: "${SWARMUI_TOKEN}"
|
|
117
|
+
gemini:
|
|
118
|
+
model: imagen-4.0-fast-generate-001
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Roadmap
|
|
122
|
+
|
|
123
|
+
A **CLI** (this release), then a simple **web UI** (`limn serve`: type β
|
|
124
|
+
generate β view β save / delete / regenerate), then a **Tauri desktop app**
|
|
125
|
+
with bring-your-own-provider, plus a rate-limited, non-storing **demo**.
|
|
126
|
+
|
|
127
|
+
π See **[SPEC.md](SPEC.md)** for the full specification.
|
|
128
|
+
|
|
129
|
+
Companion to [slide-stream](https://github.com/michael-borck/slide-stream),
|
|
130
|
+
which shares the same image-provider approach.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
limn/__init__.py,sha256=hrlI8rNm4W2cufPykhpG0q7FxNO_7L8OPKextyewWEE,81
|
|
2
|
+
limn/__main__.py,sha256=20im4P-aZuvqq6zGS8qkPDPpJZpnvJK-y2Ote0tzsiE,94
|
|
3
|
+
limn/cli.py,sha256=X6rVBo1rtUQD3cMGM5IaAyRMTggfjggs8IYq0578jDk,4673
|
|
4
|
+
limn/config.py,sha256=Nqfpje_pSuoOj0WUyBSecvuOlGpwJxfaKTCAdOJzd0Y,6393
|
|
5
|
+
limn/core.py,sha256=4RUkdiS6xlevBa8XStv-jNuNqASu8LubQTjdMaHFEbA,3891
|
|
6
|
+
limn/providers/__init__.py,sha256=DJ4PNz0DxHUSna1lg0H5MJpOf1AkX97vJ4IMW2VKK1Y,1286
|
|
7
|
+
limn/providers/base.py,sha256=BKD7NSjRec3qhOqfJ82T1adIek2mPsKrOqwbYGUq1ok,1938
|
|
8
|
+
limn/providers/gemini.py,sha256=dx2RqpYkPZ4ubyod_fFvh3xUeWyu2DpcCFvydKQl4h8,3731
|
|
9
|
+
limn/providers/openai_compat.py,sha256=OG70WsqZ114GMKVjwKaTXGkmBAZDxQNorSEGwPbacjU,4789
|
|
10
|
+
limn/providers/swarmui.py,sha256=_3_KDF3d1KSoY7Vo-uZgNp_G1JO48veT3MWJ0bp-tTg,2909
|
|
11
|
+
limn-0.1.0.dist-info/METADATA,sha256=951QWs68XS6PZu9bjVngTP7AbUU10rViL9HKoiDE5gA,4523
|
|
12
|
+
limn-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
13
|
+
limn-0.1.0.dist-info/entry_points.txt,sha256=qUtzSMDWyhzuK3-npRPNuckrEs37fmY3x2qieAdiMT0,38
|
|
14
|
+
limn-0.1.0.dist-info/RECORD,,
|