lcode-cli 0.40.0__py3-none-any.whl → 0.42.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.
- lcode/__init__.py +1 -1
- lcode/agent.py +17 -1
- lcode/api.py +2 -0
- lcode/cli.py +72 -14
- lcode/comfy.py +18 -22
- lcode/config.py +13 -0
- lcode/imageopts.py +207 -0
- lcode/media.py +239 -28
- lcode/media.toml +80 -0
- lcode/media_catalog.py +340 -0
- lcode/modes.py +1 -1
- lcode/preview.py +152 -0
- lcode/repl.py +150 -12
- lcode/sessions.py +2 -2
- lcode/workflows/flux2-klein-4b-t2i.json +2 -9
- lcode/workflows/z-image-turbo-t2i.json +31 -0
- {lcode_cli-0.40.0.dist-info → lcode_cli-0.42.0.dist-info}/METADATA +1 -1
- {lcode_cli-0.40.0.dist-info → lcode_cli-0.42.0.dist-info}/RECORD +21 -16
- {lcode_cli-0.40.0.dist-info → lcode_cli-0.42.0.dist-info}/WHEEL +0 -0
- {lcode_cli-0.40.0.dist-info → lcode_cli-0.42.0.dist-info}/entry_points.txt +0 -0
- {lcode_cli-0.40.0.dist-info → lcode_cli-0.42.0.dist-info}/licenses/LICENSE +0 -0
lcode/__init__.py
CHANGED
lcode/agent.py
CHANGED
|
@@ -308,6 +308,8 @@ class Settings:
|
|
|
308
308
|
comfyui_url: str = "http://127.0.0.1:8188"
|
|
309
309
|
comfyui_path: str | None = None # a ComfyUI install lcode doesn't find by itself (lcode.comfy)
|
|
310
310
|
media_dir: str = ".lcode/media" # where image and video mode save their files (lcode.media)
|
|
311
|
+
image_model: str = "flux2-klein-4b" # the media catalog's model image mode uses (lcode.media_catalog)
|
|
312
|
+
preview: str = "auto" # images in the terminal: auto, kitty, iterm, blocks or off (lcode.preview)
|
|
311
313
|
num_batch: int | None = None
|
|
312
314
|
keep_alive: str = "30m"
|
|
313
315
|
think: bool = True
|
|
@@ -374,6 +376,11 @@ class Agent:
|
|
|
374
376
|
self.interactive = True # False for `lcode -p`: no end-of-session questions
|
|
375
377
|
self.comfy_process = None # the ComfyUI lcode started for image and video mode, stopped when it exits
|
|
376
378
|
self.no_coding_model = "" # why there's no coding model, when lcode started in image or video mode without
|
|
379
|
+
from lcode.imageopts import Options as ImageOptions
|
|
380
|
+
|
|
381
|
+
self.image_options = ImageOptions() # image mode's defaults for this session (/options)
|
|
382
|
+
self.media_results: list[dict] = [] # the session's images, numbered (/gallery, /vary, /open)
|
|
383
|
+
self.last_request: tuple[str, ImageOptions] | None = None # for /again
|
|
377
384
|
self.reflected = 0 # messages before this index were already checked for notes to remember
|
|
378
385
|
self._memory: memory_notes.Memory | None = None
|
|
379
386
|
self._agent_types: tuple[Path, dict[str, subagents.AgentType], list[str]] | None = None
|
|
@@ -678,8 +685,10 @@ class Agent:
|
|
|
678
685
|
return any(m.get("role") == "user" for m in self.messages)
|
|
679
686
|
|
|
680
687
|
def save(self) -> None:
|
|
681
|
-
if not (self.has_conversation() or self.session_name):
|
|
688
|
+
if not (self.has_conversation() or self.session_name or self.media_results):
|
|
682
689
|
return # nothing worth resuming
|
|
690
|
+
if not self.session_title and not self.has_conversation() and self.media_results:
|
|
691
|
+
self.session_title = " ".join(self.media_results[0]["prompt"].split())[:60] # an image session
|
|
683
692
|
self.session_title = self.session_title or sessions.title_from(self.messages)
|
|
684
693
|
f = self.session_file()
|
|
685
694
|
f.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -687,6 +696,8 @@ class Agent:
|
|
|
687
696
|
"cwd": str(self.workspace), # where `lcode -c` there finds it, wherever the shell moved
|
|
688
697
|
"model": self.settings.model,
|
|
689
698
|
"mode": self.settings.mode,
|
|
699
|
+
"image_model": self.settings.image_model,
|
|
700
|
+
"media": {"results": self.media_results, "options": self.image_options.to_json()},
|
|
690
701
|
"name": self.session_name,
|
|
691
702
|
"title": self.session_title,
|
|
692
703
|
"messages": self.messages,
|
|
@@ -735,6 +746,11 @@ class Agent:
|
|
|
735
746
|
self.session_name = data.get("name", "")
|
|
736
747
|
if data.get("mode") in ("code", "image", "video"):
|
|
737
748
|
self.settings.mode = data["mode"] # a session belongs to the mode it was made in
|
|
749
|
+
if isinstance(data.get("image_model"), str):
|
|
750
|
+
self.settings.image_model = data["image_model"]
|
|
751
|
+
media = data.get("media") or {}
|
|
752
|
+
self.media_results = [r for r in media.get("results") or [] if isinstance(r, dict) and "path" in r]
|
|
753
|
+
self.image_options = type(self.image_options).from_json(media.get("options") or {})
|
|
738
754
|
self.session_title = data.get("title") or sessions.title_from(self.messages)
|
|
739
755
|
self.tools.read_mtimes.clear() # files may have changed since; the model must read them again
|
|
740
756
|
self.tools.todos = list(data.get("todos") or [])
|
lcode/api.py
CHANGED
|
@@ -200,6 +200,8 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
|
|
|
200
200
|
comfyui_url=cfg["comfyui_url"],
|
|
201
201
|
comfyui_path=cfg["comfyui_path"],
|
|
202
202
|
media_dir=cfg["media_dir"],
|
|
203
|
+
image_model=cfg["image_model"],
|
|
204
|
+
preview=cfg["preview"],
|
|
203
205
|
web=switch(options.web, cfg["web"], "on"),
|
|
204
206
|
search_backend=cfg["search_backend"],
|
|
205
207
|
searxng_url=cfg["searxng_url"],
|
lcode/cli.py
CHANGED
|
@@ -777,9 +777,10 @@ def exit_on_hangup() -> None:
|
|
|
777
777
|
|
|
778
778
|
def comfyui_status(cfg: dict) -> tuple[str, bool | None]:
|
|
779
779
|
"""lcode doctor's ComfyUI line: running or found, and whether the image model is there."""
|
|
780
|
-
from lcode import comfy
|
|
780
|
+
from lcode import comfy
|
|
781
|
+
from lcode import media_catalog as catalog
|
|
781
782
|
|
|
782
|
-
|
|
783
|
+
model = catalog.load().find(cfg.get("image_model") or "") or catalog.load().models[catalog.DEFAULT_IMAGE]
|
|
783
784
|
server = comfy.Server(cfg["comfyui_url"])
|
|
784
785
|
stats = server.stats()
|
|
785
786
|
install = None if stats else comfy.find_install(cfg["comfyui_path"])
|
|
@@ -787,14 +788,13 @@ def comfyui_status(cfg: dict) -> tuple[str, bool | None]:
|
|
|
787
788
|
return "not found: image mode offers to install it (or lcode config set comfyui_path <folder>)", None
|
|
788
789
|
if stats:
|
|
789
790
|
where = f"{(stats.get('system') or {}).get('comfyui_version', '?')} running at {server.url}"
|
|
790
|
-
installed = {folder: set(server.models(folder)) for folder in workflow.models}
|
|
791
791
|
else:
|
|
792
792
|
where = f"{install.path} (lcode starts it in image mode)"
|
|
793
|
-
|
|
794
|
-
missing = [f"{f}/{names[0]}" for f, names in workflow.models.items() if not installed[f] & set(names)]
|
|
793
|
+
_, missing = catalog.chosen_files(model, catalog.lister(server if stats else None, install))
|
|
795
794
|
if missing:
|
|
796
|
-
|
|
797
|
-
|
|
795
|
+
size = sum(f.gb for f in missing)
|
|
796
|
+
return f"{where} · {model.name} not installed: lcode media install {model.key} ({size:.1f} GB)", None
|
|
797
|
+
return f"{where} · image model: {model.name}", True
|
|
798
798
|
|
|
799
799
|
|
|
800
800
|
def media_request(agent, prompt: str, out: Console, quiet: bool) -> None:
|
|
@@ -812,7 +812,7 @@ def media_request(agent, prompt: str, out: Console, quiet: bool) -> None:
|
|
|
812
812
|
api.close_agent(agent)
|
|
813
813
|
sys.exit(2)
|
|
814
814
|
try:
|
|
815
|
-
made = media.
|
|
815
|
+
made = media.request(agent, prompt)
|
|
816
816
|
except (media.MediaError, KeyboardInterrupt) as e:
|
|
817
817
|
message = str(e) or "Cancelled."
|
|
818
818
|
if quiet:
|
|
@@ -826,12 +826,13 @@ def media_request(agent, prompt: str, out: Console, quiet: bool) -> None:
|
|
|
826
826
|
"type": "result",
|
|
827
827
|
"status": "success",
|
|
828
828
|
"mode": "image",
|
|
829
|
-
"files": [str(path) for path in
|
|
830
|
-
"model": made.model,
|
|
831
|
-
"seed": made.seed,
|
|
832
|
-
"
|
|
833
|
-
"
|
|
834
|
-
"
|
|
829
|
+
"files": [str(path) for one in made for path in one.files],
|
|
830
|
+
"model": made[0].model,
|
|
831
|
+
"seed": made[0].seed,
|
|
832
|
+
"seeds": [one.seed for one in made],
|
|
833
|
+
"width": made[0].width,
|
|
834
|
+
"height": made[0].height,
|
|
835
|
+
"seconds": round(sum(one.seconds for one in made), 1),
|
|
835
836
|
}
|
|
836
837
|
print(json.dumps(result, ensure_ascii=False), flush=True)
|
|
837
838
|
api.close_agent(agent)
|
|
@@ -909,6 +910,13 @@ def build_subparsers() -> dict[str, argparse.ArgumentParser]:
|
|
|
909
910
|
subs["setup"] = p
|
|
910
911
|
subs["models"] = argparse.ArgumentParser(prog="lcode models", description="List models and how they fit here.")
|
|
911
912
|
subs["doctor"] = argparse.ArgumentParser(prog="lcode doctor", description="Check the installation.")
|
|
913
|
+
p = argparse.ArgumentParser(
|
|
914
|
+
prog="lcode media", description="Image and video models: list them, with how they fit here, or install one."
|
|
915
|
+
)
|
|
916
|
+
p.add_argument("action", nargs="?", choices=["list", "install"], default="list")
|
|
917
|
+
p.add_argument("model", nargs="?", help="with install: a model key from the list, e.g. z-image-turbo")
|
|
918
|
+
p.add_argument("-y", "--yes", action="store_true", help="don't ask before downloading")
|
|
919
|
+
subs["media"] = p
|
|
912
920
|
p = argparse.ArgumentParser(prog="lcode config", description="Show or change settings.")
|
|
913
921
|
p.add_argument("action", nargs="?", choices=["show", "set", "unset", "path"], default="show")
|
|
914
922
|
p.add_argument("key", nargs="?", choices=list(config.SETTINGS))
|
|
@@ -1065,6 +1073,55 @@ def cmd_index(args) -> None:
|
|
|
1065
1073
|
)
|
|
1066
1074
|
|
|
1067
1075
|
|
|
1076
|
+
def cmd_media(args) -> None:
|
|
1077
|
+
"""lcode media [list] | install <model>: the media catalog, what's installed in ComfyUI, downloads."""
|
|
1078
|
+
from rich.prompt import Confirm
|
|
1079
|
+
|
|
1080
|
+
from lcode import comfy, media
|
|
1081
|
+
from lcode import media_catalog as catalog
|
|
1082
|
+
|
|
1083
|
+
try:
|
|
1084
|
+
cfg = config.load()
|
|
1085
|
+
except ConfigError as e:
|
|
1086
|
+
fail(str(e))
|
|
1087
|
+
cat, hw = catalog.load(), detect()
|
|
1088
|
+
server = comfy.Server(cfg["comfyui_url"])
|
|
1089
|
+
install = comfy.find_install(cfg["comfyui_path"])
|
|
1090
|
+
running = server.stats() is not None
|
|
1091
|
+
installed = catalog.lister(server if running else None, install) if running or install else None
|
|
1092
|
+
if args.action == "install":
|
|
1093
|
+
model = cat.find(args.model or "")
|
|
1094
|
+
if model is None:
|
|
1095
|
+
fail(f"no media model {args.model!r}: lcode media lists them")
|
|
1096
|
+
if not running and install is None:
|
|
1097
|
+
fail(f"lcode needs ComfyUI to install models into. {comfy.install_hint()}")
|
|
1098
|
+
ask = None if args.yes else (lambda q: Confirm.ask(q, default=True, console=console))
|
|
1099
|
+
try:
|
|
1100
|
+
saved = catalog.install(model, server if running else None, install, console, ask)
|
|
1101
|
+
except catalog.CatalogError as e:
|
|
1102
|
+
fail(str(e))
|
|
1103
|
+
how = f" ({len(saved)} file(s) downloaded)." if saved else "."
|
|
1104
|
+
console.print(f"[green]{model.name} is installed[/]{how}")
|
|
1105
|
+
return
|
|
1106
|
+
where = (
|
|
1107
|
+
f"ComfyUI at {server.url}" if running else (f"{install.path} (not running)" if install else "no ComfyUI found")
|
|
1108
|
+
)
|
|
1109
|
+
console.print(f"[dim]{hw.describe()} · {where} · image model: {cfg['image_model']}[/]\n")
|
|
1110
|
+
for mode in ("image", "video"):
|
|
1111
|
+
models = cat.for_mode(mode)
|
|
1112
|
+
console.print(f"[bold]{mode.capitalize()} models[/]")
|
|
1113
|
+
if not models:
|
|
1114
|
+
console.print(f" [dim]{media.SOON[mode]}[/]\n")
|
|
1115
|
+
continue
|
|
1116
|
+
for m in models:
|
|
1117
|
+
line = escape(catalog.describe(m, hw, installed))
|
|
1118
|
+
console.print(f" [bold]{m.key:<16}[/] {escape(m.name)} [dim]· {line}[/]")
|
|
1119
|
+
if m.measured:
|
|
1120
|
+
console.print(f" {'':<16} [dim]{escape(m.measured)}[/]")
|
|
1121
|
+
console.print()
|
|
1122
|
+
console.print("[dim]Install one: lcode media install <model> · choose in image mode: /model <model>[/]")
|
|
1123
|
+
|
|
1124
|
+
|
|
1068
1125
|
def cmd_mcp(args) -> None:
|
|
1069
1126
|
from lcode.mcp.commands import run
|
|
1070
1127
|
|
|
@@ -1075,6 +1132,7 @@ COMMANDS = {
|
|
|
1075
1132
|
"setup": cmd_setup,
|
|
1076
1133
|
"models": cmd_models,
|
|
1077
1134
|
"doctor": cmd_doctor,
|
|
1135
|
+
"media": cmd_media,
|
|
1078
1136
|
"config": cmd_config,
|
|
1079
1137
|
"bench": cmd_bench,
|
|
1080
1138
|
"mcp": cmd_mcp,
|
lcode/comfy.py
CHANGED
|
@@ -254,6 +254,14 @@ class Server:
|
|
|
254
254
|
except (requests.RequestException, ValueError):
|
|
255
255
|
return []
|
|
256
256
|
|
|
257
|
+
def folder_paths(self) -> dict[str, list[str]]:
|
|
258
|
+
"""Where ComfyUI keeps each kind of model (its own folders and extra_model_paths.yaml's)."""
|
|
259
|
+
try:
|
|
260
|
+
r = requests.get(self.url + "/internal/folder_paths", timeout=10)
|
|
261
|
+
return r.json() if r.status_code == 200 else {}
|
|
262
|
+
except (requests.RequestException, ValueError):
|
|
263
|
+
return {}
|
|
264
|
+
|
|
257
265
|
def upload(self, path: Path) -> str:
|
|
258
266
|
"""Upload an input image; the name to use in a workflow."""
|
|
259
267
|
with path.open("rb") as f:
|
|
@@ -420,32 +428,18 @@ class Events:
|
|
|
420
428
|
|
|
421
429
|
@dataclass
|
|
422
430
|
class Workflow:
|
|
431
|
+
"""A graph in ComfyUI's API format with `$name` placeholders: the model's files (by role, from media.toml),
|
|
432
|
+
the prompt, the seed and the settings in `defaults`."""
|
|
433
|
+
|
|
423
434
|
key: str
|
|
424
|
-
name: str
|
|
425
|
-
task: str
|
|
426
|
-
models: dict[str, list[str]] # ComfyUI folder -> file names it can use, preferred first
|
|
427
|
-
defaults: dict
|
|
428
435
|
graph: dict
|
|
436
|
+
defaults: dict = field(default_factory=dict)
|
|
429
437
|
labels: dict[str, str] = field(default_factory=dict) # node id -> what it does, for progress
|
|
430
438
|
|
|
431
439
|
|
|
432
440
|
def load_workflow(key: str) -> Workflow:
|
|
433
441
|
data = json.loads(resources.files("lcode.workflows").joinpath(f"{key}.json").read_text())
|
|
434
|
-
return Workflow(key, data["
|
|
435
|
-
data.get("labels", {})) # fmt: skip
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
def choose_files(server: Server, workflow: Workflow) -> tuple[dict[str, str], list[str]]:
|
|
439
|
-
"""For each model the workflow needs, the installed file to use; and the folders where none is installed."""
|
|
440
|
-
chosen, missing = {}, []
|
|
441
|
-
for folder, names in workflow.models.items():
|
|
442
|
-
installed = set(server.models(folder))
|
|
443
|
-
found = next((n for n in names if n in installed), None)
|
|
444
|
-
if found:
|
|
445
|
-
chosen[folder] = found
|
|
446
|
-
else:
|
|
447
|
-
missing.append(f"{folder}/{names[0]}")
|
|
448
|
-
return chosen, missing
|
|
442
|
+
return Workflow(key, data["graph"], data.get("defaults", {}), data.get("labels", {}))
|
|
449
443
|
|
|
450
444
|
|
|
451
445
|
PLACEHOLDER = re.compile(r"^\$([a-z_]+)$")
|
|
@@ -566,13 +560,15 @@ def describe_history_error(entry: dict) -> str:
|
|
|
566
560
|
|
|
567
561
|
|
|
568
562
|
def save(server: Server, outputs: dict, folder: Path, stem: str, meta: dict) -> list[Path]:
|
|
569
|
-
"""Fetch the job's files into `folder`: stem-
|
|
563
|
+
"""Fetch the job's files into `folder`: stem.png (stem-2.png if that's taken), or stem-1.png, … for several,
|
|
564
|
+
each with its settings in a .json next to it."""
|
|
570
565
|
folder.mkdir(parents=True, exist_ok=True)
|
|
571
566
|
items = [item for node in outputs.values() for kind in OUTPUT_KINDS for item in node.get(kind, [])]
|
|
567
|
+
items = [it for it in items if it.get("type", "output") == "output"]
|
|
572
568
|
saved = []
|
|
573
|
-
for i, item in enumerate(
|
|
569
|
+
for i, item in enumerate(items, 1):
|
|
574
570
|
ext = Path(item["filename"]).suffix or ".png"
|
|
575
|
-
path = unique(folder / f"{stem}-{i}{ext}")
|
|
571
|
+
path = unique(folder / (f"{stem}{ext}" if len(items) == 1 else f"{stem}-{i}{ext}"))
|
|
576
572
|
data = server.fetch(item)
|
|
577
573
|
if ext.lower() == ".png":
|
|
578
574
|
data = with_text(data, "lcode", json.dumps(meta, ensure_ascii=False))
|
lcode/config.py
CHANGED
|
@@ -45,6 +45,8 @@ SETTINGS: dict[str, tuple[object, type, str]] = {
|
|
|
45
45
|
"comfyui_url": ("http://127.0.0.1:8188", str, "ComfyUI, for image and video mode"),
|
|
46
46
|
"comfyui_path": (None, str, "your ComfyUI folder, if lcode doesn't find it (lcode starts it when needed)"),
|
|
47
47
|
"media_dir": (".lcode/media", str, "where images and videos go: a folder in the project, or an absolute path"),
|
|
48
|
+
"image_model": ("flux2-klein-4b", str, "the model image mode uses: a key from `lcode media`"),
|
|
49
|
+
"preview": ("auto", str, "images in the terminal: auto | kitty | iterm | blocks (colour text) | off"),
|
|
48
50
|
"think": (True, bool, "let the model reason before answering (slower, better)"),
|
|
49
51
|
"web": ("on", str, "web search and page fetching for the model: on | ask | off"),
|
|
50
52
|
"search_backend": ("auto", str, "auto | ollama | brave | tavily | searxng (keys come from environment variables)"),
|
|
@@ -210,6 +212,17 @@ def coerce(key: str, value: object) -> object:
|
|
|
210
212
|
raise ConfigError(f"permission_mode must be one of {', '.join(PERMISSION_MODES)}")
|
|
211
213
|
if key == "mode" and value not in WORK_MODES:
|
|
212
214
|
raise ConfigError(f"mode must be one of {', '.join(WORK_MODES)}")
|
|
215
|
+
if key == "preview":
|
|
216
|
+
from lcode.preview import PROTOCOLS
|
|
217
|
+
|
|
218
|
+
if value not in PROTOCOLS:
|
|
219
|
+
raise ConfigError(f"preview must be one of {', '.join(PROTOCOLS)}")
|
|
220
|
+
if key == "image_model":
|
|
221
|
+
from lcode import media_catalog
|
|
222
|
+
|
|
223
|
+
keys = [m.key for m in media_catalog.load().for_mode("image")]
|
|
224
|
+
if value not in keys:
|
|
225
|
+
raise ConfigError(f"image_model must be one of {', '.join(keys)} (see lcode media)")
|
|
213
226
|
if typ is bool and isinstance(value, str):
|
|
214
227
|
if value.lower() not in ("true", "false", "1", "0", "yes", "no", "on", "off"):
|
|
215
228
|
raise ConfigError(f"{key} must be true or false")
|
lcode/imageopts.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Image options: how many, what shape, which seed, how good, what to avoid, which style.
|
|
2
|
+
|
|
3
|
+
They're typed in the request (`a red fox --n 4 --ar 16:9 --seed 42 --quality best --style photo --no "people"`), or
|
|
4
|
+
set for the session with `/options n=4 ar=16:9`. Flags in a request win over the session's options for that request.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
import re
|
|
11
|
+
import shlex
|
|
12
|
+
from dataclasses import asdict, dataclass, replace
|
|
13
|
+
|
|
14
|
+
QUALITIES = ("fast", "balanced", "best")
|
|
15
|
+
MAX_COUNT = 8
|
|
16
|
+
SIZES = { # names for aspect ratios
|
|
17
|
+
"square": "1:1",
|
|
18
|
+
"portrait": "3:4",
|
|
19
|
+
"landscape": "4:3",
|
|
20
|
+
"wide": "16:9",
|
|
21
|
+
"tall": "9:16",
|
|
22
|
+
"photo": "3:2",
|
|
23
|
+
"cinema": "21:9",
|
|
24
|
+
}
|
|
25
|
+
STYLES = {
|
|
26
|
+
"photo": "professional photograph, natural light, sharp focus, 35mm lens",
|
|
27
|
+
"cinematic": "cinematic film still, dramatic lighting, shallow depth of field, color graded",
|
|
28
|
+
"anime": "anime illustration, clean line art, vibrant cel shading",
|
|
29
|
+
"watercolor": "watercolor painting, soft washes, visible paper texture",
|
|
30
|
+
"oil": "oil painting, visible brush strokes, rich colors",
|
|
31
|
+
"pixel": "pixel art, 16-bit, crisp pixels, limited palette",
|
|
32
|
+
"3d": "3D render, soft global illumination, studio lighting",
|
|
33
|
+
"sketch": "pencil sketch, graphite, cross-hatching, on paper",
|
|
34
|
+
"comic": "comic book art, bold ink outlines, halftone shading",
|
|
35
|
+
"flat": "flat vector illustration, minimal, bold shapes",
|
|
36
|
+
}
|
|
37
|
+
ALIASES = {
|
|
38
|
+
"n": "n",
|
|
39
|
+
"count": "n",
|
|
40
|
+
"ar": "size",
|
|
41
|
+
"aspect": "size",
|
|
42
|
+
"size": "size",
|
|
43
|
+
"seed": "seed",
|
|
44
|
+
"quality": "quality",
|
|
45
|
+
"q": "quality",
|
|
46
|
+
"no": "negative",
|
|
47
|
+
"negative": "negative",
|
|
48
|
+
"style": "style",
|
|
49
|
+
}
|
|
50
|
+
FLAG = re.compile(r"(?:^|\s)--?(" + "|".join(sorted(ALIASES, key=len, reverse=True)) + r")\b(?=[\s=]|$)")
|
|
51
|
+
DIMENSIONS = re.compile(r"^(\d{2,4})\s*[x×]\s*(\d{2,4})$")
|
|
52
|
+
RATIO = re.compile(r"^(\d{1,2})\s*:\s*(\d{1,2})$")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class OptionError(ValueError):
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class Options:
|
|
61
|
+
n: int = 1
|
|
62
|
+
size: str = "1:1" # an aspect ratio (16:9), a name (wide) or pixels (1344x768)
|
|
63
|
+
seed: int | None = None # None: a new random seed for each image
|
|
64
|
+
quality: str = "balanced"
|
|
65
|
+
negative: str = ""
|
|
66
|
+
style: str = ""
|
|
67
|
+
|
|
68
|
+
def describe(self) -> str:
|
|
69
|
+
seed = "random" if self.seed is None else str(self.seed)
|
|
70
|
+
parts = [f"n={self.n}", f"size={self.size}", f"seed={seed}", f"quality={self.quality}"]
|
|
71
|
+
if self.style:
|
|
72
|
+
parts.append(f"style={self.style}")
|
|
73
|
+
if self.negative:
|
|
74
|
+
parts.append(f"no={self.negative!r}")
|
|
75
|
+
return " ".join(parts)
|
|
76
|
+
|
|
77
|
+
def to_json(self) -> dict:
|
|
78
|
+
return asdict(self)
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def from_json(cls, data: dict) -> Options:
|
|
82
|
+
known = {k: v for k, v in (data or {}).items() if k in cls.__dataclass_fields__}
|
|
83
|
+
try:
|
|
84
|
+
return set_values(cls(), known)
|
|
85
|
+
except OptionError:
|
|
86
|
+
return cls()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def set_values(options: Options, values: dict[str, object]) -> Options:
|
|
90
|
+
"""A copy of `options` with `values` (by name or alias) applied. Raises OptionError saying what's wrong."""
|
|
91
|
+
out = replace(options)
|
|
92
|
+
for raw_key, raw in values.items():
|
|
93
|
+
key = ALIASES.get(str(raw_key).lower().lstrip("-"))
|
|
94
|
+
if key is None:
|
|
95
|
+
raise OptionError(f"unknown option {raw_key!r}: use n, ar (or size), seed, quality, style or no")
|
|
96
|
+
value = str(raw).strip() if raw is not None else ""
|
|
97
|
+
if key == "n":
|
|
98
|
+
if not value.isdigit() or not 1 <= int(value) <= MAX_COUNT:
|
|
99
|
+
raise OptionError(f"n is how many images: 1 to {MAX_COUNT}")
|
|
100
|
+
out.n = int(value)
|
|
101
|
+
elif key == "size":
|
|
102
|
+
out.size = check_size(value)
|
|
103
|
+
elif key == "seed":
|
|
104
|
+
if value.lower() in ("random", "none", ""):
|
|
105
|
+
out.seed = None
|
|
106
|
+
elif value.isdigit() and int(value) < 2**63:
|
|
107
|
+
out.seed = int(value)
|
|
108
|
+
else:
|
|
109
|
+
raise OptionError("seed is a whole number, or random")
|
|
110
|
+
elif key == "quality":
|
|
111
|
+
if value.lower() not in QUALITIES:
|
|
112
|
+
raise OptionError(f"quality is {', '.join(QUALITIES)}")
|
|
113
|
+
out.quality = value.lower()
|
|
114
|
+
elif key == "style":
|
|
115
|
+
if value.lower() in ("none", "off", ""):
|
|
116
|
+
out.style = ""
|
|
117
|
+
elif value.lower() in STYLES:
|
|
118
|
+
out.style = value.lower()
|
|
119
|
+
else:
|
|
120
|
+
raise OptionError(f"style is one of {', '.join(STYLES)} (or none)")
|
|
121
|
+
elif key == "negative":
|
|
122
|
+
out.negative = "" if value.lower() in ("none", "off") else value
|
|
123
|
+
return out
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def check_size(value: str) -> str:
|
|
127
|
+
v = value.lower().replace(" ", "")
|
|
128
|
+
if v in SIZES:
|
|
129
|
+
return v
|
|
130
|
+
if RATIO.match(v):
|
|
131
|
+
w, h = (int(x) for x in RATIO.match(v).groups())
|
|
132
|
+
if w and h and 1 / 4 <= w / h <= 4:
|
|
133
|
+
return f"{w}:{h}"
|
|
134
|
+
m = DIMENSIONS.match(v)
|
|
135
|
+
if m:
|
|
136
|
+
w, h = (int(x) for x in m.groups())
|
|
137
|
+
if 256 <= w <= 2048 and 256 <= h <= 2048:
|
|
138
|
+
return f"{w}x{h}"
|
|
139
|
+
raise OptionError("a size in pixels is 256 to 2048 on each side, e.g. 1344x768")
|
|
140
|
+
raise OptionError(f"size is a ratio (16:9), pixels (1344x768) or one of {', '.join(SIZES)}")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def parse(text: str, defaults: Options) -> tuple[str, Options]:
|
|
144
|
+
"""The prompt without its flags, and the options for this request. Raises OptionError."""
|
|
145
|
+
matches = list(FLAG.finditer(text))
|
|
146
|
+
if not matches:
|
|
147
|
+
return text.strip(), defaults
|
|
148
|
+
prompt = text[: matches[0].start()]
|
|
149
|
+
values: dict[str, str] = {}
|
|
150
|
+
for i, m in enumerate(matches):
|
|
151
|
+
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
|
|
152
|
+
rest = text[m.end() : end].strip().lstrip("=").strip()
|
|
153
|
+
try:
|
|
154
|
+
words = shlex.split(rest)
|
|
155
|
+
except ValueError:
|
|
156
|
+
words = rest.split()
|
|
157
|
+
key = ALIASES[m.group(1).lower()]
|
|
158
|
+
if key == "negative":
|
|
159
|
+
values[key] = " ".join(words)
|
|
160
|
+
else:
|
|
161
|
+
if not words:
|
|
162
|
+
raise OptionError(f"--{m.group(1)} needs a value")
|
|
163
|
+
values[key] = words[0]
|
|
164
|
+
if len(words) > 1: # words after a flag's value belong to the prompt
|
|
165
|
+
prompt += " " + " ".join(words[1:])
|
|
166
|
+
return prompt.strip(), set_values(defaults, values)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def parse_assignments(text: str) -> dict[str, str]:
|
|
170
|
+
"""`n=4 ar=16:9 no="blurry, text"` -> {"n": "4", "ar": "16:9", "no": "blurry, text"}."""
|
|
171
|
+
try:
|
|
172
|
+
words = shlex.split(text)
|
|
173
|
+
except ValueError as e:
|
|
174
|
+
raise OptionError(f"can't read {text!r}: {e}") from e
|
|
175
|
+
out = {}
|
|
176
|
+
for word in words:
|
|
177
|
+
key, sep, value = word.partition("=")
|
|
178
|
+
if not sep:
|
|
179
|
+
raise OptionError(f"write options as name=value, e.g. n=4 ar=16:9 (not {word!r})")
|
|
180
|
+
out[key] = value
|
|
181
|
+
return out
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def dimensions(size: str, megapixels: float, multiple: int = 16) -> tuple[int, int]:
|
|
185
|
+
"""Width and height in pixels: from a ratio and the quality's megapixels, or exact pixels as given."""
|
|
186
|
+
m = DIMENSIONS.match(size)
|
|
187
|
+
if m:
|
|
188
|
+
w, h = (int(x) for x in m.groups())
|
|
189
|
+
return round_to(w, multiple), round_to(h, multiple)
|
|
190
|
+
ratio = SIZES.get(size, size)
|
|
191
|
+
w, h = (int(x) for x in ratio.split(":"))
|
|
192
|
+
area = megapixels * 1024 * 1024
|
|
193
|
+
width = math.sqrt(area * w / h)
|
|
194
|
+
return round_to(width, multiple), round_to(width * h / w, multiple)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def round_to(value: float, multiple: int) -> int:
|
|
198
|
+
return max(multiple, round(value / multiple) * multiple)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def replace_seed(options: Options, n: int | None = None) -> Options:
|
|
202
|
+
"""The options with a random seed again (for /again and /vary), and optionally another count."""
|
|
203
|
+
return replace(options, seed=None, n=n or options.n)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def styled(prompt: str, style: str) -> str:
|
|
207
|
+
return f"{prompt}, {STYLES[style]}" if style else prompt
|