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 +1 -1
- lcode/agent.py +4 -2
- lcode/cli.py +6 -2
- lcode/comfy.py +3 -2
- lcode/edits.py +283 -0
- lcode/media.py +247 -37
- lcode/media.toml +37 -2
- lcode/media_catalog.py +12 -0
- lcode/repl.py +107 -37
- lcode/workflows/remove-background.json +13 -0
- lcode/workflows/upscale-esrgan.json +12 -0
- {lcode_cli-0.42.0.dist-info → lcode_cli-0.43.0.dist-info}/METADATA +1 -1
- {lcode_cli-0.42.0.dist-info → lcode_cli-0.43.0.dist-info}/RECORD +16 -13
- {lcode_cli-0.42.0.dist-info → lcode_cli-0.43.0.dist-info}/WHEEL +0 -0
- {lcode_cli-0.42.0.dist-info → lcode_cli-0.43.0.dist-info}/entry_points.txt +0 -0
- {lcode_cli-0.42.0.dist-info → lcode_cli-0.43.0.dist-info}/licenses/LICENSE +0 -0
lcode/__init__.py
CHANGED
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:
|
|
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(
|
|
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("
|
|
340
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
"""`
|
|
274
|
-
|
|
275
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
-
|
|
338
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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, /
|
|
1406
|
-
|
|
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
|
|
1428
|
-
|
|
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
|
-
|
|
1441
|
-
|
|
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
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
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
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
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
|
|
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
|
|
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.
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
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=
|
|
33
|
-
lcode/media.toml,sha256=
|
|
34
|
-
lcode/media_catalog.py,sha256=
|
|
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=
|
|
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.
|
|
80
|
-
lcode_cli-0.
|
|
81
|
-
lcode_cli-0.
|
|
82
|
-
lcode_cli-0.
|
|
83
|
-
lcode_cli-0.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|