lcode-cli 0.41.0__py3-none-any.whl → 0.43.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lcode/__init__.py +1 -1
- lcode/agent.py +15 -1
- lcode/api.py +1 -0
- lcode/cli.py +14 -9
- lcode/comfy.py +8 -5
- lcode/config.py +6 -0
- lcode/edits.py +283 -0
- lcode/imageopts.py +207 -0
- lcode/media.py +412 -22
- lcode/media.toml +43 -2
- lcode/media_catalog.py +21 -0
- lcode/preview.py +152 -0
- lcode/repl.py +177 -3
- lcode/sessions.py +2 -2
- lcode/workflows/remove-background.json +13 -0
- lcode/workflows/upscale-esrgan.json +12 -0
- {lcode_cli-0.41.0.dist-info → lcode_cli-0.43.0.dist-info}/METADATA +1 -1
- {lcode_cli-0.41.0.dist-info → lcode_cli-0.43.0.dist-info}/RECORD +21 -16
- {lcode_cli-0.41.0.dist-info → lcode_cli-0.43.0.dist-info}/WHEEL +0 -0
- {lcode_cli-0.41.0.dist-info → lcode_cli-0.43.0.dist-info}/entry_points.txt +0 -0
- {lcode_cli-0.41.0.dist-info → lcode_cli-0.43.0.dist-info}/licenses/LICENSE +0 -0
lcode/__init__.py
CHANGED
lcode/agent.py
CHANGED
|
@@ -309,6 +309,7 @@ class Settings:
|
|
|
309
309
|
comfyui_path: str | None = None # a ComfyUI install lcode doesn't find by itself (lcode.comfy)
|
|
310
310
|
media_dir: str = ".lcode/media" # where image and video mode save their files (lcode.media)
|
|
311
311
|
image_model: str = "flux2-klein-4b" # the media catalog's model image mode uses (lcode.media_catalog)
|
|
312
|
+
preview: str = "auto" # images in the terminal: auto, kitty, iterm, blocks or off (lcode.preview)
|
|
312
313
|
num_batch: int | None = None
|
|
313
314
|
keep_alive: str = "30m"
|
|
314
315
|
think: bool = True
|
|
@@ -375,6 +376,12 @@ class Agent:
|
|
|
375
376
|
self.interactive = True # False for `lcode -p`: no end-of-session questions
|
|
376
377
|
self.comfy_process = None # the ComfyUI lcode started for image and video mode, stopped when it exits
|
|
377
378
|
self.no_coding_model = "" # why there's no coding model, when lcode started in image or video mode without
|
|
379
|
+
from lcode.imageopts import Options as ImageOptions
|
|
380
|
+
|
|
381
|
+
self.image_options = ImageOptions() # image mode's defaults for this session (/options)
|
|
382
|
+
self.media_results: list[dict] = [] # the session's images, numbered (/gallery, /vary, /open)
|
|
383
|
+
self.last_request: dict | None = None # what /again repeats (lcode.media.redo)
|
|
384
|
+
self.image_refs: list[str] = [] # images every next request follows (/use)
|
|
378
385
|
self.reflected = 0 # messages before this index were already checked for notes to remember
|
|
379
386
|
self._memory: memory_notes.Memory | None = None
|
|
380
387
|
self._agent_types: tuple[Path, dict[str, subagents.AgentType], list[str]] | None = None
|
|
@@ -679,8 +686,10 @@ class Agent:
|
|
|
679
686
|
return any(m.get("role") == "user" for m in self.messages)
|
|
680
687
|
|
|
681
688
|
def save(self) -> None:
|
|
682
|
-
if not (self.has_conversation() or self.session_name):
|
|
689
|
+
if not (self.has_conversation() or self.session_name or self.media_results):
|
|
683
690
|
return # nothing worth resuming
|
|
691
|
+
if not self.session_title and not self.has_conversation() and self.media_results:
|
|
692
|
+
self.session_title = " ".join(self.media_results[0]["prompt"].split())[:60] # an image session
|
|
684
693
|
self.session_title = self.session_title or sessions.title_from(self.messages)
|
|
685
694
|
f = self.session_file()
|
|
686
695
|
f.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -689,6 +698,7 @@ class Agent:
|
|
|
689
698
|
"model": self.settings.model,
|
|
690
699
|
"mode": self.settings.mode,
|
|
691
700
|
"image_model": self.settings.image_model,
|
|
701
|
+
"media": {"results": self.media_results, "options": self.image_options.to_json(), "refs": self.image_refs},
|
|
692
702
|
"name": self.session_name,
|
|
693
703
|
"title": self.session_title,
|
|
694
704
|
"messages": self.messages,
|
|
@@ -739,6 +749,10 @@ class Agent:
|
|
|
739
749
|
self.settings.mode = data["mode"] # a session belongs to the mode it was made in
|
|
740
750
|
if isinstance(data.get("image_model"), str):
|
|
741
751
|
self.settings.image_model = data["image_model"]
|
|
752
|
+
media = data.get("media") or {}
|
|
753
|
+
self.media_results = [r for r in media.get("results") or [] if isinstance(r, dict) and "path" in r]
|
|
754
|
+
self.image_options = type(self.image_options).from_json(media.get("options") or {})
|
|
755
|
+
self.image_refs = [r for r in media.get("refs") or [] if isinstance(r, str)]
|
|
742
756
|
self.session_title = data.get("title") or sessions.title_from(self.messages)
|
|
743
757
|
self.tools.read_mtimes.clear() # files may have changed since; the model must read them again
|
|
744
758
|
self.tools.todos = list(data.get("todos") or [])
|
lcode/api.py
CHANGED
|
@@ -201,6 +201,7 @@ def open_agent(cwd: Path, options: Options, console: Console, hw: Hardware | Non
|
|
|
201
201
|
comfyui_path=cfg["comfyui_path"],
|
|
202
202
|
media_dir=cfg["media_dir"],
|
|
203
203
|
image_model=cfg["image_model"],
|
|
204
|
+
preview=cfg["preview"],
|
|
204
205
|
web=switch(options.web, cfg["web"], "on"),
|
|
205
206
|
search_backend=cfg["search_backend"],
|
|
206
207
|
searxng_url=cfg["searxng_url"],
|
lcode/cli.py
CHANGED
|
@@ -812,7 +812,7 @@ def media_request(agent, prompt: str, out: Console, quiet: bool) -> None:
|
|
|
812
812
|
api.close_agent(agent)
|
|
813
813
|
sys.exit(2)
|
|
814
814
|
try:
|
|
815
|
-
made = media.
|
|
815
|
+
made = media.request(agent, prompt)
|
|
816
816
|
except (media.MediaError, KeyboardInterrupt) as e:
|
|
817
817
|
message = str(e) or "Cancelled."
|
|
818
818
|
if quiet:
|
|
@@ -826,12 +826,13 @@ def media_request(agent, prompt: str, out: Console, quiet: bool) -> None:
|
|
|
826
826
|
"type": "result",
|
|
827
827
|
"status": "success",
|
|
828
828
|
"mode": "image",
|
|
829
|
-
"files": [str(path) for path in
|
|
830
|
-
"model": made.model,
|
|
831
|
-
"seed": made.seed,
|
|
832
|
-
"
|
|
833
|
-
"
|
|
834
|
-
"
|
|
829
|
+
"files": [str(path) for one in made for path in one.files],
|
|
830
|
+
"model": made[0].model,
|
|
831
|
+
"seed": made[0].seed,
|
|
832
|
+
"seeds": [one.seed for one in made],
|
|
833
|
+
"width": made[0].width,
|
|
834
|
+
"height": made[0].height,
|
|
835
|
+
"seconds": round(sum(one.seconds for one in made), 1),
|
|
835
836
|
}
|
|
836
837
|
print(json.dumps(result, ensure_ascii=False), flush=True)
|
|
837
838
|
api.close_agent(agent)
|
|
@@ -1106,9 +1107,13 @@ def cmd_media(args) -> None:
|
|
|
1106
1107
|
f"ComfyUI at {server.url}" if running else (f"{install.path} (not running)" if install else "no ComfyUI found")
|
|
1107
1108
|
)
|
|
1108
1109
|
console.print(f"[dim]{hw.describe()} · {where} · image model: {cfg['image_model']}[/]\n")
|
|
1109
|
-
for mode in ("image", "video"):
|
|
1110
|
+
for mode in ("image", "video", "tool"):
|
|
1110
1111
|
models = cat.for_mode(mode)
|
|
1111
|
-
console.print(
|
|
1112
|
+
console.print(
|
|
1113
|
+
"[bold]Tools for images[/] [dim](/upscale, /nobg)[/]"
|
|
1114
|
+
if mode == "tool"
|
|
1115
|
+
else f"[bold]{mode.capitalize()} models[/]"
|
|
1116
|
+
)
|
|
1112
1117
|
if not models:
|
|
1113
1118
|
console.print(f" [dim]{media.SOON[mode]}[/]\n")
|
|
1114
1119
|
continue
|
lcode/comfy.py
CHANGED
|
@@ -336,8 +336,9 @@ def describe_rejection(r: requests.Response) -> str:
|
|
|
336
336
|
lines = [str((data.get("error") or {}).get("message") or "ComfyUI refused the workflow")]
|
|
337
337
|
for node, info in (data.get("node_errors") or {}).items():
|
|
338
338
|
for err in info.get("errors", [])[:3]:
|
|
339
|
-
detail = err.get("
|
|
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
|
|
|
@@ -560,13 +561,15 @@ def describe_history_error(entry: dict) -> str:
|
|
|
560
561
|
|
|
561
562
|
|
|
562
563
|
def save(server: Server, outputs: dict, folder: Path, stem: str, meta: dict) -> list[Path]:
|
|
563
|
-
"""Fetch the job's files into `folder`: stem-
|
|
564
|
+
"""Fetch the job's files into `folder`: stem.png (stem-2.png if that's taken), or stem-1.png, … for several,
|
|
565
|
+
each with its settings in a .json next to it."""
|
|
564
566
|
folder.mkdir(parents=True, exist_ok=True)
|
|
565
567
|
items = [item for node in outputs.values() for kind in OUTPUT_KINDS for item in node.get(kind, [])]
|
|
568
|
+
items = [it for it in items if it.get("type", "output") == "output"]
|
|
566
569
|
saved = []
|
|
567
|
-
for i, item in enumerate(
|
|
570
|
+
for i, item in enumerate(items, 1):
|
|
568
571
|
ext = Path(item["filename"]).suffix or ".png"
|
|
569
|
-
path = unique(folder / f"{stem}-{i}{ext}")
|
|
572
|
+
path = unique(folder / (f"{stem}{ext}" if len(items) == 1 else f"{stem}-{i}{ext}"))
|
|
570
573
|
data = server.fetch(item)
|
|
571
574
|
if ext.lower() == ".png":
|
|
572
575
|
data = with_text(data, "lcode", json.dumps(meta, ensure_ascii=False))
|
lcode/config.py
CHANGED
|
@@ -46,6 +46,7 @@ SETTINGS: dict[str, tuple[object, type, str]] = {
|
|
|
46
46
|
"comfyui_path": (None, str, "your ComfyUI folder, if lcode doesn't find it (lcode starts it when needed)"),
|
|
47
47
|
"media_dir": (".lcode/media", str, "where images and videos go: a folder in the project, or an absolute path"),
|
|
48
48
|
"image_model": ("flux2-klein-4b", str, "the model image mode uses: a key from `lcode media`"),
|
|
49
|
+
"preview": ("auto", str, "images in the terminal: auto | kitty | iterm | blocks (colour text) | off"),
|
|
49
50
|
"think": (True, bool, "let the model reason before answering (slower, better)"),
|
|
50
51
|
"web": ("on", str, "web search and page fetching for the model: on | ask | off"),
|
|
51
52
|
"search_backend": ("auto", str, "auto | ollama | brave | tavily | searxng (keys come from environment variables)"),
|
|
@@ -211,6 +212,11 @@ def coerce(key: str, value: object) -> object:
|
|
|
211
212
|
raise ConfigError(f"permission_mode must be one of {', '.join(PERMISSION_MODES)}")
|
|
212
213
|
if key == "mode" and value not in WORK_MODES:
|
|
213
214
|
raise ConfigError(f"mode must be one of {', '.join(WORK_MODES)}")
|
|
215
|
+
if key == "preview":
|
|
216
|
+
from lcode.preview import PROTOCOLS
|
|
217
|
+
|
|
218
|
+
if value not in PROTOCOLS:
|
|
219
|
+
raise ConfigError(f"preview must be one of {', '.join(PROTOCOLS)}")
|
|
214
220
|
if key == "image_model":
|
|
215
221
|
from lcode import media_catalog
|
|
216
222
|
|
lcode/edits.py
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Image edits as changes to a ComfyUI workflow: reference images, inpainting through a mask, outpainting.
|
|
2
|
+
|
|
3
|
+
A model that edits with reference latents (FLUX.2 klein) takes the images through its own text-to-image workflow:
|
|
4
|
+
each image is scaled, encoded by the VAE and attached to both conditionings before the guider, and the instruction is
|
|
5
|
+
the prompt. On top of that:
|
|
6
|
+
|
|
7
|
+
- **Inpainting** keeps the original outside a mask: the edited picture is pasted into the original only where the
|
|
8
|
+
mask is white (with a soft edge).
|
|
9
|
+
- **Outpainting** puts the image on a bigger canvas, lets the model fill the new border, and pastes the original back,
|
|
10
|
+
so it stays exactly as it was.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
import struct
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
BASE = 8000 # node ids lcode adds start here (the workflows' own are small numbers)
|
|
20
|
+
SIZE_NODE = str(BASE + 9)
|
|
21
|
+
IMAGE_TYPES = (".png", ".jpg", ".jpeg", ".webp", ".bmp")
|
|
22
|
+
EXTEND_HINT = (
|
|
23
|
+
"Replace the blurry border around the sharp picture with sharp, detailed scenery that continues the picture "
|
|
24
|
+
"outward, matching its light, perspective and style. Keep the sharp middle exactly as it is. No frame, no bars."
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class EditError(ValueError):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def node_of(graph: dict, class_type: str) -> str | None:
|
|
33
|
+
return next((nid for nid, node in graph.items() if node.get("class_type") == class_type), None)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def save_node(graph: dict) -> str:
|
|
37
|
+
nid = node_of(graph, "SaveImage")
|
|
38
|
+
if nid is None:
|
|
39
|
+
raise EditError("the workflow saves no image")
|
|
40
|
+
return nid
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load_images(graph: dict, names: list[str]) -> list[list]:
|
|
44
|
+
"""LoadImage nodes for uploaded images; their IMAGE outputs."""
|
|
45
|
+
links = []
|
|
46
|
+
for k, name in enumerate(names):
|
|
47
|
+
nid = str(BASE + 100 + k)
|
|
48
|
+
graph[nid] = {"class_type": "LoadImage", "inputs": {"image": name}}
|
|
49
|
+
links.append([nid, 0])
|
|
50
|
+
return links
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def add_references(graph: dict, images: list[list], megapixels: float, size_from_first: bool) -> list:
|
|
54
|
+
"""Attach the images as reference latents to the guider's positive and negative conditionings.
|
|
55
|
+
|
|
56
|
+
Each is scaled to `megapixels` first. With `size_from_first`, the picture made has the first image's size
|
|
57
|
+
(its width and height go to every node of the workflow that takes them). Returns the first scaled image.
|
|
58
|
+
"""
|
|
59
|
+
guider = node_of(graph, "CFGGuider")
|
|
60
|
+
vae = node_of(graph, "VAELoader")
|
|
61
|
+
if guider is None or vae is None:
|
|
62
|
+
raise EditError("this workflow can't take reference images")
|
|
63
|
+
positive, negative = graph[guider]["inputs"]["positive"], graph[guider]["inputs"]["negative"]
|
|
64
|
+
first = None
|
|
65
|
+
for k, image in enumerate(images):
|
|
66
|
+
scale, encode, ref_pos, ref_neg = (str(BASE + 10 * (k + 1) + i) for i in range(4))
|
|
67
|
+
graph[scale] = {
|
|
68
|
+
"class_type": "ImageScaleToTotalPixels",
|
|
69
|
+
"inputs": {"image": image, "upscale_method": "lanczos", "megapixels": megapixels, "resolution_steps": 16},
|
|
70
|
+
}
|
|
71
|
+
graph[encode] = {"class_type": "VAEEncode", "inputs": {"pixels": [scale, 0], "vae": [vae, 0]}}
|
|
72
|
+
graph[ref_pos] = {"class_type": "ReferenceLatent", "inputs": {"conditioning": positive, "latent": [encode, 0]}}
|
|
73
|
+
graph[ref_neg] = {"class_type": "ReferenceLatent", "inputs": {"conditioning": negative, "latent": [encode, 0]}}
|
|
74
|
+
positive, negative = [ref_pos, 0], [ref_neg, 0]
|
|
75
|
+
first = first or [scale, 0]
|
|
76
|
+
graph[guider]["inputs"]["positive"], graph[guider]["inputs"]["negative"] = positive, negative
|
|
77
|
+
if size_from_first and first:
|
|
78
|
+
set_size(graph, first)
|
|
79
|
+
return first
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def set_size(graph: dict, image: list) -> None:
|
|
83
|
+
"""The picture made takes `image`'s size: its width and height go to every workflow node that takes them."""
|
|
84
|
+
graph[SIZE_NODE] = {"class_type": "GetImageSize", "inputs": {"image": image}}
|
|
85
|
+
for nid, node in graph.items():
|
|
86
|
+
if nid.isdigit() and int(nid) >= BASE:
|
|
87
|
+
continue # lcode's own nodes keep their sizes
|
|
88
|
+
inputs = node.get("inputs", {})
|
|
89
|
+
for key, out in (("width", 0), ("height", 1)):
|
|
90
|
+
if key in inputs and not isinstance(inputs[key], list):
|
|
91
|
+
inputs[key] = [SIZE_NODE, out]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def scale_to(graph: dict, image: list, megapixels: float) -> list:
|
|
95
|
+
nid = str(BASE + 301)
|
|
96
|
+
graph[nid] = {
|
|
97
|
+
"class_type": "ImageScaleToTotalPixels",
|
|
98
|
+
"inputs": {"image": image, "upscale_method": "lanczos", "megapixels": megapixels, "resolution_steps": 16},
|
|
99
|
+
}
|
|
100
|
+
return [nid, 0]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def mask_from_file(graph: dict, mask_name: str) -> list:
|
|
104
|
+
"""The user's mask image (white: change), at the picture's size, with a soft edge."""
|
|
105
|
+
if SIZE_NODE not in graph:
|
|
106
|
+
raise EditError("inpainting needs the picture at the original's size")
|
|
107
|
+
load, to_image, scale, blur, to_mask = (str(BASE + 200 + i) for i in range(5))
|
|
108
|
+
graph[load] = {"class_type": "LoadImageMask", "inputs": {"image": mask_name, "channel": "red"}}
|
|
109
|
+
graph[to_image] = {"class_type": "MaskToImage", "inputs": {"mask": [load, 0]}}
|
|
110
|
+
graph[scale] = {
|
|
111
|
+
"class_type": "ImageScale",
|
|
112
|
+
"inputs": {"image": [to_image, 0], "upscale_method": "bilinear", "width": [SIZE_NODE, 0],
|
|
113
|
+
"height": [SIZE_NODE, 1], "crop": "disabled"},
|
|
114
|
+
} # fmt: skip
|
|
115
|
+
graph[blur] = {"class_type": "ImageBlur", "inputs": {"image": [scale, 0], "blur_radius": 8, "sigma": 4.0}}
|
|
116
|
+
graph[to_mask] = {"class_type": "ImageToMask", "inputs": {"image": [blur, 0], "channel": "red"}}
|
|
117
|
+
return [to_mask, 0]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def mask_scaled(graph: dict, mask: list, megapixels: float) -> list:
|
|
121
|
+
"""A mask brought to the same size as an image scaled to `megapixels` (outpainting's border)."""
|
|
122
|
+
to_image, scale, to_mask = (str(BASE + 310 + i) for i in range(3))
|
|
123
|
+
graph[to_image] = {"class_type": "MaskToImage", "inputs": {"mask": mask}}
|
|
124
|
+
graph[scale] = {
|
|
125
|
+
"class_type": "ImageScaleToTotalPixels",
|
|
126
|
+
"inputs": {"image": [to_image, 0], "upscale_method": "bilinear", "megapixels": megapixels,
|
|
127
|
+
"resolution_steps": 16},
|
|
128
|
+
} # fmt: skip
|
|
129
|
+
graph[to_mask] = {"class_type": "ImageToMask", "inputs": {"image": [scale, 0], "channel": "red"}}
|
|
130
|
+
return [to_mask, 0]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def sample_inside(graph: dict, image: list, mask: list) -> None:
|
|
134
|
+
"""Start from the image itself and let the sampler change only what the mask covers."""
|
|
135
|
+
sampler = node_of(graph, "SamplerCustomAdvanced") or node_of(graph, "KSampler")
|
|
136
|
+
vae = node_of(graph, "VAELoader")
|
|
137
|
+
if sampler is None or vae is None:
|
|
138
|
+
raise EditError("this workflow can't paint inside a mask")
|
|
139
|
+
encode, masked = str(BASE + 400), str(BASE + 401)
|
|
140
|
+
graph[encode] = {"class_type": "VAEEncode", "inputs": {"pixels": image, "vae": [vae, 0]}}
|
|
141
|
+
graph[masked] = {"class_type": "SetLatentNoiseMask", "inputs": {"samples": [encode, 0], "mask": mask}}
|
|
142
|
+
graph[sampler]["inputs"]["latent_image"] = [masked, 0]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def paste(graph: dict, destination: list, source: list, mask: list) -> None:
|
|
146
|
+
"""The saved picture becomes `destination` with `source` pasted where the mask is white."""
|
|
147
|
+
save = save_node(graph)
|
|
148
|
+
nid = str(BASE + 500)
|
|
149
|
+
graph[nid] = {
|
|
150
|
+
"class_type": "ImageCompositeMasked",
|
|
151
|
+
"inputs": {"destination": destination, "source": source, "x": 0, "y": 0, "resize_source": False,
|
|
152
|
+
"mask": mask},
|
|
153
|
+
} # fmt: skip
|
|
154
|
+
graph[save]["inputs"]["images"] = [nid, 0]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def inpaint(graph: dict, original: list, mask_name: str) -> None:
|
|
158
|
+
"""Change only where the mask image is white: sampled there from the original, then pasted into it."""
|
|
159
|
+
mask = mask_from_file(graph, mask_name)
|
|
160
|
+
sample_inside(graph, original, mask)
|
|
161
|
+
paste(graph, original, graph[save_node(graph)]["inputs"]["images"], mask)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def pad_canvas(graph: dict, image: list, pads: tuple[int, int, int, int]) -> tuple[list, list]:
|
|
165
|
+
"""Outpainting's canvas, bigger by `pads` (left, top, right, bottom); (canvas, mask of the new border).
|
|
166
|
+
|
|
167
|
+
The border starts as a blurred, stretched copy of the image rather than flat grey: a fast model keeps a little of
|
|
168
|
+
what it starts from, and grey would show.
|
|
169
|
+
"""
|
|
170
|
+
pad, size, stretched, blurred, canvas = (str(BASE + 300 + i) for i in (0, 2, 3, 4, 5))
|
|
171
|
+
left, top, right, bottom = pads
|
|
172
|
+
graph[pad] = {
|
|
173
|
+
"class_type": "ImagePadForOutpaint",
|
|
174
|
+
"inputs": {"image": image, "left": left, "top": top, "right": right, "bottom": bottom, "feathering": 48},
|
|
175
|
+
}
|
|
176
|
+
graph[size] = {"class_type": "GetImageSize", "inputs": {"image": [pad, 0]}}
|
|
177
|
+
graph[stretched] = {
|
|
178
|
+
"class_type": "ImageScale",
|
|
179
|
+
"inputs": {"image": image, "upscale_method": "bilinear", "width": [size, 0], "height": [size, 1],
|
|
180
|
+
"crop": "disabled"},
|
|
181
|
+
} # fmt: skip
|
|
182
|
+
graph[blurred] = {"class_type": "ImageBlur", "inputs": {"image": [stretched, 0], "blur_radius": 31, "sigma": 10.0}}
|
|
183
|
+
graph[canvas] = {
|
|
184
|
+
"class_type": "ImageCompositeMasked",
|
|
185
|
+
"inputs": {"destination": [blurred, 0], "source": image, "x": left, "y": top, "resize_source": False},
|
|
186
|
+
}
|
|
187
|
+
return [canvas, 0], [pad, 1]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def outpaint(graph: dict, canvas_scaled: list, border: list, megapixels: float) -> None:
|
|
191
|
+
"""Fill only the new border, sampled around the original, and keep the original exactly as it was."""
|
|
192
|
+
mask = mask_scaled(graph, border, megapixels)
|
|
193
|
+
sample_inside(graph, canvas_scaled, mask)
|
|
194
|
+
invert = str(BASE + 501)
|
|
195
|
+
graph[invert] = {"class_type": "InvertMask", "inputs": {"mask": mask}}
|
|
196
|
+
paste(graph, graph[save_node(graph)]["inputs"]["images"], canvas_scaled, [invert, 0])
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ----------------------------------------------------------------------------- sizes and arguments
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def image_size(path: Path) -> tuple[int, int]:
|
|
203
|
+
"""Width and height from a PNG, JPEG or WebP file's header."""
|
|
204
|
+
data = path.read_bytes()[:65536]
|
|
205
|
+
if data.startswith(b"\x89PNG\r\n\x1a\n"):
|
|
206
|
+
return struct.unpack(">II", data[16:24])
|
|
207
|
+
if data[:2] == b"\xff\xd8":
|
|
208
|
+
i = 2
|
|
209
|
+
while i + 9 < len(data):
|
|
210
|
+
if data[i] != 0xFF:
|
|
211
|
+
i += 1
|
|
212
|
+
continue
|
|
213
|
+
marker = data[i + 1]
|
|
214
|
+
length = struct.unpack(">H", data[i + 2 : i + 4])[0]
|
|
215
|
+
if marker in (0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF):
|
|
216
|
+
h, w = struct.unpack(">HH", data[i + 5 : i + 9])
|
|
217
|
+
return w, h
|
|
218
|
+
i += 2 + length
|
|
219
|
+
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
|
220
|
+
kind = data[12:16]
|
|
221
|
+
if kind == b"VP8X":
|
|
222
|
+
return int.from_bytes(data[24:27], "little") + 1, int.from_bytes(data[27:30], "little") + 1
|
|
223
|
+
if kind == b"VP8 ":
|
|
224
|
+
w, h = struct.unpack("<HH", data[26:30])
|
|
225
|
+
return w & 0x3FFF, h & 0x3FFF
|
|
226
|
+
if kind == b"VP8L":
|
|
227
|
+
bits = int.from_bytes(data[21:25], "little")
|
|
228
|
+
return (bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1
|
|
229
|
+
raise EditError(f"can't read the size of {path.name}: use a PNG, JPEG or WebP image")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def pads_for_ratio(width: int, height: int, ratio: str) -> tuple[int, int, int, int]:
|
|
233
|
+
"""Borders that bring the image to an aspect ratio (a:b), split evenly, in steps of 8 pixels."""
|
|
234
|
+
m = re.fullmatch(r"(\d{1,2}):(\d{1,2})", ratio)
|
|
235
|
+
if not m or not int(m.group(1)) or not int(m.group(2)):
|
|
236
|
+
raise EditError("the shape to extend to is a ratio, e.g. --ar 16:9")
|
|
237
|
+
a, b = int(m.group(1)), int(m.group(2))
|
|
238
|
+
if abs(width / height - a / b) < 0.01:
|
|
239
|
+
raise EditError(f"it's {a}:{b} already: choose another shape, or --left/--right/--top/--bottom pixels")
|
|
240
|
+
if width / height < a / b: # wider
|
|
241
|
+
extra = round(height * a / b) - width
|
|
242
|
+
left = (extra // 2) // 8 * 8
|
|
243
|
+
return left, 0, (extra - left + 7) // 8 * 8, 0
|
|
244
|
+
extra = round(width * b / a) - height
|
|
245
|
+
top = (extra // 2) // 8 * 8
|
|
246
|
+
return 0, top, 0, (extra - top + 7) // 8 * 8
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
EXTEND_FLAG = re.compile(r"--(left|top|right|bottom)\s+(\d+)")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def parse_extend(text: str, width: int, height: int) -> tuple[tuple[int, int, int, int], str]:
|
|
253
|
+
"""`/extend` arguments: --ar 16:9, or --left/--top/--right/--bottom pixels; the rest describes the new part."""
|
|
254
|
+
sides = {k: int(v) for k, v in EXTEND_FLAG.findall(text)}
|
|
255
|
+
text = EXTEND_FLAG.sub(" ", text)
|
|
256
|
+
ratio = re.search(r"--(?:ar|size)\s+(\S+)", text)
|
|
257
|
+
text = re.sub(r"--(?:ar|size)\s+\S+", " ", text)
|
|
258
|
+
if ratio and sides:
|
|
259
|
+
raise EditError("extend by a shape (--ar 16:9) or by sides (--left 256), not both")
|
|
260
|
+
if ratio:
|
|
261
|
+
from lcode.imageopts import SIZES
|
|
262
|
+
|
|
263
|
+
pads = pads_for_ratio(width, height, SIZES.get(ratio.group(1), ratio.group(1)))
|
|
264
|
+
elif sides:
|
|
265
|
+
pads = tuple((sides.get(k, 0) + 7) // 8 * 8 for k in ("left", "top", "right", "bottom"))
|
|
266
|
+
if any(p > 2048 for p in pads):
|
|
267
|
+
raise EditError("a side grows by at most 2048 pixels")
|
|
268
|
+
else:
|
|
269
|
+
pads = pads_for_ratio(width, height, "16:9" if width <= height * 16 / 9 - 1 else "1:1")
|
|
270
|
+
return pads, " ".join(text.split())
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
REFERENCE = re.compile(r'(?:^|(?<=\s))@(#\d+|"[^"]+"|\S+)')
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def split_references(text: str) -> tuple[str, list[str]]:
|
|
277
|
+
"""The prompt without its @references, and the references (#3, or file paths) in order."""
|
|
278
|
+
refs = [m.group(1).strip('"') for m in REFERENCE.finditer(text)]
|
|
279
|
+
return " ".join(REFERENCE.sub(" ", text).split()), refs
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def is_image(path: Path) -> bool:
|
|
283
|
+
return path.suffix.lower() in IMAGE_TYPES
|
lcode/imageopts.py
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Image options: how many, what shape, which seed, how good, what to avoid, which style.
|
|
2
|
+
|
|
3
|
+
They're typed in the request (`a red fox --n 4 --ar 16:9 --seed 42 --quality best --style photo --no "people"`), or
|
|
4
|
+
set for the session with `/options n=4 ar=16:9`. Flags in a request win over the session's options for that request.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import math
|
|
10
|
+
import re
|
|
11
|
+
import shlex
|
|
12
|
+
from dataclasses import asdict, dataclass, replace
|
|
13
|
+
|
|
14
|
+
QUALITIES = ("fast", "balanced", "best")
|
|
15
|
+
MAX_COUNT = 8
|
|
16
|
+
SIZES = { # names for aspect ratios
|
|
17
|
+
"square": "1:1",
|
|
18
|
+
"portrait": "3:4",
|
|
19
|
+
"landscape": "4:3",
|
|
20
|
+
"wide": "16:9",
|
|
21
|
+
"tall": "9:16",
|
|
22
|
+
"photo": "3:2",
|
|
23
|
+
"cinema": "21:9",
|
|
24
|
+
}
|
|
25
|
+
STYLES = {
|
|
26
|
+
"photo": "professional photograph, natural light, sharp focus, 35mm lens",
|
|
27
|
+
"cinematic": "cinematic film still, dramatic lighting, shallow depth of field, color graded",
|
|
28
|
+
"anime": "anime illustration, clean line art, vibrant cel shading",
|
|
29
|
+
"watercolor": "watercolor painting, soft washes, visible paper texture",
|
|
30
|
+
"oil": "oil painting, visible brush strokes, rich colors",
|
|
31
|
+
"pixel": "pixel art, 16-bit, crisp pixels, limited palette",
|
|
32
|
+
"3d": "3D render, soft global illumination, studio lighting",
|
|
33
|
+
"sketch": "pencil sketch, graphite, cross-hatching, on paper",
|
|
34
|
+
"comic": "comic book art, bold ink outlines, halftone shading",
|
|
35
|
+
"flat": "flat vector illustration, minimal, bold shapes",
|
|
36
|
+
}
|
|
37
|
+
ALIASES = {
|
|
38
|
+
"n": "n",
|
|
39
|
+
"count": "n",
|
|
40
|
+
"ar": "size",
|
|
41
|
+
"aspect": "size",
|
|
42
|
+
"size": "size",
|
|
43
|
+
"seed": "seed",
|
|
44
|
+
"quality": "quality",
|
|
45
|
+
"q": "quality",
|
|
46
|
+
"no": "negative",
|
|
47
|
+
"negative": "negative",
|
|
48
|
+
"style": "style",
|
|
49
|
+
}
|
|
50
|
+
FLAG = re.compile(r"(?:^|\s)--?(" + "|".join(sorted(ALIASES, key=len, reverse=True)) + r")\b(?=[\s=]|$)")
|
|
51
|
+
DIMENSIONS = re.compile(r"^(\d{2,4})\s*[x×]\s*(\d{2,4})$")
|
|
52
|
+
RATIO = re.compile(r"^(\d{1,2})\s*:\s*(\d{1,2})$")
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class OptionError(ValueError):
|
|
56
|
+
pass
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass
|
|
60
|
+
class Options:
|
|
61
|
+
n: int = 1
|
|
62
|
+
size: str = "1:1" # an aspect ratio (16:9), a name (wide) or pixels (1344x768)
|
|
63
|
+
seed: int | None = None # None: a new random seed for each image
|
|
64
|
+
quality: str = "balanced"
|
|
65
|
+
negative: str = ""
|
|
66
|
+
style: str = ""
|
|
67
|
+
|
|
68
|
+
def describe(self) -> str:
|
|
69
|
+
seed = "random" if self.seed is None else str(self.seed)
|
|
70
|
+
parts = [f"n={self.n}", f"size={self.size}", f"seed={seed}", f"quality={self.quality}"]
|
|
71
|
+
if self.style:
|
|
72
|
+
parts.append(f"style={self.style}")
|
|
73
|
+
if self.negative:
|
|
74
|
+
parts.append(f"no={self.negative!r}")
|
|
75
|
+
return " ".join(parts)
|
|
76
|
+
|
|
77
|
+
def to_json(self) -> dict:
|
|
78
|
+
return asdict(self)
|
|
79
|
+
|
|
80
|
+
@classmethod
|
|
81
|
+
def from_json(cls, data: dict) -> Options:
|
|
82
|
+
known = {k: v for k, v in (data or {}).items() if k in cls.__dataclass_fields__}
|
|
83
|
+
try:
|
|
84
|
+
return set_values(cls(), known)
|
|
85
|
+
except OptionError:
|
|
86
|
+
return cls()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def set_values(options: Options, values: dict[str, object]) -> Options:
|
|
90
|
+
"""A copy of `options` with `values` (by name or alias) applied. Raises OptionError saying what's wrong."""
|
|
91
|
+
out = replace(options)
|
|
92
|
+
for raw_key, raw in values.items():
|
|
93
|
+
key = ALIASES.get(str(raw_key).lower().lstrip("-"))
|
|
94
|
+
if key is None:
|
|
95
|
+
raise OptionError(f"unknown option {raw_key!r}: use n, ar (or size), seed, quality, style or no")
|
|
96
|
+
value = str(raw).strip() if raw is not None else ""
|
|
97
|
+
if key == "n":
|
|
98
|
+
if not value.isdigit() or not 1 <= int(value) <= MAX_COUNT:
|
|
99
|
+
raise OptionError(f"n is how many images: 1 to {MAX_COUNT}")
|
|
100
|
+
out.n = int(value)
|
|
101
|
+
elif key == "size":
|
|
102
|
+
out.size = check_size(value)
|
|
103
|
+
elif key == "seed":
|
|
104
|
+
if value.lower() in ("random", "none", ""):
|
|
105
|
+
out.seed = None
|
|
106
|
+
elif value.isdigit() and int(value) < 2**63:
|
|
107
|
+
out.seed = int(value)
|
|
108
|
+
else:
|
|
109
|
+
raise OptionError("seed is a whole number, or random")
|
|
110
|
+
elif key == "quality":
|
|
111
|
+
if value.lower() not in QUALITIES:
|
|
112
|
+
raise OptionError(f"quality is {', '.join(QUALITIES)}")
|
|
113
|
+
out.quality = value.lower()
|
|
114
|
+
elif key == "style":
|
|
115
|
+
if value.lower() in ("none", "off", ""):
|
|
116
|
+
out.style = ""
|
|
117
|
+
elif value.lower() in STYLES:
|
|
118
|
+
out.style = value.lower()
|
|
119
|
+
else:
|
|
120
|
+
raise OptionError(f"style is one of {', '.join(STYLES)} (or none)")
|
|
121
|
+
elif key == "negative":
|
|
122
|
+
out.negative = "" if value.lower() in ("none", "off") else value
|
|
123
|
+
return out
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def check_size(value: str) -> str:
|
|
127
|
+
v = value.lower().replace(" ", "")
|
|
128
|
+
if v in SIZES:
|
|
129
|
+
return v
|
|
130
|
+
if RATIO.match(v):
|
|
131
|
+
w, h = (int(x) for x in RATIO.match(v).groups())
|
|
132
|
+
if w and h and 1 / 4 <= w / h <= 4:
|
|
133
|
+
return f"{w}:{h}"
|
|
134
|
+
m = DIMENSIONS.match(v)
|
|
135
|
+
if m:
|
|
136
|
+
w, h = (int(x) for x in m.groups())
|
|
137
|
+
if 256 <= w <= 2048 and 256 <= h <= 2048:
|
|
138
|
+
return f"{w}x{h}"
|
|
139
|
+
raise OptionError("a size in pixels is 256 to 2048 on each side, e.g. 1344x768")
|
|
140
|
+
raise OptionError(f"size is a ratio (16:9), pixels (1344x768) or one of {', '.join(SIZES)}")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def parse(text: str, defaults: Options) -> tuple[str, Options]:
|
|
144
|
+
"""The prompt without its flags, and the options for this request. Raises OptionError."""
|
|
145
|
+
matches = list(FLAG.finditer(text))
|
|
146
|
+
if not matches:
|
|
147
|
+
return text.strip(), defaults
|
|
148
|
+
prompt = text[: matches[0].start()]
|
|
149
|
+
values: dict[str, str] = {}
|
|
150
|
+
for i, m in enumerate(matches):
|
|
151
|
+
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
|
|
152
|
+
rest = text[m.end() : end].strip().lstrip("=").strip()
|
|
153
|
+
try:
|
|
154
|
+
words = shlex.split(rest)
|
|
155
|
+
except ValueError:
|
|
156
|
+
words = rest.split()
|
|
157
|
+
key = ALIASES[m.group(1).lower()]
|
|
158
|
+
if key == "negative":
|
|
159
|
+
values[key] = " ".join(words)
|
|
160
|
+
else:
|
|
161
|
+
if not words:
|
|
162
|
+
raise OptionError(f"--{m.group(1)} needs a value")
|
|
163
|
+
values[key] = words[0]
|
|
164
|
+
if len(words) > 1: # words after a flag's value belong to the prompt
|
|
165
|
+
prompt += " " + " ".join(words[1:])
|
|
166
|
+
return prompt.strip(), set_values(defaults, values)
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def parse_assignments(text: str) -> dict[str, str]:
|
|
170
|
+
"""`n=4 ar=16:9 no="blurry, text"` -> {"n": "4", "ar": "16:9", "no": "blurry, text"}."""
|
|
171
|
+
try:
|
|
172
|
+
words = shlex.split(text)
|
|
173
|
+
except ValueError as e:
|
|
174
|
+
raise OptionError(f"can't read {text!r}: {e}") from e
|
|
175
|
+
out = {}
|
|
176
|
+
for word in words:
|
|
177
|
+
key, sep, value = word.partition("=")
|
|
178
|
+
if not sep:
|
|
179
|
+
raise OptionError(f"write options as name=value, e.g. n=4 ar=16:9 (not {word!r})")
|
|
180
|
+
out[key] = value
|
|
181
|
+
return out
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def dimensions(size: str, megapixels: float, multiple: int = 16) -> tuple[int, int]:
|
|
185
|
+
"""Width and height in pixels: from a ratio and the quality's megapixels, or exact pixels as given."""
|
|
186
|
+
m = DIMENSIONS.match(size)
|
|
187
|
+
if m:
|
|
188
|
+
w, h = (int(x) for x in m.groups())
|
|
189
|
+
return round_to(w, multiple), round_to(h, multiple)
|
|
190
|
+
ratio = SIZES.get(size, size)
|
|
191
|
+
w, h = (int(x) for x in ratio.split(":"))
|
|
192
|
+
area = megapixels * 1024 * 1024
|
|
193
|
+
width = math.sqrt(area * w / h)
|
|
194
|
+
return round_to(width, multiple), round_to(width * h / w, multiple)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def round_to(value: float, multiple: int) -> int:
|
|
198
|
+
return max(multiple, round(value / multiple) * multiple)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def replace_seed(options: Options, n: int | None = None) -> Options:
|
|
202
|
+
"""The options with a random seed again (for /again and /vary), and optionally another count."""
|
|
203
|
+
return replace(options, seed=None, n=n or options.n)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def styled(prompt: str, style: str) -> str:
|
|
207
|
+
return f"{prompt}, {STYLES[style]}" if style else prompt
|