lcode-cli 0.41.0__py3-none-any.whl → 0.43.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.41.0"
6
+ __version__ = "0.43.0"
7
7
 
8
8
  API = ("Session", "Result", "Options", "SetupError")
9
9
 
lcode/agent.py CHANGED
@@ -309,6 +309,7 @@ class Settings:
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
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)
312
313
  num_batch: int | None = None
313
314
  keep_alive: str = "30m"
314
315
  think: bool = True
@@ -375,6 +376,12 @@ class Agent:
375
376
  self.interactive = True # False for `lcode -p`: no end-of-session questions
376
377
  self.comfy_process = None # the ComfyUI lcode started for image and video mode, stopped when it exits
377
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: dict | None = None # what /again repeats (lcode.media.redo)
384
+ self.image_refs: list[str] = [] # images every next request follows (/use)
378
385
  self.reflected = 0 # messages before this index were already checked for notes to remember
379
386
  self._memory: memory_notes.Memory | None = None
380
387
  self._agent_types: tuple[Path, dict[str, subagents.AgentType], list[str]] | None = None
@@ -679,8 +686,10 @@ class Agent:
679
686
  return any(m.get("role") == "user" for m in self.messages)
680
687
 
681
688
  def save(self) -> None:
682
- if not (self.has_conversation() or self.session_name):
689
+ if not (self.has_conversation() or self.session_name or self.media_results):
683
690
  return # nothing worth resuming
691
+ if not self.session_title and not self.has_conversation() and self.media_results:
692
+ self.session_title = " ".join(self.media_results[0]["prompt"].split())[:60] # an image session
684
693
  self.session_title = self.session_title or sessions.title_from(self.messages)
685
694
  f = self.session_file()
686
695
  f.parent.mkdir(parents=True, exist_ok=True)
@@ -689,6 +698,7 @@ class Agent:
689
698
  "model": self.settings.model,
690
699
  "mode": self.settings.mode,
691
700
  "image_model": self.settings.image_model,
701
+ "media": {"results": self.media_results, "options": self.image_options.to_json(), "refs": self.image_refs},
692
702
  "name": self.session_name,
693
703
  "title": self.session_title,
694
704
  "messages": self.messages,
@@ -739,6 +749,10 @@ class Agent:
739
749
  self.settings.mode = data["mode"] # a session belongs to the mode it was made in
740
750
  if isinstance(data.get("image_model"), str):
741
751
  self.settings.image_model = data["image_model"]
752
+ media = data.get("media") or {}
753
+ self.media_results = [r for r in media.get("results") or [] if isinstance(r, dict) and "path" in r]
754
+ self.image_options = type(self.image_options).from_json(media.get("options") or {})
755
+ self.image_refs = [r for r in media.get("refs") or [] if isinstance(r, str)]
742
756
  self.session_title = data.get("title") or sessions.title_from(self.messages)
743
757
  self.tools.read_mtimes.clear() # files may have changed since; the model must read them again
744
758
  self.tools.todos = list(data.get("todos") or [])
lcode/api.py CHANGED
@@ -201,6 +201,7 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
201
201
  comfyui_path=cfg["comfyui_path"],
202
202
  media_dir=cfg["media_dir"],
203
203
  image_model=cfg["image_model"],
204
+ preview=cfg["preview"],
204
205
  web=switch(options.web, cfg["web"], "on"),
205
206
  search_backend=cfg["search_backend"],
206
207
  searxng_url=cfg["searxng_url"],
lcode/cli.py CHANGED
@@ -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)
@@ -1106,9 +1107,13 @@ def cmd_media(args) -> None:
1106
1107
  f"ComfyUI at {server.url}" if running else (f"{install.path} (not running)" if install else "no ComfyUI found")
1107
1108
  )
1108
1109
  console.print(f"[dim]{hw.describe()} · {where} · image model: {cfg['image_model']}[/]\n")
1109
- for mode in ("image", "video"):
1110
+ for mode in ("image", "video", "tool"):
1110
1111
  models = cat.for_mode(mode)
