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 CHANGED
@@ -3,7 +3,7 @@
3
3
  From a program: `from lcode import Session` (see lcode.api).
4
4
  """
5
5
 
6
- __version__ = "0.40.0"
6
+ __version__ = "0.42.0"
7
7
 
8
8
  API = ("Session", "Result", "Options", "SetupError")
9
9
 
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, media
780
+ from lcode import comfy
781
+ from lcode import media_catalog as catalog
781
782
 
782
- workflow = comfy.load_workflow(media.IMAGE_WORKFLOW)
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
- installed = {folder: set(comfy.local_models(install, folder)) for folder in workflow.models}
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
- return f"{where} · {workflow.name} not installed (missing {', '.join(missing)})", None
797
- return f"{where} · image model: {workflow.name}", True
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.generate_image(agent, prompt)
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 made.files],
830
- "model": made.model,
831
- "seed": made.seed,
832
- "width": made.width,
833
- "height": made.height,
834
- "seconds": round(made.seconds, 1),
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["name"], data["task"], data["models"], data.get("defaults", {}), data["graph"],
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-1.png, … with stem-1.json next to each."""
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((it for it in items if it.get("type", "output") == "output"), 1):
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