lcode-cli 0.42.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.42.0"
6
+ __version__ = "0.43.0"
7
7
 
8
8
  API = ("Session", "Result", "Options", "SetupError")
9
9
 
lcode/agent.py CHANGED
@@ -380,7 +380,8 @@ class Agent:
380
380
 
381
381
  self.image_options = ImageOptions() # image mode's defaults for this session (/options)
382
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
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)
384
385
  self.reflected = 0 # messages before this index were already checked for notes to remember
385
386
  self._memory: memory_notes.Memory | None = None
386
387
  self._agent_types: tuple[Path, dict[str, subagents.AgentType], list[str]] | None = None
@@ -697,7 +698,7 @@ class Agent:
697
698
  "model": self.settings.model,
698
699
  "mode": self.settings.mode,
699
700
  "image_model": self.settings.image_model,
700
- "media": {"results": self.media_results, "options": self.image_options.to_json()},
701
+ "media": {"results": self.media_results, "options": self.image_options.to_json(), "refs": self.image_refs},
701
702
  "name": self.session_name,
702
703
  "title": self.session_title,
703
704
  "messages": self.messages,
@@ -751,6 +752,7 @@ class Agent:
751
752
  media = data.get("media") or {}
752
753
  self.media_results = [r for r in media.get("results") or [] if isinstance(r, dict) and "path" in r]
753
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)]
754
756
  self.session_title = data.get("title") or sessions.title_from(self.messages)
755
757
  self.tools.read_mtimes.clear() # files may have changed since; the model must read them again
756
758
  self.tools.todos = list(data.get("todos") or [])
lcode/cli.py CHANGED
@@ -1107,9 +1107,13 @@ def cmd_media(args) -> None:
1107
1107
  f"ComfyUI at {server.url}" if running else (f"{install.path} (not running)" if install else "no ComfyUI found")
1108
1108
  )
1109
1109
  console.print(f"[dim]{hw.describe()} · {where} · image model: {cfg['image_model']}[/]\n")
1110
- for mode in ("image", "video"):
1110
+ for mode in ("image", "video", "tool"):
1111
1111
  models = cat.for_mode(mode)
1112
- 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
+ )
1113
1117
  if not models:
1114
1118
  console.print(f" [dim]{media.SOON[mode]}[/]\n")
1115
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
 
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/media.py CHANGED
@@ -8,6 +8,7 @@ from __future__ import annotations
8
8
 
9
9
  import os
10
10
  import random
11
+ import re
11
12
  import shutil
12
13
  import subprocess
13
14
  import sys
@@ -20,7 +21,7 @@ from typing import TYPE_CHECKING
20
21
  import requests
21
22
  from rich.text import Text
22
23
 
23
- from lcode import comfy, imageopts, preview
24
+ from lcode import comfy, edits, imageopts, preview
24
25
  from lcode import media_catalog as catalog
25
26
 
26
27
  if TYPE_CHECKING:
@@ -252,39 +253,107 @@ def thumbnail(server: comfy.Server, outputs: dict) -> bytes:
252
253
  return b""
253
254
 
254
255
 
256
+ SIZE_FLAG = re.compile(r"(?:^|\s)--?(ar|aspect|size)\b")
257
+ MASK_FLAG = re.compile(r'(?:^|\s)--mask\s+("[^"]+"|\S+)')
258
+
259
+
255
260
  def request(
256
261
  agent: Agent, text: str, cancelled: Callable[[], bool] = lambda: False, ask: Callable[[str], bool] | None = None
257
262
  ) -> list[Made]:
258
- """A typed request: the prompt and its flags (--n 4 --ar 16:9 …, over the session's /options), then the images."""
263
+ """A typed request: its @references (images to edit or follow), --mask, and flags (--n 4 --ar 16:9 …, over
264
+ the session's /options), then the images."""
265
+ mask = None
266
+ m = MASK_FLAG.search(text)
267
+ if m:
268
+ mask = resolve(agent, m.group(1).strip('"'))
269
+ text = MASK_FLAG.sub(" ", text)
270
+ text, refs = edits.split_references(text)
259
271
  try:
260
272
  prompt, options = imageopts.parse(text, agent.image_options)
261
273
  except imageopts.OptionError as e:
262
274
  raise MediaError(str(e)) from e
263
- return make(agent, prompt, options, cancelled, ask)
275
+ sources = [resolve(agent, ref) for ref in refs] + [Path(p) for p in agent.image_refs if Path(p).exists()]
276
+ if mask and not sources:
277
+ raise MediaError("--mask needs the image it masks: @photo.png --mask mask.png make the sky purple")
278
+ job = {"kind": "edit" if sources else "image", "prompt": prompt, "options": options,
279
+ "sources": [str(p) for p in sources], "mask": str(mask) if mask else None, "pads": None,
280
+ "keep_shape": not SIZE_FLAG.search(text)} # fmt: skip
281
+ return redo(agent, job, cancelled, ask)
264
282
 
265
283
 