1111
- console.print(f"[bold]{mode.capitalize()} models[/]")
1112
+ console.print(
1113
+ "[bold]Tools for images[/] [dim](/upscale, /nobg)[/]"
1114
+ if mode == "tool"
1115
+ else f"[bold]{mode.capitalize()} models[/]"
1116
+ )
1112
1117
  if not models:
1113
1118
  console.print(f" [dim]{media.SOON[mode]}[/]\n")
1114
1119
  continue
lcode/comfy.py CHANGED
@@ -336,8 +336,9 @@ def describe_rejection(r: requests.Response) -> str:
336
336
  lines = [str((data.get("error") or {}).get("message") or "ComfyUI refused the workflow")]
337
337
  for node, info in (data.get("node_errors") or {}).items():
338
338
  for err in info.get("errors", [])[:3]:
339
- detail = err.get("details") or err.get("message") or ""
340
- lines.append(f"{info.get('class_type', 'node')} ({node}): {detail}")
339
+ message, detail = err.get("message") or "", err.get("details") or ""
340
+ why = f"{message}: {detail}" if message and detail and detail not in message else (detail or message)
341
+ lines.append(f"{info.get('class_type', 'node')} ({node}): {why}")
341
342
  return "; ".join(lines)
342
343
 
343
344
 
@@ -560,13 +561,15 @@ def describe_history_error(entry: dict) -> str:
560
561
 
561
562
 
562
563
  def save(server: Server, outputs: dict, folder: Path, stem: str, meta: dict) -> list[Path]:
563
- """Fetch the job's files into `folder`: stem-1.png, … with stem-1.json next to each."""
564
+ """Fetch the job's files into `folder`: stem.png (stem-2.png if that's taken), or stem-1.png, … for several,
565
+ each with its settings in a .json next to it."""
564
566
  folder.mkdir(parents=True, exist_ok=True)
565
567
  items = [item for node in outputs.values() for kind in OUTPUT_KINDS for item in node.get(kind, [])]
568
+ items = [it for it in items if it.get("type", "output") == "output"]
566
569
  saved = []
567
- for i, item in enumerate((it for it in items if it.get("type", "output") == "output"), 1):
570
+ for i, item in enumerate(items, 1):
568
571
  ext = Path(item["filename"]).suffix or ".png"
569
- path = unique(folder / f"{stem}-{i}{ext}")
572
+ path = unique(folder / (f"{stem}{ext}" if len(items) == 1 else f"{stem}-{i}{ext}"))
570
573
  data = server.fetch(item)
571
574
  if ext.lower() == ".png":
572
575
  data = with_text(data, "lcode", json.dumps(meta, ensure_ascii=False))
lcode/config.py CHANGED
@@ -46,6 +46,7 @@ SETTINGS: dict[str, tuple[object, type, str]] = {
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
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"),
49
50
  "think": (True, bool, "let the model reason before answering (slower, better)"),
50
51
  "web": ("on", str, "web search and page fetching for the model: on | ask | off"),
51
52
  "search_backend": ("auto", str, "auto | ollama | brave | tavily | searxng (keys come from environment variables)"),
@@ -211,6 +212,11 @@ def coerce(key: str, value: object) -> object:
211
212
  raise ConfigError(f"permission_mode must be one of {', '.join(PERMISSION_MODES)}")
212
213
  if key == "mode" and value not in WORK_MODES:
213
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)}")
214
220
  if key == "image_model":
215
221
  from lcode import media_catalog
216
222
 