266
- def make(
267
- agent: Agent,
268
- prompt: str,
269
- options: imageopts.Options,
270
- cancelled: Callable[[], bool] = lambda: False,
271
- ask: Callable[[str], bool] | None = None,
284
+ def redo(
285
+ agent: Agent, job: dict, cancelled: Callable[[], bool] = lambda: False, ask: Callable[[str], bool] | None = None
272
286
  ) -> list[Made]:
273
- """`options.n` images of the prompt; a fixed seed counts up from image to image."""
274
- if not prompt.strip():
275
- raise MediaError("Describe the image you want.")
287
+ """Make what `job` describes (also what /again and /vary repeat): images, or edits of its sources."""
288
+ prompt, options = job["prompt"], job["options"]
289
+ if not prompt.strip() and job["kind"] in ("image", "edit") and not job.get("pads"):
290
+ raise MediaError("Describe the image you want." if job["kind"] == "image" else "Say what to change.")
276
291
  model = current(agent)
277
292
  if options.negative and not model.negative:
278
293
  agent.say(f"{model.name} doesn't use a negative prompt: describe what you want instead (--no is ignored).")
279
- agent.last_request = (prompt, options)
294
+ agent.last_request = job
280
295
  made = []
281
296
  for i in range(options.n):
282
297
  seed = None if options.seed is None else options.seed + i
283
298
  label = f"{i + 1} of {options.n}" if options.n > 1 else ""
284
- made.append(generate_image(agent, prompt, cancelled, ask, replace(options, seed=seed, n=1), label))
299
+ one = replace(options, seed=seed, n=1)
300
+ if job["kind"] == "image":
301
+ made.append(generate_image(agent, prompt, cancelled, ask, one, label))
302
+ else:
303
+ made.append(edit_image(agent, job, one, cancelled, ask, label))
285
304
  return made
286
305
 
287
306
 
307
+ def make(
308
+ agent: Agent,
309
+ prompt: str,
310
+ options: imageopts.Options,
311
+ cancelled: Callable[[], bool] = lambda: False,
312
+ ask: Callable[[str], bool] | None = None,
313
+ ) -> list[Made]:
314
+ """`options.n` images of the prompt; a fixed seed counts up from image to image."""
315
+ job = {"kind": "image", "prompt": prompt, "options": options, "sources": [], "mask": None, "pads": None}
316
+ return redo(agent, job, cancelled, ask)
317
+
318
+
319
+ def resolve(agent: Agent, ref: str) -> Path:
320
+ """An image the user points at: #3 (a result) or a file path."""
321
+ ref = ref.strip()
322
+ if re.fullmatch(r"#?\d+", ref) and ref.startswith("#"):
323
+ return Path(result(agent, ref)["path"])
324
+ path = Path(ref).expanduser()
325
+ path = path if path.is_absolute() else agent.cwd / path
326
+ if not path.is_file():
327
+ raise MediaError(f"There's no image {ref}: give a file (@photo.png) or a result's number (@#3).")
328
+ if not edits.is_image(path):
329
+ raise MediaError(f"{ref} isn't an image lcode can use: PNG, JPEG, WebP or BMP.")
330
+ return path
331
+
332
+
333
+ @dataclass
334
+ class Plan:
335
+ """A job ready for ComfyUI: the graph, what to call it, and what goes in its JSON file."""
336
+
337
+ model: catalog.MediaModel
338
+ workflow: comfy.Workflow
339
+ graph: dict
340
+ meta: dict
341
+ prompt: str
342
+ seed: int
343
+ options: imageopts.Options
344
+ kind: str = "image"
345
+ sources: list[str] = field(default_factory=list)
346
+ job: dict = field(default_factory=dict) # what made it, for /vary (prompt, sources, mask, pads)
347
+ stem: str = "" # the file name without its extension, when not the time and the prompt
348
+
349
+
350
+ def prepare(agent: Agent, ask, cancelled) -> comfy.Server:
351
+ server = engine(agent, ask, cancelled)
352
+ for path in comfy.collect(server):
353
+ agent.say(f"Saved {shown(agent, path)}: it finished while lcode wasn't watching.")
354
+ return server
355
+
356
+
288
357
  def generate_image(
289
358
  agent: Agent,
290
359
  prompt: str,
@@ -298,13 +367,10 @@ def generate_image(
298
367
  if not prompt:
299
368
  raise MediaError("Describe the image you want.")
300
369
  options = options or imageopts.Options()
301
- server = engine(agent, ask, cancelled)
302
- for path in comfy.collect(server):
303
- agent.say(f"Saved {shown(agent, path)}: it finished while lcode wasn't watching.")
370
+ server = prepare(agent, ask, cancelled)
304
371
  model = current(agent)
305
372
  files = ensure_installed(agent, model, server, ask)
306
373
  workflow = comfy.load_workflow(model.workflow)
307
- free_gpu(agent)
308
374
  seed = options.seed if options.seed is not None else random.randrange(2**32)
309
375
  steps, megapixels = model.preset(options.quality)
310
376
  width, height = imageopts.dimensions(options.size, megapixels)
@@ -316,15 +382,145 @@ def generate_image(
316
382
  values["negative"] = options.negative
317
383
  graph = comfy.fill(workflow.graph, values)
318
384
  add_thumbnail(graph, width, height)
385
+ meta = describe_job(model, workflow, prompt, text, seed, values, options, files)
386
+ return run_plan(agent, server, Plan(model, workflow, graph, meta, prompt, seed, options), cancelled, label)
387
+
388
+
389
+ def edit_image(
390
+ agent: Agent,
391
+ job: dict,
392
+ options: imageopts.Options,
393
+ cancelled: Callable[[], bool] = lambda: False,
394
+ ask: Callable[[str], bool] | None = None,
395
+ label: str = "",
396
+ ) -> Made:
397
+ """An edit of the job's source images: by instruction, through a mask (inpainting), or on a bigger canvas
398
+ (outpainting). The first source sets the shape unless the request gave one."""
399
+ server = prepare(agent, ask, cancelled)
400
+ chosen = current(agent)
401
+ model = catalog.load().editor(chosen)
402
+ if model is None:
403
+ raise MediaError("None of lcode's image models edits pictures yet.")
404
+ if model.key != chosen.key:
405
+ agent.say(f"{chosen.name} doesn't edit pictures: {model.name} makes this one.")
406
+ files = ensure_installed(agent, model, server, ask)
407
+ workflow = comfy.load_workflow(model.workflow)
408
+ sources = [Path(p) for p in job["sources"]]
409
+ try:
410
+ names = [server.upload(p) for p in sources]
411
+ mask_name = server.upload(Path(job["mask"])) if job.get("mask") else None
412
+ first_w, first_h = edits.image_size(sources[0])
413
+ except (comfy.ComfyError, OSError, edits.EditError) as e:
414
+ raise MediaError(f"Couldn't hand the images to ComfyUI: {e}") from e
415
+ pads = job.get("pads")
416
+ if pads:
417
+ first_w, first_h = first_w + pads[0] + pads[2], first_h + pads[1] + pads[3]
418
+ seed = options.seed if options.seed is not None else random.randrange(2**32)
419
+ steps, megapixels = model.preset(options.quality)
420
+ keep_shape = bool(job.get("keep_shape", True) or job.get("mask") or pads)
421
+ ratio = f"{first_w}x{first_h}" if keep_shape else options.size
422
+ width, height = shape(ratio, megapixels)
423
+ instruction = job["prompt"] if not pads else f"{edits.EXTEND_HINT} {job['prompt']}".strip()
424
+ text = imageopts.styled(instruction, options.style)
425
+ values = {**workflow.defaults, **files, "prompt": text, "seed": seed, "width": width, "height": height}
426
+ if steps:
427
+ values["steps"] = steps
428
+ if model.negative:
429
+ values["negative"] = options.negative
430
+ graph = comfy.fill(workflow.graph, values)
431
+ try:
432
+ links = edits.load_images(graph, names)
433
+ if pads: # the original is the reference; the picture is the padded canvas, painted only in its border
434
+ canvas, border = edits.pad_canvas(graph, links[0], pads)
435
+ canvas = edits.add_references(graph, [canvas, *links[1:]], megapixels, size_from_first=True)
436
+ edits.outpaint(graph, canvas, border, megapixels)
437
+ else:
438
+ first = edits.add_references(graph, links, megapixels, size_from_first=keep_shape)
439
+ if mask_name:
440
+ edits.inpaint(graph, first, mask_name)
441
+ except edits.EditError as e:
442
+ raise MediaError(f"{model.name} can't do that edit: {e}") from e
443
+ add_thumbnail(graph, width, height)
444
+ meta = describe_job(model, workflow, job["prompt"], text, seed, values, options, files)
445
+ meta.update(
446
+ {"edit_of": [shown(agent, p) for p in sources], "mask": shown(agent, Path(job["mask"])) if job.get("mask")
447
+ else "", "extend": list(pads) if pads else []}
448
+ ) # fmt: skip
449
+ kind = "extend" if pads else ("inpaint" if job.get("mask") else "edit")
450
+ remembered = {k: job.get(k) for k in ("kind", "prompt", "sources", "mask", "pads", "keep_shape")}
451
+ plan = Plan(model, workflow, graph, meta, job["prompt"] or "extended", seed, options, kind, job["sources"],
452
+ remembered, f"{sources[0].stem}-extended" if pads else "") # fmt: skip
453
+ return run_plan(agent, server, plan, cancelled, label)
454
+
455
+
456
+ def apply_tool(
457
+ agent: Agent,
458
+ task: str,
459
+ source: Path,
460
+ scale: int = 2,
461
+ cancelled: Callable[[], bool] = lambda: False,
462
+ ask: Callable[[str], bool] | None = None,
463
+ ) -> Made:
464
+ """/upscale and /nobg: a tool model on one image (no seed, no prompt)."""
465
+ server = prepare(agent, ask, cancelled)
466
+ model = catalog.load().tool(task)
467
+ files = ensure_installed(agent, model, server, ask)
468
+ workflow = comfy.load_workflow(model.workflow)
469
+ try:
470
+ name = server.upload(source)
471
+ width, height = edits.image_size(source)
472
+ except (comfy.ComfyError, OSError, edits.EditError) as e:
473
+ raise MediaError(f"Couldn't hand the image to ComfyUI: {e}") from e
474
+ values = {**workflow.defaults, **files, "image": name}
475
+ if task == "upscale":
476
+ values["scale_by"] = scale / 4 # Real-ESRGAN makes 4 times the size; scaled back for 2
477
+ width, height = width * scale, height * scale
478
+ graph = comfy.fill(workflow.graph, values)
479
+ add_thumbnail(graph, width, height)
480
+ what = f"upscaled {scale}×" if task == "upscale" else "background removed"
481
+ prompt = f"{what}: {shown(agent, source)}"
319
482
  meta = {
483
+ "prompt": prompt,
484
+ "model": model.name,
485
+ "model_key": model.key,
486
+ "workflow": workflow.key,
487
+ "source": shown(agent, source),
488
+ "files": files,
489
+ "created": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
490
+ }
491
+ suffix = f"upscaled-{scale}x" if task == "upscale" else "nobg"
492
+ plan = Plan(model, workflow, graph, meta, prompt, 0, imageopts.Options(), task, [str(source)], {},
493
+ f"{source.stem}-{suffix}") # fmt: skip
494
+ return run_plan(agent, server, plan, cancelled, "")
495
+
496
+
497
+ def shape(size: str, megapixels: float) -> tuple[int, int]:
498
+ """Pixels for an edit: a ratio or a source's own pixels, brought to the quality's megapixels."""
499
+ m = edits.re.fullmatch(r"(\d+)x(\d+)", size)
500
+ if m:
501
+ w, h = int(m.group(1)), int(m.group(2))
502
+ return imageopts.dimensions(f"{w}:{h}" if w < 100 and h < 100 else ratio_of(w, h), megapixels)
503
+ return imageopts.dimensions(size, megapixels)
504
+
505
+
506
+ def ratio_of(width: int, height: int) -> str:
507
+ """A ratio string close to width:height, with small numbers (for imageopts.dimensions)."""
508
+ from fractions import Fraction
509
+
510
+ f = Fraction(width, height).limit_denominator(32)
511
+ return f"{f.numerator}:{f.denominator}" if f.numerator < 100 else f"{round(width / height * 16)}:16"
512
+
513
+
514
+ def describe_job(model, workflow, prompt, text, seed, values, options, files) -> dict:
515
+ return {
320
516
  "prompt": prompt,
321
517
  "model": model.name,
322
518
  "model_key": model.key,
323
519
  "workflow": workflow.key,
324
520
  "seed": seed,
325
- "width": width,
326
- "height": height,
327
- "steps": values["steps"],
521
+ "width": values["width"],
522
+ "height": values["height"],
523
+ "steps": values.get("steps"),
328
524
  "quality": options.quality,
329
525
  "style": options.style,
330
526
  "negative": options.negative if model.negative else "",
@@ -332,18 +528,22 @@ def generate_image(
332
528
  "files": files,
333
529
  "created": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
334
530
  }
531
+
532
+
533
+ def run_plan(agent: Agent, server: comfy.Server, plan: Plan, cancelled, label: str) -> Made:
534
+ """Queue the plan, follow it, save what it made, number it, show it."""
535
+ free_gpu(agent)
335
536
  out = folder(agent)
336
- stem = f"{time.strftime('%Y%m%d-%H%M%S')}-{comfy.slug(prompt)}"
337
- line = Line(
338
- f"{model.name} ({label})" if label else model.name, "Esc or Ctrl+C cancels" if agent.interactive else ""
339
- )
537
+ stem = plan.stem or f"{time.strftime('%Y%m%d-%H%M%S')}-{comfy.slug(plan.prompt)}"
538
+ hint = "Esc or Ctrl+C cancels" if agent.interactive else ""
539
+ line = Line(f"{plan.model.name} ({label})" if label else plan.model.name, hint)
340
540
  job = {"id": ""}
341
- labels = {**workflow.labels, THUMB_NODES[0]: "making the preview", THUMB_NODES[1]: "making the preview"}
541
+ labels = {**plan.workflow.labels, THUMB_NODES[0]: "making the preview", THUMB_NODES[1]: "making the preview"}
342
542
 
343
543
  def on_event(event: comfy.Event) -> None:
344
544
  if event.kind == "queued":
345
545
  job["id"] = event.prompt_id
346
- comfy.remember(event.prompt_id, server.url, out, stem, meta)
546
+ comfy.remember(event.prompt_id, server.url, out, stem, plan.meta)
347
547
  elif event.kind == "waiting":
348
548
  line.doing = f"waiting: {event.value} job(s) ahead in ComfyUI's queue"
349
549
  elif event.kind == "node":
@@ -354,7 +554,7 @@ def generate_image(
354
554
 
355
555
  try:
356
556
  with agent.console.status(line, spinner="dots"):
357
- prompt_id, outputs = comfy.run(server, graph, on_event, cancelled)
557
+ prompt_id, outputs = comfy.run(server, plan.graph, on_event, cancelled)
358
558
  except (KeyboardInterrupt, comfy.Cancelled) as e:
359
559
  if job["id"]:
360
560
  if isinstance(e, KeyboardInterrupt): # run() cancels it in ComfyUI itself for Esc
@@ -366,15 +566,21 @@ def generate_image(
366
566
  comfy.forget(job["id"])
367
567
  raise MediaError(str(e)) from e
368
568
  try:
369
- saved = comfy.save(server, outputs, out, stem, {**meta, "comfyui_prompt_id": prompt_id})
569
+ saved = comfy.save(server, outputs, out, stem, {**plan.meta, "comfyui_prompt_id": prompt_id})
370
570
  except (comfy.ComfyError, OSError) as e:
371
571
  raise MediaError(f"ComfyUI made it, but lcode couldn't save it: {e}") from e
372
572
  comfy.forget(prompt_id)
373
573
  if not saved:
374
574
  raise MediaError("ComfyUI finished but returned no image.")
375
- made = Made(saved, model.name, seed, width, height, time.monotonic() - line.started, prompt)
575
+ width, height = plan.meta.get("width") or 0, plan.meta.get("height") or 0
576
+ if plan.kind != "image": # an edit's or a tool's size comes from its source: read what ComfyUI made
577
+ try:
578
+ width, height = edits.image_size(saved[0])
579
+ except (OSError, edits.EditError):
580
+ pass
581
+ made = Made(saved, plan.model.name, plan.seed, width, height, time.monotonic() - line.started, plan.prompt)
376
582
  made.thumbnail = thumbnail(server, outputs)
377
- made.numbers = record(agent, made, meta, options)
583
+ made.numbers = record(agent, made, plan)
378
584
  show(agent, made)
379
585
  return made
380
586
 
@@ -382,7 +588,7 @@ def generate_image(
382
588
  # ----------------------------------------------------------------------------- results: numbered, shown, opened
383
589
 
384
590
 
385
- def record(agent: Agent, made: Made, meta: dict, options: imageopts.Options) -> list[int]:
591
+ def record(agent: Agent, made: Made, plan: Plan) -> list[int]:
386
592
  """Number the images in the session's gallery, with what made them (for /gallery, /vary, /open)."""
387
593
  numbers = []
388
594
  for path in made.files:
@@ -396,9 +602,14 @@ def record(agent: Agent, made: Made, meta: dict, options: imageopts.Options) ->
396
602
  "seed": made.seed,
397
603
  "width": made.width,
398
604
  "height": made.height,
399
- "steps": meta["steps"],
605
+ "steps": plan.meta.get("steps"),
400
606
  "seconds": round(made.seconds, 1),
401
- "options": options.to_json(),
607
+ "options": plan.options.to_json(),
608
+ "kind": plan.kind,
609
+ "sources": plan.sources,
610
+ "mask": plan.meta.get("mask", ""),
611
+ "extend": plan.meta.get("extend", []),
612
+ "job": plan.job,
402
613
  }
403
614
  )
404
615
  numbers.append(number)
@@ -406,14 +617,13 @@ def record(agent: Agent, made: Made, meta: dict, options: imageopts.Options) ->
406
617
 
407
618
 
408
619
  def show(agent: Agent, made: Made) -> None:
620
+ seed = f" · seed {made.seed}" if made.seed else "" # tools (upscaling, cut-outs) have none
409
621
  for path, number in zip(made.files, made.numbers or [0] * len(made.files), strict=False):
410
622
  agent.console.print(
411
623
  Text("◆ ", style="green")
412
624
  + Text(f"#{number} " if number else "", style="bold green")
413
625
  + Text(shown(agent, path), style="bold")
414
- + Text(
415
- f" {made.width}×{made.height} · {made.model} · seed {made.seed} · {made.seconds:.1f} s", style="dim"
416
- )
626
+ + Text(f" {made.width}×{made.height} · {made.model}{seed} · {made.seconds:.1f} s", style="dim")
417
627
  )
418
628
  if made.files:
419
629
  show_preview(agent, made.files[0], made.thumbnail)
lcode/media.toml CHANGED
@@ -8,7 +8,7 @@
8
8
  # alternatives other file names that do the same job: an installed one is used instead of downloading
9
9
  #
10
10
  # [models.<key>] one model.
11
- # mode image or video
11
+ # mode image, video, or tool (upscaling, background removal: used by image mode's commands)
12
12
  # tasks text-to-image, edit, inpaint, text-to-video, image-to-video, first-last-frame, reference-to-video, audio
13
13
  # workflow lcode's workflow for it (lcode/workflows/<workflow>.json)
14
14
  # files role -> file key; the workflow's $role placeholders get the file names
@@ -17,6 +17,8 @@
17
17
  # measured what it did on real hardware; models without it haven't been run yet
18
18
  # quality per preset (fast, balanced, best): [steps, megapixels]
19
19
  # negative whether it uses a negative prompt (distilled models don't)
20
+ # edit how it edits images: "reference-latent" (its text-to-image workflow, given the images as
21
+ # reference latents on both conditionings); absent: it doesn't edit
20
22
 
21
23
  [files.flux-2-klein-4b-fp8]
22
24
  folder = "diffusion_models"
@@ -51,12 +53,25 @@ name = "ae.safetensors"
51
53
  url = "https://huggingface.co/Comfy-Org/z_image_turbo/resolve/main/split_files/vae/ae.safetensors"
52
54
  gb = 0.34
53
55
 
56
+ [files.birefnet]
57
+ folder = "background_removal"
58
+ name = "birefnet.safetensors"
59
+ url = "https://huggingface.co/Comfy-Org/BiRefNet/resolve/main/background_removal/birefnet.safetensors"
60
+ gb = 0.44
61
+
62
+ [files.realesrgan-x4]
63
+ folder = "upscale_models"
64
+ name = "RealESRGAN_x4plus.safetensors"
65
+ url = "https://huggingface.co/Comfy-Org/Real-ESRGAN_repackaged/resolve/main/RealESRGAN_x4plus.safetensors"
66
+ gb = 0.07
67
+
54
68
  [models.flux2-klein-4b]
55
69
  name = "FLUX.2 klein 4B"
56
70
  mode = "image"
57
71
  publisher = "Black Forest Labs"
58
72
  license = "Apache 2.0"
59
- tasks = ["text-to-image"]
73
+ tasks = ["text-to-image", "edit", "references"]
74
+ edit = "reference-latent"
60
75
  workflow = "flux2-klein-4b-t2i"
61
76
  files = { model = "flux-2-klein-4b-fp8", text_encoder = "qwen-3-4b", vae = "flux2-vae" }
62
77
  gpu_gb = 0.0
@@ -78,3 +93,23 @@ measured = "1024×1024: 17 s per image (1.9 s per step), 23 s for the first (RTX
78
93
  notes = "photorealistic, writes text well: 8 steps"
79
94
  quality = { fast = [6, 0.5], balanced = [8, 1.0], best = [9, 2.0] }
80
95
  negative = false
96
+
97
+ [models.realesrgan-x4]
98
+ name = "Real-ESRGAN x4"
99
+ mode = "tool"
100
+ publisher = "Xintao Wang et al."
101
+ license = "BSD-3-Clause"
102
+ tasks = ["upscale"]
103
+ workflow = "upscale-esrgan"
104
+ files = { model = "realesrgan-x4" }
105
+ notes = "/upscale: 2× or 4× the pixels"
106
+
107
+ [models.birefnet]
108
+ name = "BiRefNet"
109
+ mode = "tool"
110
+ publisher = "ZhengPeng7"
111
+ license = "MIT"
112
+ tasks = ["remove-background"]
113
+ workflow = "remove-background"
114
+ files = { model = "birefnet" }
115
+ notes = "/nobg: a transparent PNG"
lcode/media_catalog.py CHANGED
@@ -69,6 +69,7 @@ class MediaModel:
69
69
  notes: str = ""
70
70
  quality: dict[str, tuple[int, float]] = field(default_factory=dict, hash=False) # preset -> (steps, megapixels)
71
71
  negative: bool = False # whether it uses a negative prompt
72
+ edit: str = "" # how it edits images ("reference-latent"), or empty: it doesn't
72
73
 
73
74
  def preset(self, quality: str) -> tuple[int | None, float]:
74
75
  """(steps, megapixels) for a quality preset: None steps keeps the workflow's."""
@@ -110,6 +111,16 @@ class Catalog:
110
111
  def for_mode(self, mode: str) -> list[MediaModel]:
111
112
  return [m for m in self.models.values() if m.mode == mode]
112
113
 
114
+ def tool(self, task: str) -> MediaModel:
115
+ """The tool for a task (upscale, remove-background)."""
116
+ return next(m for m in self.models.values() if m.mode == "tool" and task in m.tasks)
117
+
118
+ def editor(self, preferred: MediaModel) -> MediaModel | None:
119
+ """A model that edits: the preferred one if it can, else the first image model that can."""
120
+ if preferred.edit:
121
+ return preferred
122
+ return next((m for m in self.for_mode("image") if m.edit), None)
123
+
113
124
  def find(self, wanted: str, mode: str | None = None) -> MediaModel | None:
114
125
  """A model by key or name (any case), or by its number in the mode's list."""
115
126
  candidates = self.for_mode(mode) if mode else list(self.models.values())
@@ -146,6 +157,7 @@ def load() -> Catalog:
146
157
  m.get("notes", ""),
147
158
  {name: (int(v[0]), float(v[1])) for name, v in (m.get("quality") or {}).items()},
148
159
  bool(m.get("negative", False)),
160
+ m.get("edit", ""),
149
161
  )
150
162
  return Catalog(files, models)
151
163
 
lcode/repl.py CHANGED
@@ -84,6 +84,11 @@ COMMANDS = {
84
84
  "/vary": "Image mode: variations of an image, new seeds, e.g. /vary 3 (default: the last; --n 2)",
85
85
  "/gallery": "Image mode: this session's images with their settings (/gallery all: every image here)",
86
86
  "/open": "Open an image in your viewer, e.g. /open 3 (default: the last)",
87
+ "/edit": "Image mode: change an image by instruction, e.g. /edit 3 make the sky purple (@photo.png: references)",
88
+ "/use": "Image mode: images the next requests follow, e.g. /use 3 or /use photo.png (/use off)",
89
+ "/extend": "Image mode: outpaint an image to a shape or by sides, e.g. /extend 3 --ar 16:9 (or --left 256)",
90
+ "/upscale": "Image mode: 2 or 4 times the pixels, e.g. /upscale 3 4",
91
+ "/nobg": "Image mode: remove the background (a transparent PNG), e.g. /nobg 3",
87
92
  "/cd": "Change the working directory",
88
93
  "/todos": "Show the current todo list",
89
94
  "/status": "The task sheet: your requests, the plan and notes, files changed, the last check",
@@ -1186,7 +1191,7 @@ def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
1186
1191
  run_safely(agent, arg)
1187
1192
  elif cmd == "/mode":
1188
1193
  mode_command(agent, arg.strip().lower())
1189
- elif cmd in ("/options", "/again", "/vary", "/gallery", "/open"):
1194
+ elif cmd in IMAGE_COMMANDS:
1190
1195
  image_command(agent, cmd, arg)
1191
1196
  elif cmd == "/permissions":
1192
1197
  if arg in PERMISSION_MODES:
@@ -1401,9 +1406,16 @@ def respond(agent: Agent, line: str) -> None:
1401
1406
  run_safely(agent, line)
1402
1407
 
1403
1408
 
1409
+ IMAGE_COMMANDS = ("/options", "/again", "/vary", "/gallery", "/open", "/edit", "/use", "/extend", "/upscale", "/nobg")
1410
+ NUMBER = re.compile(r"^#?(\d+)$")
1411
+
1412
+
1404
1413
  def image_command(agent: Agent, cmd: str, arg: str) -> None:
1405
- """/options, /again, /vary, /gallery and /open: image mode's follow-ups on its results."""
1406
- from lcode import imageopts
1414
+ """Image mode's commands: /options, follow-ups on results (/again, /vary, /edit, /extend, /upscale, /nobg),
1415
+ references (/use), /gallery and /open."""
1416
+ from dataclasses import replace
1417
+
1418
+ from lcode import edits, imageopts
1407
1419
 
1408
1420
  c = agent.console
1409
1421
  if cmd == "/options":
@@ -1424,41 +1436,96 @@ def image_command(agent: Agent, cmd: str, arg: str) -> None:
1424
1436
  if cmd == "/gallery":
1425
1437
  gallery(agent, everything=arg.strip() == "all")
1426
1438
  return
1427
- if cmd == "/open":
1428
- try:
1429
- entry = media.result(agent, arg)
1430
- except media.MediaError as e:
1431
- c.print(f"[yellow]{escape(str(e))}[/]")
1432
- return
1433
- problem = media.open_file(Path(entry["path"]))
1434
- c.print(f"[yellow]{escape(problem)}[/]" if problem else f"[dim]Opened #{entry['n']}.[/]")
1435
- return
1436
- if agent.settings.mode != "image":
1437
- c.print("[yellow]/again and /vary work in image mode: /mode image.[/]")
1439
+ if agent.settings.mode != "image" and cmd != "/open":
1440
+ c.print(f"[yellow]{cmd} works in image mode: /mode image.[/]")
1438
1441
  return
1442
+ first, _, rest = arg.strip().partition(" ")
1443
+ number = first if NUMBER.match(first) else ""
1444
+ rest = rest if number else arg.strip()
1439
1445
  try:
1440
- number, _ = imageopts.parse(arg, imageopts.Options()) # what isn't a flag: the image's number
1441
- except imageopts.OptionError as e:
1442
- c.print(f"[red]{escape(str(e))}[/]")
1443
- return
1444
- if cmd == "/again":
1445
- if agent.last_request is None:
1446
- c.print("[yellow]Nothing to do again yet: describe an image first.[/]")
1446
+ if cmd == "/use":
1447
+ use_references(agent, arg.strip())
1447
1448
  return
1448
- prompt, options = agent.last_request
1449
- options = imageopts.replace_seed(options)
1450
- else:
1451
- try:
1452
- entry = media.result(agent, number)
1453
- except media.MediaError as e:
1454
- c.print(f"[yellow]{escape(str(e))}[/]")
1449
+ if cmd == "/again":
1450
+ if agent.last_request is None:
1451
+ c.print("[yellow]Nothing to do again yet: describe an image first.[/]")
1452
+ return
1453
+ job = dict(agent.last_request)
1454
+ _, job["options"] = imageopts.parse(arg, imageopts.replace_seed(job["options"]))
1455
+ make_image(agent, run=lambda cancel, ask: media.redo(agent, job, cancel, ask))
1456
+ return
1457
+ entry = media.result(agent, f"#{NUMBER.match(number).group(1)}" if number else "")
1458
+ source = Path(entry["path"])
1459
+ if cmd == "/open":
1460
+ problem = media.open_file(source)
1461
+ c.print(f"[yellow]{escape(problem)}[/]" if problem else f"[dim]Opened #{entry['n']}.[/]")
1455
1462
  return
1456
- prompt = entry["prompt"]
1457
- options = imageopts.Options.from_json(entry.get("options") or {})
1458
- options = imageopts.replace_seed(options, n=4)
1459
- c.print(f"[dim]Variations of #{entry['n']}: {escape(prompt)}[/]")
1460
- _, options = imageopts.parse(arg, options) # flags given here win
1461
- make_image(agent, prompt, options)
1463
+ if cmd == "/vary":
1464
+ job = dict(entry.get("job") or {"kind": "image", "sources": [], "mask": None, "pads": None})
1465
+ if entry.get("kind") in ("upscale", "remove-background") or job["kind"] not in ("image", "edit"):
1466
+ c.print(f"[yellow]#{entry['n']} is {escape(entry['prompt'])}: /vary the image it came from.[/]")
1467
+ return
1468
+ job["prompt"] = entry["prompt"] if job["kind"] == "image" else job.get("prompt", entry["prompt"])
1469
+ options = imageopts.replace_seed(imageopts.Options.from_json(entry.get("options") or {}), n=4)
1470
+ _, job["options"] = imageopts.parse(rest, options)
1471
+ c.print(f"[dim]Variations of #{entry['n']}: {escape(entry['prompt'])}[/]")
1472
+ make_image(agent, run=lambda cancel, ask: media.redo(agent, job, cancel, ask))
1473
+ return
1474
+ if cmd == "/edit":
1475
+ text, refs = edits.split_references(rest)
1476
+ instruction, options = imageopts.parse(text, imageopts.replace_seed(agent.image_options))
1477
+ if not instruction:
1478
+ c.print("[yellow]Say what to change: /edit 3 make the sky purple[/]")
1479
+ return
1480
+ sources = [str(source)] + [str(media.resolve(agent, r)) for r in refs]
1481
+ job = {"kind": "edit", "prompt": instruction, "options": options, "sources": sources, "mask": None,
1482
+ "pads": None, "keep_shape": "--ar" not in rest and "--size" not in rest} # fmt: skip
1483
+ make_image(agent, run=lambda cancel, ask: media.redo(agent, job, cancel, ask))
1484
+ return
1485
+ if cmd == "/extend":
1486
+ width, height = edits.image_size(source)
1487
+ pads, text = edits.parse_extend(rest, width, height)
1488
+ text, options = imageopts.parse(text, imageopts.replace_seed(agent.image_options, n=1))
1489
+ job = {"kind": "edit", "prompt": text, "options": replace(options, size="1:1"), "sources": [str(source)],
1490
+ "mask": None, "pads": pads, "keep_shape": True} # fmt: skip
1491
+ grown = f"{width + pads[0] + pads[2]}×{height + pads[1] + pads[3]}"
1492
+ c.print(f"[dim]Extending #{entry['n']} from {width}×{height} to {grown} before scaling.[/]")
1493
+ make_image(agent, run=lambda cancel, ask: media.redo(agent, job, cancel, ask))
1494
+ return
1495
+ if cmd == "/upscale":
1496
+ scale = rest.strip().lower().rstrip("x×") or "2"
1497
+ if scale not in ("2", "4"):
1498
+ c.print("[yellow]/upscale makes 2 or 4 times the size: /upscale 3 4[/]")
1499
+ return
1500
+ make_image(
1501
+ agent, run=lambda cancel, ask: media.apply_tool(agent, "upscale", source, int(scale), cancel, ask)
1502
+ )
1503
+ return
1504
+ if cmd == "/nobg":
1505
+ make_image(
1506
+ agent, run=lambda cancel, ask: media.apply_tool(agent, "remove-background", source, 2, cancel, ask)
1507
+ )
1508
+ return
1509
+ except (media.MediaError, edits.EditError, imageopts.OptionError) as e:
1510
+ c.print(f"[yellow]{escape(str(e))}[/]")
1511
+
1512
+
1513
+ def use_references(agent: Agent, arg: str) -> None:
1514
+ """/use 3 @photo.png: images every next request follows (as references) until /use off."""
1515
+ c = agent.console
1516
+ if arg in ("off", "none", "clear"):
1517
+ agent.image_refs = []
1518
+ c.print("[dim]No reference images now.[/]")
1519
+ return
1520
+ if arg:
1521
+ refs = [r.lstrip("@") for r in arg.split()]
1522
+ paths = [media.resolve(agent, f"#{NUMBER.match(r).group(1)}" if NUMBER.match(r) else r) for r in refs]
1523
+ agent.image_refs = [str(p) for p in paths]
1524
+ if not agent.image_refs:
1525
+ c.print("[dim]No reference images. /use 3 (or /use photo.png) makes the next requests follow one.[/]")
1526
+ return
1527
+ shown = ", ".join(media.shown(agent, Path(p)) for p in agent.image_refs)
1528
+ c.print(f"References for the next requests: [bold]{escape(shown)}[/] [dim](/use off stops)[/]")
1462
1529
 
1463
1530
 
1464
1531
  def gallery(agent: Agent, everything: bool = False) -> None:
@@ -1493,8 +1560,9 @@ def gallery(agent: Agent, everything: bool = False) -> None:
1493
1560
  )
1494
1561
 
1495
1562
 
1496
- def make_image(agent: Agent, prompt: str, options=None) -> None:
1497
- """Image mode: ComfyUI makes the images; Esc or Ctrl+C cancels them there too."""
1563
+ def make_image(agent: Agent, prompt: str = "", options=None, run=None) -> None:
1564
+ """Image mode: ComfyUI makes the images (a request, or `run(cancelled, ask)`); Esc or Ctrl+C cancels them
1565
+ there too."""
1498
1566
  steering = Steering()
1499
1567
  reader = KeyReader(steering)
1500
1568
  reading = agent.interactive and reader.start() # Esc sets steering.pause: here it cancels
@@ -1505,7 +1573,9 @@ def make_image(agent: Agent, prompt: str, options=None) -> None:
1505
1573
 
1506
1574
  try:
1507
1575
  asking = ask if agent.interactive else None
1508
- if options is None:
1576
+ if run is not None:
1577
+ run(lambda: steering.pause, asking)
1578
+ elif options is None:
1509
1579
  media.request(agent, prompt, cancelled=lambda: steering.pause, ask=asking)
1510
1580
  else:
1511
1581
  media.make(agent, prompt, options, cancelled=lambda: steering.pause, ask=asking)
@@ -0,0 +1,13 @@
1
+ {
2
+ "source": "ComfyUI's template utility_birefnet_remove_background, in API format",
3
+ "defaults": {},
4
+ "labels": {"1": "loading the image", "2": "loading BiRefNet", "3": "finding the subject", "5": "cutting it out", "6": "saving"},
5
+ "graph": {
6
+ "1": {"class_type": "LoadImage", "inputs": {"image": "$image"}},
7
+ "2": {"class_type": "LoadBackgroundRemovalModel", "inputs": {"bg_removal_name": "$model"}},
8
+ "3": {"class_type": "RemoveBackground", "inputs": {"bg_removal_model": ["2", 0], "image": ["1", 0]}},
9
+ "4": {"class_type": "InvertMask", "inputs": {"mask": ["3", 0]}},
10
+ "5": {"class_type": "JoinImageWithAlpha", "inputs": {"image": ["1", 0], "alpha": ["4", 0]}},
11
+ "6": {"class_type": "SaveImage", "inputs": {"images": ["5", 0], "filename_prefix": "lcode/image"}}
12
+ }
13
+ }
@@ -0,0 +1,12 @@
1
+ {
2
+ "source": "ComfyUI's template utility-gan_upscaler (for images), in API format",
3
+ "defaults": {"scale_by": 0.5},
4
+ "labels": {"1": "loading the image", "2": "loading the upscaler", "3": "upscaling", "4": "resizing", "5": "saving"},
5
+ "graph": {
6
+ "1": {"class_type": "LoadImage", "inputs": {"image": "$image"}},
7
+ "2": {"class_type": "UpscaleModelLoader", "inputs": {"model_name": "$model"}},
8
+ "3": {"class_type": "ImageUpscaleWithModel", "inputs": {"upscale_model": ["2", 0], "image": ["1", 0]}},
9
+ "4": {"class_type": "ImageScaleBy", "inputs": {"image": ["3", 0], "upscale_method": "lanczos", "scale_by": "$scale_by"}},
10
+ "5": {"class_type": "SaveImage", "inputs": {"images": ["4", 0], "filename_prefix": "lcode/image"}}
11
+ }
12
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lcode-cli
3
- Version: 0.42.0
3
+ Version: 0.43.0
4
4
  Summary: A local-first terminal coding agent powered by open-weight models on your own GPU or Mac, via Ollama.
5
5
  Project-URL: Homepage, https://nasser1941.github.io/lcode/
6
6
  Project-URL: Documentation, https://nasser1941.github.io/lcode/
@@ -1,8 +1,8 @@
1
- lcode/__init__.py,sha256=v3apJPFRjkc0xQqftHToQJR2cWw-oucwV0aezidZmyA,473
1
+ lcode/__init__.py,sha256=SlAe74M7eoZyKxBXWVe_w72XGAaCfPj29sJG6lAuiSc,473
2
2
  lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
3
3
  lcode/acp.py,sha256=d97VV3sbqXdWKhPQQNbYRaDTm8WrUk0mGbDLiOyX7jE,29058
4
4
  lcode/action.py,sha256=wuT5F9EJCdEligPeytzmEi8FpahWBhXer528tpuTj_k,16711
5
- lcode/agent.py,sha256=DmKlZ86NPzicwc_pXDEJ3_vgRwri2XknFq9M01f2Sgk,88527
5
+ lcode/agent.py,sha256=RVf_gQLwkIYxItYduWB2O0aZu8osKvsRxOBL3UN8ANA,88729
6
6
  lcode/api.py,sha256=efsUuIZneUc6Ar6KWerKAoMlMFOlSUtLX4_quplr3t8,17664
7
7
  lcode/backends.py,sha256=343alwyDZin-bEhb5-Es4f8ek1oBQUzEvV6RFWR1VZo,21160
8
8
  lcode/bench.py,sha256=HxeUfy9Kv_0Rug1HAlDLyHYfYd9dw2bMgbXsvUy9nd4,43096
@@ -11,13 +11,14 @@ lcode/bench_runtime.py,sha256=EjzNgIvUkdbT8cbSy1TpmlyeIk6e-ipjlDFpM7Dnqx0,6835
11
11
  lcode/catalog.py,sha256=pZ1azX9OH2r4JE2c6E5OfwywpgS39-W2oXzoCHJhmtM,4141
12
12
  lcode/checkpoints.py,sha256=eqPtmPe44-aweWqc2H21ROogwzIt3NpvcUobsaCHoGY,18354
13
13
  lcode/checks.py,sha256=haYYIPhadKavGYEhh8YWAaWrPMcfzrpT2i6638f4xjQ,12933
14
- lcode/cli.py,sha256=3HPENwGw8rufYAgfmCHVK4R3gkEU2uDfgUyF1gnadzw,51385
14
+ lcode/cli.py,sha256=ZVafS8V-82SuEldT6VlAcqKEuBabA-HCi8Tk_0IP9Pc,51516
15
15
  lcode/clipboard.py,sha256=nTSdJyCXPdtjH947RjvB3QvRqnL5HZsS9KB8EkC42cc,5138
16
16
  lcode/cloud.py,sha256=q5Dl5GuqWvKzBCIso-eh6yomvEOsnQ0LW8ZgaAGwBxc,2131
17
17
  lcode/codesearch.py,sha256=Io8leN1nb_Y-Dvlm9xaGtwUWYXX-ooswuznJV6dGU1s,10766
18
- lcode/comfy.py,sha256=SpIoY9fSvtm1TVtHTkUZ5W59RG55vtMncI-bRogqGBY,26490
18
+ lcode/comfy.py,sha256=_pbLVQS_1clsa7awPrxQ7xIOwp5XzyMHPDAagomEswc,26614
19
19
  lcode/config.py,sha256=F7CRPdlqW6i9CSapqeU83ClOHOsf-Mxx1sWGXRYI1ac,13861
20
20
  lcode/context.py,sha256=wgK16ZcNNnq4ngHx0EmtyKHDFTImciGEmtC5NP8tKJQ,20435
21
+ lcode/edits.py,sha256=iYXSbKv4ZfxP12B1ov6-CLEWvR_tWFUdHRQbER3O8d0,13340
21
22
  lcode/escalate.py,sha256=f-_6_moPaHi2kLuJw2JtkzvTkCl6DoBQgYBOCYLhunM,15120
22
23
  lcode/extensions.py,sha256=W0HFDl0BvROzr7i_qtabHdIbacBgP5o5qIJLzoM2oNE,16806
23
24
  lcode/frontmatter.py,sha256=Bx0n4vLPhp7ZCWx-g7xqzqNy5VoKBxyeeikh5UFB_gA,2441
@@ -29,9 +30,9 @@ lcode/jobs.py,sha256=P1P3FqI6EqL4LgdMX9YGZwCW0Bh5BRXFA_FnQxpHNjE,5618
29
30
  lcode/limits.py,sha256=oJzVQoMyTZ0_Fkpu5t3qxrf9v7Hv9S8U8BRQR5M1aKA,1237
30
31
  lcode/lsp.py,sha256=NtSW7xapv7fg2HAQLuYK1nwIqT7nG0LK1hiTpOmla_4,26086
31
32
  lcode/matching.py,sha256=2N3_qwKJ5gd8el2m_y_egKkFM9rsF1eUhYJcRCVIp98,14015
32
- lcode/media.py,sha256=FHvghSTDsJxXhhpivdlpHrm4FyLEH7HeG2StHPo7bj0,19012
33
- lcode/media.toml,sha256=Lv82r8YIA8CFEybXYV3qHtKGYJDk9hCflVXBG_xlfe0,3545
34
- lcode/media_catalog.py,sha256=y7kR4hzLW6b4tRQjk7b1_Oqq7zc5SWEG7Wux7pksCXI,13716
33
+ lcode/media.py,sha256=F8jRT8J2GkUVUxEMbK4DT6sykxE4JXnKOQ-7Ntl0RgY,28984
34
+ lcode/media.toml,sha256=QSv1QmeR9XukBBnmTtAxAZKp2-EguIGJmbar4rDM-is,4715
35
+ lcode/media_catalog.py,sha256=geAt3B4mOxTydXFFaYA0wBO9512_115eBPAjAWTUIj4,14331
35
36
  lcode/memory.py,sha256=WZEHTp-lVxAwS-yyLQR2xB3TMZ9sejmX2Ivg7T-tl3w,21949
36
37
  lcode/models.toml,sha256=lt2G64zzo_Ryxv2ISRSNZoVD_JqCqYqsM01n8WoXGs8,4098
37
38
  lcode/modes.py,sha256=Ke_mEeYF_SskbVUXMpJMeV5d5fFEQ-Vb-MKp4SIvT0Y,4990
@@ -42,7 +43,7 @@ lcode/planning.py,sha256=Q2upaoZPnXDMQT2F-ETxX1lk_wDntTzhSKNVYIQSoQA,6540
42
43
  lcode/preview.py,sha256=ATzEs6yaI1__bfo2_juWznrTDXUD4QTc_aQMPRCytgA,6253
43
44
  lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
44
45
  lcode/repeats.py,sha256=OTrPdb6BKArDLQEgHaXmJZ-tIFsY_1bk4P0wzzANKLY,5964
45
- lcode/repl.py,sha256=c_XpqiOfQjnQ6Ng1Zff3yqBOb5QJrlzQxDnHo6P_2d4,67500
46
+ lcode/repl.py,sha256=OkJ6ClINTE2P3JNKTP1ulu-bTNCtfro12f4w6kM069g,71932
46
47
  lcode/repomap.py,sha256=1GRJgEUjBl9XHSXsF7tSGXBCEgsY-3CzwMohaTudZ_M,13969
47
48
  lcode/residency.py,sha256=B9pwqG6yx5aQD2M5xyQ-bMnYmhD7fv3wCVw_X6VIeAo,7012
48
49
  lcode/review.py,sha256=0QBIS-R1B2b3zqx5TcC1qZptpKBbrDhYvMKSNCaDAUo,4686
@@ -75,9 +76,11 @@ lcode/servers/valohai.py,sha256=e5xZGIoa4cja4f5i85q1GpL9c7cglUjV8jN2co1CFMY,1443
75
76
  lcode/servers/xai.py,sha256=h2emAvHSnkuB9WmJInlynsDhvxJwNVD2AHFX7vqMXNc,13627
76
77
  lcode/workflows/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
77
78
  lcode/workflows/flux2-klein-4b-t2i.json,sha256=yGrYDT8pG2bns9DS1JavhlX5fwhDgIKfAwrHrRCyE8k,1788
79
+ lcode/workflows/remove-background.json,sha256=iMNaXqlkcWN-mCQWezD3NUbW94cHN3JcPYsSUsfOl64,794
80
+ lcode/workflows/upscale-esrgan.json,sha256=quDmTnncvycggskgvw6zvfNsub7mS0WY3a4J99fWeXc,745
78
81
  lcode/workflows/z-image-turbo-t2i.json,sha256=sHsL7xnnuULOuWGYlQrDl4jOTySdAmb2T82c_cChv0s,1585
79
- lcode_cli-0.42.0.dist-info/METADATA,sha256=k7PWFqR8Oqms3gNa4-HR6NMUUVSsfS2eupoJvVdPO0w,13386
80
- lcode_cli-0.42.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
81
- lcode_cli-0.42.0.dist-info/entry_points.txt,sha256=Hxsn_itbnbgft3U0FwxWdmEt0yoAV2LB-nJDnL9WT20,172
82
- lcode_cli-0.42.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
83
- lcode_cli-0.42.0.dist-info/RECORD,,
82
+ lcode_cli-0.43.0.dist-info/METADATA,sha256=FjFitYxjlbB2iFty4gVvGqNJKSoys9npCyZubZmm5i4,13386
83
+ lcode_cli-0.43.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
84
+ lcode_cli-0.43.0.dist-info/entry_points.txt,sha256=Hxsn_itbnbgft3U0FwxWdmEt0yoAV2LB-nJDnL9WT20,172
85
+ lcode_cli-0.43.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
86
+ lcode_cli-0.43.0.dist-info/RECORD,,