lcode/edits.py ADDED
@@ -0,0 +1,283 @@
1
+ """Image edits as changes to a ComfyUI workflow: reference images, inpainting through a mask, outpainting.
2
+
3
+ A model that edits with reference latents (FLUX.2 klein) takes the images through its own text-to-image workflow:
4
+ each image is scaled, encoded by the VAE and attached to both conditionings before the guider, and the instruction is
5
+ the prompt. On top of that:
6
+
7
+ - **Inpainting** keeps the original outside a mask: the edited picture is pasted into the original only where the
8
+ mask is white (with a soft edge).
9
+ - **Outpainting** puts the image on a bigger canvas, lets the model fill the new border, and pastes the original back,
10
+ so it stays exactly as it was.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import re
16
+ import struct
17
+ from pathlib import Path
18
+
19
+ BASE = 8000 # node ids lcode adds start here (the workflows' own are small numbers)
20
+ SIZE_NODE = str(BASE + 9)
21
+ IMAGE_TYPES = (".png", ".jpg", ".jpeg", ".webp", ".bmp")
22
+ EXTEND_HINT = (
23
+ "Replace the blurry border around the sharp picture with sharp, detailed scenery that continues the picture "
24
+ "outward, matching its light, perspective and style. Keep the sharp middle exactly as it is. No frame, no bars."
25
+ )
26
+
27
+
28
+ class EditError(ValueError):
29
+ pass
30
+
31
+
32
+ def node_of(graph: dict, class_type: str) -> str | None:
33
+ return next((nid for nid, node in graph.items() if node.get("class_type") == class_type), None)
34
+
35
+
36
+ def save_node(graph: dict) -> str:
37
+ nid = node_of(graph, "SaveImage")
38
+ if nid is None:
39
+ raise EditError("the workflow saves no image")
40
+ return nid
41
+
42
+
43
+ def load_images(graph: dict, names: list[str]) -> list[list]:
44
+ """LoadImage nodes for uploaded images; their IMAGE outputs."""
45
+ links = []
46
+ for k, name in enumerate(names):
47
+ nid = str(BASE + 100 + k)
48
+ graph[nid] = {"class_type": "LoadImage", "inputs": {"image": name}}
49
+ links.append([nid, 0])
50
+ return links
51
+
52
+
53
+ def add_references(graph: dict, images: list[list], megapixels: float, size_from_first: bool) -> list:
54
+ """Attach the images as reference latents to the guider's positive and negative conditionings.
55
+
56
+ Each is scaled to `megapixels` first. With `size_from_first`, the picture made has the first image's size
57
+ (its width and height go to every node of the workflow that takes them). Returns the first scaled image.
58
+ """
59
+ guider = node_of(graph, "CFGGuider")
60
+ vae = node_of(graph, "VAELoader")
61
+ if guider is None or vae is None:
62
+ raise EditError("this workflow can't take reference images")
63
+ positive, negative = graph[guider]["inputs"]["positive"], graph[guider]["inputs"]["negative"]
64
+ first = None
65
+ for k, image in enumerate(images):
66
+ scale, encode, ref_pos, ref_neg = (str(BASE + 10 * (k + 1) + i) for i in range(4))
67
+ graph[scale] = {
68
+ "class_type": "ImageScaleToTotalPixels",
69
+ "inputs": {"image": image, "upscale_method": "lanczos", "megapixels": megapixels, "resolution_steps": 16},
70
+ }
71
+ graph[encode] = {"class_type": "VAEEncode", "inputs": {"pixels": [scale, 0], "vae": [vae, 0]}}
72
+ graph[ref_pos] = {"class_type": "ReferenceLatent", "inputs": {"conditioning": positive, "latent": [encode, 0]}}
73
+ graph[ref_neg] = {"class_type": "ReferenceLatent", "inputs": {"conditioning": negative, "latent": [encode, 0]}}
74
+ positive, negative = [ref_pos, 0], [ref_neg, 0]
75
+ first = first or [scale, 0]
76
+ graph[guider]["inputs"]["positive"], graph[guider]["inputs"]["negative"] = positive, negative
77
+ if size_from_first and first:
78
+ set_size(graph, first)
79
+ return first
80
+
81
+
82
+ def set_size(graph: dict, image: list) -> None:
83
+ """The picture made takes `image`'s size: its width and height go to every workflow node that takes them."""
84
+ graph[SIZE_NODE] = {"class_type": "GetImageSize", "inputs": {"image": image}}
85
+ for nid, node in graph.items():
86
+ if nid.isdigit() and int(nid) >= BASE:
87
+ continue # lcode's own nodes keep their sizes
88
+ inputs = node.get("inputs", {})
89
+ for key, out in (("width", 0), ("height", 1)):
90
+ if key in inputs and not isinstance(inputs[key], list):
91
+ inputs[key] = [SIZE_NODE, out]
92
+
93
+
94
+ def scale_to(graph: dict, image: list, megapixels: float) -> list:
95
+ nid = str(BASE + 301)
96
+ graph[nid] = {
97
+ "class_type": "ImageScaleToTotalPixels",
98
+ "inputs": {"image": image, "upscale_method": "lanczos", "megapixels": megapixels, "resolution_steps": 16},
99
+ }
100
+ return [nid, 0]
101
+
102
+
103
+ def mask_from_file(graph: dict, mask_name: str) -> list:
104
+ """The user's mask image (white: change), at the picture's size, with a soft edge."""
105
+ if SIZE_NODE not in graph:
106
+ raise EditError("inpainting needs the picture at the original's size")
107
+ load, to_image, scale, blur, to_mask = (str(BASE + 200 + i) for i in range(5))
108
+ graph[load] = {"class_type": "LoadImageMask", "inputs": {"image": mask_name, "channel": "red"}}
109
+ graph[to_image] = {"class_type": "MaskToImage", "inputs": {"mask": [load, 0]}}
110
+ graph[scale] = {
111
+ "class_type": "ImageScale",
112
+ "inputs": {"image": [to_image, 0], "upscale_method": "bilinear", "width": [SIZE_NODE, 0],
113
+ "height": [SIZE_NODE, 1], "crop": "disabled"},
114
+ } # fmt: skip
115
+ graph[blur] = {"class_type": "ImageBlur", "inputs": {"image": [scale, 0], "blur_radius": 8, "sigma": 4.0}}
116
+ graph[to_mask] = {"class_type": "ImageToMask", "inputs": {"image": [blur, 0], "channel": "red"}}
117
+ return [to_mask, 0]
118
+
119
+
120
+ def mask_scaled(graph: dict, mask: list, megapixels: float) -> list:
121
+ """A mask brought to the same size as an image scaled to `megapixels` (outpainting's border)."""
122
+ to_image, scale, to_mask = (str(BASE + 310 + i) for i in range(3))
123
+ graph[to_image] = {"class_type": "MaskToImage", "inputs": {"mask": mask}}
124
+ graph[scale] = {
125
+ "class_type": "ImageScaleToTotalPixels",
126
+ "inputs": {"image": [to_image, 0], "upscale_method": "bilinear", "megapixels": megapixels,
127
+ "resolution_steps": 16},
128
+ } # fmt: skip
129
+ graph[to_mask] = {"class_type": "ImageToMask", "inputs": {"image": [scale, 0], "channel": "red"}}
130
+ return [to_mask, 0]
131
+
132
+
133
+ def sample_inside(graph: dict, image: list, mask: list) -> None:
134
+ """Start from the image itself and let the sampler change only what the mask covers."""
135
+ sampler = node_of(graph, "SamplerCustomAdvanced") or node_of(graph, "KSampler")
136
+ vae = node_of(graph, "VAELoader")
137
+ if sampler is None or vae is None:
138
+ raise EditError("this workflow can't paint inside a mask")
139
+ encode, masked = str(BASE + 400), str(BASE + 401)
140
+ graph[encode] = {"class_type": "VAEEncode", "inputs": {"pixels": image, "vae": [vae, 0]}}
141
+ graph[masked] = {"class_type": "SetLatentNoiseMask", "inputs": {"samples": [encode, 0], "mask": mask}}
142
+ graph[sampler]["inputs"]["latent_image"] = [masked, 0]
143
+
144
+
145
+ def paste(graph: dict, destination: list, source: list, mask: list) -> None:
146
+ """The saved picture becomes `destination` with `source` pasted where the mask is white."""
147
+ save = save_node(graph)
148
+ nid = str(BASE + 500)
149
+ graph[nid] = {
150
+ "class_type": "ImageCompositeMasked",
151
+ "inputs": {"destination": destination, "source": source, "x": 0, "y": 0, "resize_source": False,
152
+ "mask": mask},
153
+ } # fmt: skip
154
+ graph[save]["inputs"]["images"] = [nid, 0]
155
+
156
+
157
+ def inpaint(graph: dict, original: list, mask_name: str) -> None:
158
+ """Change only where the mask image is white: sampled there from the original, then pasted into it."""
159
+ mask = mask_from_file(graph, mask_name)
160
+ sample_inside(graph, original, mask)
161
+ paste(graph, original, graph[save_node(graph)]["inputs"]["images"], mask)
162
+
163
+
164
+ def pad_canvas(graph: dict, image: list, pads: tuple[int, int, int, int]) -> tuple[list, list]:
165
+ """Outpainting's canvas, bigger by `pads` (left, top, right, bottom); (canvas, mask of the new border).
166
+
167
+ The border starts as a blurred, stretched copy of the image rather than flat grey: a fast model keeps a little of
168
+ what it starts from, and grey would show.
169
+ """
170
+ pad, size, stretched, blurred, canvas = (str(BASE + 300 + i) for i in (0, 2, 3, 4, 5))
171
+ left, top, right, bottom = pads
172
+ graph[pad] = {
173
+ "class_type": "ImagePadForOutpaint",
174
+ "inputs": {"image": image, "left": left, "top": top, "right": right, "bottom": bottom, "feathering": 48},
175
+ }
176
+ graph[size] = {"class_type": "GetImageSize", "inputs": {"image": [pad, 0]}}
177
+ graph[stretched] = {
178
+ "class_type": "ImageScale",
179
+ "inputs": {"image": image, "upscale_method": "bilinear", "width": [size, 0], "height": [size, 1],
180
+ "crop": "disabled"},
181
+ } # fmt: skip
182
+ graph[blurred] = {"class_type": "ImageBlur", "inputs": {"image": [stretched, 0], "blur_radius": 31, "sigma": 10.0}}
183
+ graph[canvas] = {
184
+ "class_type": "ImageCompositeMasked",
185
+ "inputs": {"destination": [blurred, 0], "source": image, "x": left, "y": top, "resize_source": False},
186
+ }
187
+ return [canvas, 0], [pad, 1]
188
+
189
+
190
+ def outpaint(graph: dict, canvas_scaled: list, border: list, megapixels: float) -> None:
191
+ """Fill only the new border, sampled around the original, and keep the original exactly as it was."""
192
+ mask = mask_scaled(graph, border, megapixels)
193
+ sample_inside(graph, canvas_scaled, mask)
194
+ invert = str(BASE + 501)
195
+ graph[invert] = {"class_type": "InvertMask", "inputs": {"mask": mask}}
196
+ paste(graph, graph[save_node(graph)]["inputs"]["images"], canvas_scaled, [invert, 0])
197
+
198
+
199
+ # ----------------------------------------------------------------------------- sizes and arguments
200
+
201
+
202
+ def image_size(path: Path) -> tuple[int, int]:
203
+ """Width and height from a PNG, JPEG or WebP file's header."""
204
+ data = path.read_bytes()[:65536]
205
+ if data.startswith(b"\x89PNG\r\n\x1a\n"):
206
+ return struct.unpack(">II", data[16:24])
207
+ if data[:2] == b"\xff\xd8":
208
+ i = 2
209
+ while i + 9 < len(data):
210
+ if data[i] != 0xFF:
211
+ i += 1
212
+ continue
213
+ marker = data[i + 1]
214
+ length = struct.unpack(">H", data[i + 2 : i + 4])[0]
215
+ if marker in (0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF):
216
+ h, w = struct.unpack(">HH", data[i + 5 : i + 9])
217
+ return w, h
218
+ i += 2 + length
219
+ if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
220
+ kind = data[12:16]
221
+ if kind == b"VP8X":
222
+ return int.from_bytes(data[24:27], "little") + 1, int.from_bytes(data[27:30], "little") + 1
223
+ if kind == b"VP8 ":
224
+ w, h = struct.unpack("<HH", data[26:30])
225
+ return w & 0x3FFF, h & 0x3FFF
226
+ if kind == b"VP8L":
227
+ bits = int.from_bytes(data[21:25], "little")
228
+ return (bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1
229
+ raise EditError(f"can't read the size of {path.name}: use a PNG, JPEG or WebP image")
230
+
231
+
232
+ def pads_for_ratio(width: int, height: int, ratio: str) -> tuple[int, int, int, int]:
233
+ """Borders that bring the image to an aspect ratio (a:b), split evenly, in steps of 8 pixels."""
234
+ m = re.fullmatch(r"(\d{1,2}):(\d{1,2})", ratio)
235
+ if not m or not int(m.group(1)) or not int(m.group(2)):
236
+ raise EditError("the shape to extend to is a ratio, e.g. --ar 16:9")
237
+ a, b = int(m.group(1)), int(m.group(2))
238
+ if abs(width / height - a / b) < 0.01:
239
+ raise EditError(f"it's {a}:{b} already: choose another shape, or --left/--right/--top/--bottom pixels")
240
+ if width / height < a / b: # wider
241
+ extra = round(height * a / b) - width
242
+ left = (extra // 2) // 8 * 8
243
+ return left, 0, (extra - left + 7) // 8 * 8, 0
244
+ extra = round(width * b / a) - height
245
+ top = (extra // 2) // 8 * 8
246
+ return 0, top, 0, (extra - top + 7) // 8 * 8
247
+
248
+
249
+ EXTEND_FLAG = re.compile(r"--(left|top|right|bottom)\s+(\d+)")
250
+
251
+
252
+ def parse_extend(text: str, width: int, height: int) -> tuple[tuple[int, int, int, int], str]:
253
+ """`/extend` arguments: --ar 16:9, or --left/--top/--right/--bottom pixels; the rest describes the new part."""
254
+ sides = {k: int(v) for k, v in EXTEND_FLAG.findall(text)}
255
+ text = EXTEND_FLAG.sub(" ", text)
256
+ ratio = re.search(r"--(?:ar|size)\s+(\S+)", text)
257
+ text = re.sub(r"--(?:ar|size)\s+\S+", " ", text)
258
+ if ratio and sides:
259
+ raise EditError("extend by a shape (--ar 16:9) or by sides (--left 256), not both")
260
+ if ratio:
261
+ from lcode.imageopts import SIZES
262
+
263
+ pads = pads_for_ratio(width, height, SIZES.get(ratio.group(1), ratio.group(1)))
264
+ elif sides:
265
+ pads = tuple((sides.get(k, 0) + 7) // 8 * 8 for k in ("left", "top", "right", "bottom"))
266
+ if any(p > 2048 for p in pads):
267
+ raise EditError("a side grows by at most 2048 pixels")
268
+ else:
269
+ pads = pads_for_ratio(width, height, "16:9" if width <= height * 16 / 9 - 1 else "1:1")
270
+ return pads, " ".join(text.split())
271
+
272
+
273
+ REFERENCE = re.compile(r'(?:^|(?<=\s))@(#\d+|"[^"]+"|\S+)')
274
+
275
+
276
+ def split_references(text: str) -> tuple[str, list[str]]:
277
+ """The prompt without its @references, and the references (#3, or file paths) in order."""
278
+ refs = [m.group(1).strip('"') for m in REFERENCE.finditer(text)]
279
+ return " ".join(REFERENCE.sub(" ", text).split()), refs
280
+
281
+
282
+ def is_image(path: Path) -> bool:
283
+ return path.suffix.lower() in IMAGE_TYPES
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