lcode-cli 0.41.0__py3-none-any.whl → 0.42.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lcode/__init__.py +1 -1
- lcode/agent.py +13 -1
- lcode/api.py +1 -0
- lcode/cli.py +8 -7
- lcode/comfy.py +5 -3
- lcode/config.py +6 -0
- lcode/imageopts.py +207 -0
- lcode/media.py +195 -15
- lcode/media.toml +6 -0
- lcode/media_catalog.py +9 -0
- lcode/preview.py +152 -0
- lcode/repl.py +107 -3
- lcode/sessions.py +2 -2
- {lcode_cli-0.41.0.dist-info → lcode_cli-0.42.0.dist-info}/METADATA +1 -1
- {lcode_cli-0.41.0.dist-info → lcode_cli-0.42.0.dist-info}/RECORD +18 -16
- {lcode_cli-0.41.0.dist-info → lcode_cli-0.42.0.dist-info}/WHEEL +0 -0
- {lcode_cli-0.41.0.dist-info → lcode_cli-0.42.0.dist-info}/entry_points.txt +0 -0
- {lcode_cli-0.41.0.dist-info → lcode_cli-0.42.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,11 @@ 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: tuple[str, ImageOptions] | None = None # for /again
|
|
378
384
|
self.reflected = 0 # messages before this index were already checked for notes to remember
|
|
379
385
|
self._memory: memory_notes.Memory | None = None
|
|
380
386
|
self._agent_types: tuple[Path, dict[str, subagents.AgentType], list[str]] | None = None
|
|
@@ -679,8 +685,10 @@ class Agent:
|
|
|
679
685
|
return any(m.get("role") == "user" for m in self.messages)
|
|
680
686
|
|
|
681
687
|
def save(self) -> None:
|
|
682
|
-
if not (self.has_conversation() or self.session_name):
|
|
688
|
+
if not (self.has_conversation() or self.session_name or self.media_results):
|
|
683
689
|
return # nothing worth resuming
|
|
690
|
+
if not self.session_title and not self.has_conversation() and self.media_results:
|
|
691
|
+
self.session_title = " ".join(self.media_results[0]["prompt"].split())[:60] # an image session
|
|
684
692
|
self.session_title = self.session_title or sessions.title_from(self.messages)
|
|
685
693
|
f = self.session_file()
|
|
686
694
|
f.parent.mkdir(parents=True, exist_ok=True)
|
|
@@ -689,6 +697,7 @@ class Agent:
|
|
|
689
697
|
"model": self.settings.model,
|
|
690
698
|
"mode": self.settings.mode,
|
|
691
699
|
"image_model": self.settings.image_model,
|
|
700
|
+
"media": {"results": self.media_results, "options": self.image_options.to_json()},
|
|
692
701
|
"name": self.session_name,
|
|
693
702
|
"title": self.session_title,
|
|
694
703
|
"messages": self.messages,
|
|
@@ -739,6 +748,9 @@ class Agent:
|
|
|
739
748
|
self.settings.mode = data["mode"] # a session belongs to the mode it was made in
|
|
740
749
|
if isinstance(data.get("image_model"), str):
|
|
741
750
|
self.settings.image_model = data["image_model"]
|
|
751
|
+
media = data.get("media") or {}
|
|
752
|
+
self.media_results = [r for r in media.get("results") or [] if isinstance(r, dict) and "path" in r]
|
|
753
|
+
self.image_options = type(self.image_options).from_json(media.get("options") or {})
|
|
742
754
|
self.session_title = data.get("title") or sessions.title_from(self.messages)
|
|
743
755
|
self.tools.read_mtimes.clear() # files may have changed since; the model must read them again
|
|
744
756
|
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)
|
lcode/comfy.py
CHANGED
|
@@ -560,13 +560,15 @@ def describe_history_error(entry: dict) -> str:
|
|
|
560
560
|
|
|
561
561
|
|
|
562
562
|
def save(server: Server, outputs: dict, folder: Path, stem: str, meta: dict) -> list[Path]:
|
|
563
|
-
"""Fetch the job's files into `folder`: stem-
|
|
563
|
+
"""Fetch the job's files into `folder`: stem.png (stem-2.png if that's taken), or stem-1.png, … for several,
|
|
564
|
+
each with its settings in a .json next to it."""
|
|
564
565
|
folder.mkdir(parents=True, exist_ok=True)
|
|
565
566
|
items = [item for node in outputs.values() for kind in OUTPUT_KINDS for item in node.get(kind, [])]
|
|
567
|
+
items = [it for it in items if it.get("type", "output") == "output"]
|
|
566
568
|
saved = []
|
|
567
|
-
for i, item in enumerate(
|
|
569
|
+
for i, item in enumerate(items, 1):
|
|
568
570
|
ext = Path(item["filename"]).suffix or ".png"
|
|
569
|
-
path = unique(folder / f"{stem}-{i}{ext}")
|
|
571
|
+
path = unique(folder / (f"{stem}{ext}" if len(items) == 1 else f"{stem}-{i}{ext}"))
|
|
570
572
|
data = server.fetch(item)
|
|
571
573
|
if ext.lower() == ".png":
|
|
572
574
|
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/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
|
lcode/media.py
CHANGED
|
@@ -6,16 +6,21 @@ sizes, variations and a gallery come with #151, video with #153.
|
|
|
6
6
|
|
|
7
7
|
from __future__ import annotations
|
|
8
8
|
|
|
9
|
+
import os
|
|
9
10
|
import random
|
|
11
|
+
import shutil
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
10
14
|
import time
|
|
11
15
|
from collections.abc import Callable
|
|
12
|
-
from dataclasses import dataclass
|
|
16
|
+
from dataclasses import dataclass, field, replace
|
|
13
17
|
from pathlib import Path
|
|
14
18
|
from typing import TYPE_CHECKING
|
|
15
19
|
|
|
20
|
+
import requests
|
|
16
21
|
from rich.text import Text
|
|
17
22
|
|
|
18
|
-
from lcode import comfy
|
|
23
|
+
from lcode import comfy, imageopts, preview
|
|
19
24
|
from lcode import media_catalog as catalog
|
|
20
25
|
|
|
21
26
|
if TYPE_CHECKING:
|
|
@@ -52,6 +57,9 @@ class Made:
|
|
|
52
57
|
width: int
|
|
53
58
|
height: int
|
|
54
59
|
seconds: float
|
|
60
|
+
prompt: str = ""
|
|
61
|
+
numbers: list[int] = field(default_factory=list) # in the session's gallery
|
|
62
|
+
thumbnail: bytes = b"" # a small PNG, for the half-block preview
|
|
55
63
|
|
|
56
64
|
|
|
57
65
|
# ----------------------------------------------------------------------------- ComfyUI, started and stopped
|
|
@@ -217,13 +225,79 @@ def ensure_installed(
|
|
|
217
225
|
return files
|
|
218
226
|
|
|
219
227
|
|
|
228
|
+
THUMB_NODES = ("9001", "9002") # the thumbnail lcode adds to a workflow, for the half-block preview
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def add_thumbnail(graph: dict, width: int, height: int) -> None:
|
|
232
|
+
"""Have ComfyUI also make a small copy of the image (not saved, a preview), for the half-block preview."""
|
|
233
|
+
save = next((nid for nid, node in graph.items() if node.get("class_type") == "SaveImage"), None)
|
|
234
|
+
if save is None:
|
|
235
|
+
return
|
|
236
|
+
tw, th = preview.thumbnail_size(width, height)
|
|
237
|
+
scale, show = THUMB_NODES
|
|
238
|
+
graph[scale] = {
|
|
239
|
+
"class_type": "ImageScale",
|
|
240
|
+
"inputs": {"image": graph[save]["inputs"]["images"], "upscale_method": "area", "width": tw, "height": th,
|
|
241
|
+
"crop": "disabled"},
|
|
242
|
+
} # fmt: skip
|
|
243
|
+
graph[show] = {"class_type": "PreviewImage", "inputs": {"images": [scale, 0]}}
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def thumbnail(server: comfy.Server, outputs: dict) -> bytes:
|
|
247
|
+
for item in (outputs.get(THUMB_NODES[1]) or {}).get("images", []):
|
|
248
|
+
try:
|
|
249
|
+
return server.fetch(item)
|
|
250
|
+
except (comfy.ComfyError, OSError, requests.RequestException):
|
|
251
|
+
return b""
|
|
252
|
+
return b""
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def request(
|
|
256
|
+
agent: Agent, text: str, cancelled: Callable[[], bool] = lambda: False, ask: Callable[[str], bool] | None = None
|
|
257
|
+
) -> list[Made]:
|
|
258
|
+
"""A typed request: the prompt and its flags (--n 4 --ar 16:9 …, over the session's /options), then the images."""
|
|
259
|
+
try:
|
|
260
|
+
prompt, options = imageopts.parse(text, agent.image_options)
|
|
261
|
+
except imageopts.OptionError as e:
|
|
262
|
+
raise MediaError(str(e)) from e
|
|
263
|
+
return make(agent, prompt, options, cancelled, ask)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def make(
|
|
267
|
+
agent: Agent,
|
|
268
|
+
prompt: str,
|
|
269
|
+
options: imageopts.Options,
|
|
270
|
+
cancelled: Callable[[], bool] = lambda: False,
|
|
271
|
+
ask: Callable[[str], bool] | None = None,
|
|
272
|
+
) -> list[Made]:
|
|
273
|
+
"""`options.n` images of the prompt; a fixed seed counts up from image to image."""
|
|
274
|
+
if not prompt.strip():
|
|
275
|
+
raise MediaError("Describe the image you want.")
|
|
276
|
+
model = current(agent)
|
|
277
|
+
if options.negative and not model.negative:
|
|
278
|
+
agent.say(f"{model.name} doesn't use a negative prompt: describe what you want instead (--no is ignored).")
|
|
279
|
+
agent.last_request = (prompt, options)
|
|
280
|
+
made = []
|
|
281
|
+
for i in range(options.n):
|
|
282
|
+
seed = None if options.seed is None else options.seed + i
|
|
283
|
+
label = f"{i + 1} of {options.n}" if options.n > 1 else ""
|
|
284
|
+
made.append(generate_image(agent, prompt, cancelled, ask, replace(options, seed=seed, n=1), label))
|
|
285
|
+
return made
|
|
286
|
+
|
|
287
|
+
|
|
220
288
|
def generate_image(
|
|
221
|
-
agent: Agent,
|
|
289
|
+
agent: Agent,
|
|
290
|
+
prompt: str,
|
|
291
|
+
cancelled: Callable[[], bool] = lambda: False,
|
|
292
|
+
ask: Callable[[str], bool] | None = None,
|
|
293
|
+
options: imageopts.Options | None = None,
|
|
294
|
+
label: str = "",
|
|
222
295
|
) -> Made:
|
|
223
|
-
"""One image from the prompt, saved in the media folder. Raises MediaError with what to do."""
|
|
296
|
+
"""One image from the prompt, saved in the media folder and numbered. Raises MediaError with what to do."""
|
|
224
297
|
prompt = prompt.strip()
|
|
225
298
|
if not prompt:
|
|
226
299
|
raise MediaError("Describe the image you want.")
|
|
300
|
+
options = options or imageopts.Options()
|
|
227
301
|
server = engine(agent, ask, cancelled)
|
|
228
302
|
for path in comfy.collect(server):
|
|
229
303
|
agent.say(f"Saved {shown(agent, path)}: it finished while lcode wasn't watching.")
|
|
@@ -231,25 +305,40 @@ def generate_image(
|
|
|
231
305
|
files = ensure_installed(agent, model, server, ask)
|
|
232
306
|
workflow = comfy.load_workflow(model.workflow)
|
|
233
307
|
free_gpu(agent)
|
|
234
|
-
seed = random.randrange(2**32)
|
|
235
|
-
|
|
308
|
+
seed = options.seed if options.seed is not None else random.randrange(2**32)
|
|
309
|
+
steps, megapixels = model.preset(options.quality)
|
|
310
|
+
width, height = imageopts.dimensions(options.size, megapixels)
|
|
311
|
+
text = imageopts.styled(prompt, options.style)
|
|
312
|
+
values = {**workflow.defaults, **files, "prompt": text, "seed": seed, "width": width, "height": height}
|
|
313
|
+
if steps:
|
|
314
|
+
values["steps"] = steps
|
|
315
|
+
if model.negative:
|
|
316
|
+
values["negative"] = options.negative
|
|
236
317
|
graph = comfy.fill(workflow.graph, values)
|
|
318
|
+
add_thumbnail(graph, width, height)
|
|
237
319
|
meta = {
|
|
238
320
|
"prompt": prompt,
|
|
239
321
|
"model": model.name,
|
|
240
322
|
"model_key": model.key,
|
|
241
323
|
"workflow": workflow.key,
|
|
242
324
|
"seed": seed,
|
|
243
|
-
"width":
|
|
244
|
-
"height":
|
|
325
|
+
"width": width,
|
|
326
|
+
"height": height,
|
|
245
327
|
"steps": values["steps"],
|
|
328
|
+
"quality": options.quality,
|
|
329
|
+
"style": options.style,
|
|
330
|
+
"negative": options.negative if model.negative else "",
|
|
331
|
+
"full_prompt": text,
|
|
246
332
|
"files": files,
|
|
247
333
|
"created": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
|
248
334
|
}
|
|
249
335
|
out = folder(agent)
|
|
250
336
|
stem = f"{time.strftime('%Y%m%d-%H%M%S')}-{comfy.slug(prompt)}"
|
|
251
|
-
line = Line(
|
|
337
|
+
line = Line(
|
|
338
|
+
f"{model.name} ({label})" if label else model.name, "Esc or Ctrl+C cancels" if agent.interactive else ""
|
|
339
|
+
)
|
|
252
340
|
job = {"id": ""}
|
|
341
|
+
labels = {**workflow.labels, THUMB_NODES[0]: "making the preview", THUMB_NODES[1]: "making the preview"}
|
|
253
342
|
|
|
254
343
|
def on_event(event: comfy.Event) -> None:
|
|
255
344
|
if event.kind == "queued":
|
|
@@ -258,9 +347,9 @@ def generate_image(
|
|
|
258
347
|
elif event.kind == "waiting":
|
|
259
348
|
line.doing = f"waiting: {event.value} job(s) ahead in ComfyUI's queue"
|
|
260
349
|
elif event.kind == "node":
|
|
261
|
-
line.doing, line.step =
|
|
350
|
+
line.doing, line.step = labels.get(event.node, "working"), None
|
|
262
351
|
elif event.kind == "progress" and event.total:
|
|
263
|
-
line.doing =
|
|
352
|
+
line.doing = labels.get(event.node, line.doing)
|
|
264
353
|
line.progress(event.value, event.total)
|
|
265
354
|
|
|
266
355
|
try:
|
|
@@ -283,11 +372,102 @@ def generate_image(
|
|
|
283
372
|
comfy.forget(prompt_id)
|
|
284
373
|
if not saved:
|
|
285
374
|
raise MediaError("ComfyUI finished but returned no image.")
|
|
286
|
-
made = Made(saved, model.name, seed,
|
|
287
|
-
|
|
375
|
+
made = Made(saved, model.name, seed, width, height, time.monotonic() - line.started, prompt)
|
|
376
|
+
made.thumbnail = thumbnail(server, outputs)
|
|
377
|
+
made.numbers = record(agent, made, meta, options)
|
|
378
|
+
show(agent, made)
|
|
379
|
+
return made
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
# ----------------------------------------------------------------------------- results: numbered, shown, opened
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def record(agent: Agent, made: Made, meta: dict, options: imageopts.Options) -> list[int]:
|
|
386
|
+
"""Number the images in the session's gallery, with what made them (for /gallery, /vary, /open)."""
|
|
387
|
+
numbers = []
|
|
388
|
+
for path in made.files:
|
|
389
|
+
number = len(agent.media_results) + 1
|
|
390
|
+
agent.media_results.append(
|
|
391
|
+
{
|
|
392
|
+
"n": number,
|
|
393
|
+
"path": str(path),
|
|
394
|
+
"prompt": made.prompt,
|
|
395
|
+
"model": made.model,
|
|
396
|
+
"seed": made.seed,
|
|
397
|
+
"width": made.width,
|
|
398
|
+
"height": made.height,
|
|
399
|
+
"steps": meta["steps"],
|
|
400
|
+
"seconds": round(made.seconds, 1),
|
|
401
|
+
"options": options.to_json(),
|
|
402
|
+
}
|
|
403
|
+
)
|
|
404
|
+
numbers.append(number)
|
|
405
|
+
return numbers
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def show(agent: Agent, made: Made) -> None:
|
|
409
|
+
for path, number in zip(made.files, made.numbers or [0] * len(made.files), strict=False):
|
|
288
410
|
agent.console.print(
|
|
289
411
|
Text("◆ ", style="green")
|
|
412
|
+
+ Text(f"#{number} " if number else "", style="bold green")
|
|
290
413
|
+ Text(shown(agent, path), style="bold")
|
|
291
|
-
+ Text(
|
|
414
|
+
+ Text(
|
|
415
|
+
f" {made.width}×{made.height} · {made.model} · seed {made.seed} · {made.seconds:.1f} s", style="dim"
|
|
416
|
+
)
|
|
292
417
|
)
|
|
293
|
-
|
|
418
|
+
if made.files:
|
|
419
|
+
show_preview(agent, made.files[0], made.thumbnail)
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def show_preview(agent: Agent, path: Path, thumb: bytes = b"") -> None:
|
|
423
|
+
"""The image in the terminal: itself where the terminal can show pictures, else a half-block preview."""
|
|
424
|
+
setting = agent.settings.preview
|
|
425
|
+
how = preview.protocol(setting)
|
|
426
|
+
if how == "off" or (setting == "auto" and not agent.console.is_terminal):
|
|
427
|
+
return
|
|
428
|
+
try:
|
|
429
|
+
if how in ("kitty", "iterm"):
|
|
430
|
+
data = path.read_bytes()
|
|
431
|
+
sequence = preview.kitty(data) if how == "kitty" else preview.iterm(data)
|
|
432
|
+
agent.console.file.write(sequence)
|
|
433
|
+
agent.console.file.flush()
|
|
434
|
+
elif thumb:
|
|
435
|
+
agent.console.print(preview.blocks(thumb))
|
|
436
|
+
except (OSError, preview.PngError):
|
|
437
|
+
pass
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def result(agent: Agent, which: str) -> dict:
|
|
441
|
+
"""A gallery entry by its number (#3 or 3), or the last one."""
|
|
442
|
+
if not agent.media_results:
|
|
443
|
+
raise MediaError("No images in this session yet.")
|
|
444
|
+
key = which.strip().lstrip("#")
|
|
445
|
+
if not key:
|
|
446
|
+
return agent.media_results[-1]
|
|
447
|
+
if not key.isdigit() or not 1 <= int(key) <= len(agent.media_results):
|
|
448
|
+
raise MediaError(f"There's no image #{key}: this session has #1 to #{len(agent.media_results)} (/gallery).")
|
|
449
|
+
return agent.media_results[int(key) - 1]
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def open_file(path: Path) -> str:
|
|
453
|
+
"""Open a file in the system's viewer. An error message, or empty."""
|
|
454
|
+
if not path.exists():
|
|
455
|
+
return f"{path} isn't there any more."
|
|
456
|
+
if sys.platform == "darwin":
|
|
457
|
+
command = ["open", str(path)]
|
|
458
|
+
elif sys.platform.startswith("win"):
|
|
459
|
+
os.startfile(str(path)) # type: ignore[attr-defined]
|
|
460
|
+
return ""
|
|
461
|
+
else:
|
|
462
|
+
opener = shutil.which("xdg-open") or shutil.which("gio")
|
|
463
|
+
if not opener:
|
|
464
|
+
return "No program to open it with (xdg-open isn't installed): open the file yourself."
|
|
465
|
+
command = [opener, str(path)] if opener.endswith("xdg-open") else [opener, "open", str(path)]
|
|
466
|
+
try:
|
|
467
|
+
subprocess.Popen(
|
|
468
|
+
command, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
|
469
|
+
start_new_session=True,
|
|
470
|
+
) # fmt: skip
|
|
471
|
+
except OSError as e:
|
|
472
|
+
return f"Couldn't open it: {e}"
|
|
473
|
+
return ""
|
lcode/media.toml
CHANGED
|
@@ -15,6 +15,8 @@
|
|
|
15
15
|
# gpu_gb GPU memory it needs, when known better than lcode's estimate (the largest file + 1.5 GB); ComfyUI
|
|
16
16
|
# fills whatever is free with cached models, so nvidia-smi's peak doesn't tell
|
|
17
17
|
# measured what it did on real hardware; models without it haven't been run yet
|
|
18
|
+
# quality per preset (fast, balanced, best): [steps, megapixels]
|
|
19
|
+
# negative whether it uses a negative prompt (distilled models don't)
|
|
18
20
|
|
|
19
21
|
[files.flux-2-klein-4b-fp8]
|
|
20
22
|
folder = "diffusion_models"
|
|
@@ -60,6 +62,8 @@ files = { model = "flux-2-klein-4b-fp8", text_encoder = "qwen-3-4b", vae = "flux
|
|
|
60
62
|
gpu_gb = 0.0
|
|
61
63
|
measured = "1024×1024: 3.5 s per image, 14 s for the first with ComfyUI starting (RTX 4080 Laptop, 12 GB)"
|
|
62
64
|
notes = "fast and small: 4 steps"
|
|
65
|
+
quality = { fast = [4, 0.5], balanced = [4, 1.0], best = [4, 2.0] }
|
|
66
|
+
negative = false
|
|
63
67
|
|
|
64
68
|
[models.z-image-turbo]
|
|
65
69
|
name = "Z-Image Turbo"
|
|
@@ -72,3 +76,5 @@ files = { model = "z-image-turbo-int8", text_encoder = "qwen-3-4b", vae = "flux-
|
|
|
72
76
|
gpu_gb = 0.0
|
|
73
77
|
measured = "1024×1024: 17 s per image (1.9 s per step), 23 s for the first (RTX 4080 Laptop, 12 GB)"
|
|
74
78
|
notes = "photorealistic, writes text well: 8 steps"
|
|
79
|
+
quality = { fast = [6, 0.5], balanced = [8, 1.0], best = [9, 2.0] }
|
|
80
|
+
negative = false
|
lcode/media_catalog.py
CHANGED
|
@@ -67,6 +67,13 @@ class MediaModel:
|
|
|
67
67
|
gpu_gb: float = 0.0 # measured peak GPU memory; 0 when not measured yet
|
|
68
68
|
measured: str = ""
|
|
69
69
|
notes: str = ""
|
|
70
|
+
quality: dict[str, tuple[int, float]] = field(default_factory=dict, hash=False) # preset -> (steps, megapixels)
|
|
71
|
+
negative: bool = False # whether it uses a negative prompt
|
|
72
|
+
|
|
73
|
+
def preset(self, quality: str) -> tuple[int | None, float]:
|
|
74
|
+
"""(steps, megapixels) for a quality preset: None steps keeps the workflow's."""
|
|
75
|
+
steps, megapixels = self.quality.get(quality) or self.quality.get("balanced") or (None, 1.0)
|
|
76
|
+
return (int(steps) if steps else None), float(megapixels)
|
|
70
77
|
|
|
71
78
|
@property
|
|
72
79
|
def size_gb(self) -> float:
|
|
@@ -137,6 +144,8 @@ def load() -> Catalog:
|
|
|
137
144
|
float(m.get("gpu_gb", 0.0)),
|
|
138
145
|
m.get("measured", ""),
|
|
139
146
|
m.get("notes", ""),
|
|
147
|
+
{name: (int(v[0]), float(v[1])) for name, v in (m.get("quality") or {}).items()},
|
|
148
|
+
bool(m.get("negative", False)),
|
|
140
149
|
)
|
|
141
150
|
return Catalog(files, models)
|
|
142
151
|
|
lcode/preview.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Show an image in the terminal: the picture itself where the terminal can (kitty's graphics protocol, iTerm2's
|
|
2
|
+
inline images), otherwise a small colour preview drawn with half blocks.
|
|
3
|
+
|
|
4
|
+
The half-block preview comes from a thumbnail ComfyUI makes along with the image (a few dozen pixels wide), decoded
|
|
5
|
+
here: lcode needs no imaging library for it. A PNG decoder for 8-bit, non-interlaced images is all that takes.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import base64
|
|
11
|
+
import os
|
|
12
|
+
import struct
|
|
13
|
+
import zlib
|
|
14
|
+
from collections.abc import Mapping
|
|
15
|
+
|
|
16
|
+
from rich.text import Text
|
|
17
|
+
|
|
18
|
+
PROTOCOLS = ("auto", "kitty", "iterm", "blocks", "off")
|
|
19
|
+
COLUMNS = 40 # how wide a preview is, in terminal columns
|
|
20
|
+
THUMB_WIDTH = 64 # pixels: the thumbnail ComfyUI makes for half-block previews
|
|
21
|
+
KITTY_CHUNK = 4096
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def protocol(setting: str = "auto", env: Mapping[str, str] = os.environ) -> str:
|
|
25
|
+
"""kitty, iterm, blocks or off: what this terminal can show (or what the `preview` setting says)."""
|
|
26
|
+
if setting != "auto":
|
|
27
|
+
return setting
|
|
28
|
+
if env.get("TMUX") or env.get("TERM", "").startswith("screen"):
|
|
29
|
+
return "blocks" # multiplexers need passthrough for images: keep to text
|
|
30
|
+
if env.get("KITTY_WINDOW_ID") or env.get("TERM") == "xterm-kitty" or env.get("TERM_PROGRAM") == "ghostty":
|
|
31
|
+
return "kitty"
|
|
32
|
+
if env.get("TERM_PROGRAM") in ("iTerm.app", "WezTerm") or env.get("LC_TERMINAL") == "iTerm2":
|
|
33
|
+
return "iterm"
|
|
34
|
+
return "blocks"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def kitty(png: bytes, columns: int = COLUMNS) -> str:
|
|
38
|
+
"""The escape sequences that show a PNG in kitty (and Ghostty), `columns` wide."""
|
|
39
|
+
data = base64.b64encode(png).decode()
|
|
40
|
+
chunks = [data[i : i + KITTY_CHUNK] for i in range(0, len(data), KITTY_CHUNK)] or [""]
|
|
41
|
+
out = []
|
|
42
|
+
for i, chunk in enumerate(chunks):
|
|
43
|
+
more = 1 if i < len(chunks) - 1 else 0
|
|
44
|
+
head = f"a=T,f=100,c={columns},q=2,m={more}" if i == 0 else f"m={more}"
|
|
45
|
+
out.append(f"\x1b_G{head};{chunk}\x1b\\")
|
|
46
|
+
return "".join(out) + "\n"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def iterm(png: bytes, columns: int = COLUMNS) -> str:
|
|
50
|
+
"""The escape sequence that shows an image in iTerm2 (and WezTerm), `columns` wide."""
|
|
51
|
+
data = base64.b64encode(png).decode()
|
|
52
|
+
return f"\x1b]1337;File=inline=1;size={len(png)};width={columns};preserveAspectRatio=1:{data}\x07\n"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
# ----------------------------------------------------------------------------- PNG, decoded
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class PngError(ValueError):
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def decode_png(data: bytes) -> tuple[int, int, list[list[tuple[int, int, int]]]]:
|
|
63
|
+
"""(width, height, rows of RGB pixels) of an 8-bit, non-interlaced PNG (grey, RGB, palette, with alpha)."""
|
|
64
|
+
if not data.startswith(b"\x89PNG\r\n\x1a\n"):
|
|
65
|
+
raise PngError("not a PNG")
|
|
66
|
+
i, idat, palette = 8, [], b""
|
|
67
|
+
width = height = depth = color = interlace = 0
|
|
68
|
+
while i + 8 <= len(data):
|
|
69
|
+
(n,) = struct.unpack(">I", data[i : i + 4])
|
|
70
|
+
kind, body = data[i + 4 : i + 8], data[i + 8 : i + 8 + n]
|
|
71
|
+
if kind == b"IHDR":
|
|
72
|
+
width, height, depth, color, _, _, interlace = struct.unpack(">IIBBBBB", body)
|
|
73
|
+
elif kind == b"PLTE":
|
|
74
|
+
palette = body
|
|
75
|
+
elif kind == b"IDAT":
|
|
76
|
+
idat.append(body)
|
|
77
|
+
elif kind == b"IEND":
|
|
78
|
+
break
|
|
79
|
+
i += 12 + n
|
|
80
|
+
if depth != 8 or interlace or color not in (0, 2, 3, 4, 6):
|
|
81
|
+
raise PngError(f"unsupported PNG (bit depth {depth}, colour type {color}, interlace {interlace})")
|
|
82
|
+
channels = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4}[color]
|
|
83
|
+
raw = zlib.decompress(b"".join(idat))
|
|
84
|
+
stride = width * channels
|
|
85
|
+
rows, previous = [], bytearray(stride)
|
|
86
|
+
for y in range(height):
|
|
87
|
+
start = y * (stride + 1)
|
|
88
|
+
kind, line = raw[start], bytearray(raw[start + 1 : start + 1 + stride])
|
|
89
|
+
unfilter(kind, line, previous, channels)
|
|
90
|
+
rows.append(pixels(line, color, channels, palette))
|
|
91
|
+
previous = line
|
|
92
|
+
return width, height, rows
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def unfilter(kind: int, line: bytearray, previous: bytearray, bpp: int) -> None:
|
|
96
|
+
if kind == 0:
|
|
97
|
+
return
|
|
98
|
+
for x in range(len(line)):
|
|
99
|
+
left = line[x - bpp] if x >= bpp else 0
|
|
100
|
+
up = previous[x]
|
|
101
|
+
if kind == 1:
|
|
102
|
+
line[x] = (line[x] + left) & 0xFF
|
|
103
|
+
elif kind == 2:
|
|
104
|
+
line[x] = (line[x] + up) & 0xFF
|
|
105
|
+
elif kind == 3:
|
|
106
|
+
line[x] = (line[x] + (left + up) // 2) & 0xFF
|
|
107
|
+
elif kind == 4:
|
|
108
|
+
corner = previous[x - bpp] if x >= bpp else 0
|
|
109
|
+
p = left + up - corner
|
|
110
|
+
pa, pb, pc = abs(p - left), abs(p - up), abs(p - corner)
|
|
111
|
+
line[x] = (line[x] + (left if pa <= pb and pa <= pc else up if pb <= pc else corner)) & 0xFF
|
|
112
|
+
else:
|
|
113
|
+
raise PngError(f"unknown PNG filter {kind}")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def pixels(line: bytearray, color: int, channels: int, palette: bytes) -> list[tuple[int, int, int]]:
|
|
117
|
+
out = []
|
|
118
|
+
for x in range(0, len(line), channels):
|
|
119
|
+
if color in (0, 4):
|
|
120
|
+
out.append((line[x], line[x], line[x]))
|
|
121
|
+
elif color == 3:
|
|
122
|
+
j = line[x] * 3
|
|
123
|
+
out.append(tuple(palette[j : j + 3]) if j + 3 <= len(palette) else (0, 0, 0))
|
|
124
|
+
else:
|
|
125
|
+
out.append((line[x], line[x + 1], line[x + 2]))
|
|
126
|
+
return out
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def blocks(png: bytes, columns: int = COLUMNS) -> Text:
|
|
130
|
+
"""A colour preview `columns` wide: each character shows two pixels, one above the other (▀)."""
|
|
131
|
+
width, height, rows = decode_png(png)
|
|
132
|
+
columns = max(1, min(columns, width))
|
|
133
|
+
lines = max(1, round(height * columns / width / 2))
|
|
134
|
+
text = Text()
|
|
135
|
+
for row in range(lines):
|
|
136
|
+
for col in range(columns):
|
|
137
|
+
x = min(width - 1, col * width // columns)
|
|
138
|
+
top = rows[min(height - 1, (2 * row) * height // (2 * lines))][x]
|
|
139
|
+
bottom = rows[min(height - 1, (2 * row + 1) * height // (2 * lines))][x]
|
|
140
|
+
text.append("▀", style=f"{hex_color(top)} on {hex_color(bottom)}")
|
|
141
|
+
if row < lines - 1:
|
|
142
|
+
text.append("\n")
|
|
143
|
+
return text
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def hex_color(rgb: tuple[int, int, int]) -> str:
|
|
147
|
+
return "#{:02x}{:02x}{:02x}".format(*rgb)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def thumbnail_size(width: int, height: int, thumb: int = THUMB_WIDTH) -> tuple[int, int]:
|
|
151
|
+
"""The thumbnail ComfyUI makes for the half-block preview: `thumb` pixels wide, the image's shape."""
|
|
152
|
+
return thumb, max(2, round(thumb * height / width))
|
lcode/repl.py
CHANGED
|
@@ -79,6 +79,11 @@ COMMANDS = {
|
|
|
79
79
|
"/plan": "Plan before changing anything: /plan <request>, or /plan to show the approved plan",
|
|
80
80
|
"/mode": "What lcode makes: code, image or video, e.g. /mode image",
|
|
81
81
|
"/permissions": "Permission mode: ask | plan | auto-edit | yolo (Shift+Tab cycles)",
|
|
82
|
+
"/options": "Image mode's options for this session, e.g. /options n=4 ar=16:9 quality=best style=photo",
|
|
83
|
+
"/again": "Image mode: the last request again, with new seeds (flags work: /again --n 4)",
|
|
84
|
+
"/vary": "Image mode: variations of an image, new seeds, e.g. /vary 3 (default: the last; --n 2)",
|
|
85
|
+
"/gallery": "Image mode: this session's images with their settings (/gallery all: every image here)",
|
|
86
|
+
"/open": "Open an image in your viewer, e.g. /open 3 (default: the last)",
|
|
82
87
|
"/cd": "Change the working directory",
|
|
83
88
|
"/todos": "Show the current todo list",
|
|
84
89
|
"/status": "The task sheet: your requests, the plan and notes, files changed, the last check",
|
|
@@ -1181,6 +1186,8 @@ def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
|
|
|
1181
1186
|
run_safely(agent, arg)
|
|
1182
1187
|
elif cmd == "/mode":
|
|
1183
1188
|
mode_command(agent, arg.strip().lower())
|
|
1189
|
+
elif cmd in ("/options", "/again", "/vary", "/gallery", "/open"):
|
|
1190
|
+
image_command(agent, cmd, arg)
|
|
1184
1191
|
elif cmd == "/permissions":
|
|
1185
1192
|
if arg in PERMISSION_MODES:
|
|
1186
1193
|
agent.perms.mode = arg
|
|
@@ -1394,8 +1401,100 @@ def respond(agent: Agent, line: str) -> None:
|
|
|
1394
1401
|
run_safely(agent, line)
|
|
1395
1402
|
|
|
1396
1403
|
|
|
1397
|
-
def
|
|
1398
|
-
"""
|
|
1404
|
+
def image_command(agent: Agent, cmd: str, arg: str) -> None:
|
|
1405
|
+
"""/options, /again, /vary, /gallery and /open: image mode's follow-ups on its results."""
|
|
1406
|
+
from lcode import imageopts
|
|
1407
|
+
|
|
1408
|
+
c = agent.console
|
|
1409
|
+
if cmd == "/options":
|
|
1410
|
+
if not arg.strip():
|
|
1411
|
+
c.print(f"Image options: [bold]{escape(agent.image_options.describe())}[/]")
|
|
1412
|
+
c.print(
|
|
1413
|
+
'[dim]Set them with /options n=4 ar=16:9 seed=42 quality=best style=photo no="text"; '
|
|
1414
|
+
f"styles: {', '.join(imageopts.STYLES)}. Flags in a request (--n 4) win for that request.[/]"
|
|
1415
|
+
)
|
|
1416
|
+
return
|
|
1417
|
+
try:
|
|
1418
|
+
agent.image_options = imageopts.set_values(agent.image_options, imageopts.parse_assignments(arg))
|
|
1419
|
+
except imageopts.OptionError as e:
|
|
1420
|
+
c.print(f"[red]{escape(str(e))}[/]")
|
|
1421
|
+
return
|
|
1422
|
+
c.print(f"Image options: [bold]{escape(agent.image_options.describe())}[/]")
|
|
1423
|
+
return
|
|
1424
|
+
if cmd == "/gallery":
|
|
1425
|
+
gallery(agent, everything=arg.strip() == "all")
|
|
1426
|
+
return
|
|
1427
|
+
if cmd == "/open":
|
|
1428
|
+
try:
|
|
1429
|
+
entry = media.result(agent, arg)
|
|
1430
|
+
except media.MediaError as e:
|
|
1431
|
+
c.print(f"[yellow]{escape(str(e))}[/]")
|
|
1432
|
+
return
|
|
1433
|
+
problem = media.open_file(Path(entry["path"]))
|
|
1434
|
+
c.print(f"[yellow]{escape(problem)}[/]" if problem else f"[dim]Opened #{entry['n']}.[/]")
|
|
1435
|
+
return
|
|
1436
|
+
if agent.settings.mode != "image":
|
|
1437
|
+
c.print("[yellow]/again and /vary work in image mode: /mode image.[/]")
|
|
1438
|
+
return
|
|
1439
|
+
try:
|
|
1440
|
+
number, _ = imageopts.parse(arg, imageopts.Options()) # what isn't a flag: the image's number
|
|
1441
|
+
except imageopts.OptionError as e:
|
|
1442
|
+
c.print(f"[red]{escape(str(e))}[/]")
|
|
1443
|
+
return
|
|
1444
|
+
if cmd == "/again":
|
|
1445
|
+
if agent.last_request is None:
|
|
1446
|
+
c.print("[yellow]Nothing to do again yet: describe an image first.[/]")
|
|
1447
|
+
return
|
|
1448
|
+
prompt, options = agent.last_request
|
|
1449
|
+
options = imageopts.replace_seed(options)
|
|
1450
|
+
else:
|
|
1451
|
+
try:
|
|
1452
|
+
entry = media.result(agent, number)
|
|
1453
|
+
except media.MediaError as e:
|
|
1454
|
+
c.print(f"[yellow]{escape(str(e))}[/]")
|
|
1455
|
+
return
|
|
1456
|
+
prompt = entry["prompt"]
|
|
1457
|
+
options = imageopts.Options.from_json(entry.get("options") or {})
|
|
1458
|
+
options = imageopts.replace_seed(options, n=4)
|
|
1459
|
+
c.print(f"[dim]Variations of #{entry['n']}: {escape(prompt)}[/]")
|
|
1460
|
+
_, options = imageopts.parse(arg, options) # flags given here win
|
|
1461
|
+
make_image(agent, prompt, options)
|
|
1462
|
+
|
|
1463
|
+
|
|
1464
|
+
def gallery(agent: Agent, everything: bool = False) -> None:
|
|
1465
|
+
"""The session's images (or every image in the media folder), with what made them."""
|
|
1466
|
+
import json as json_module
|
|
1467
|
+
|
|
1468
|
+
c = agent.console
|
|
1469
|
+
if everything:
|
|
1470
|
+
folder = media.folder(agent)
|
|
1471
|
+
entries = []
|
|
1472
|
+
for sidecar in sorted(folder.glob("*.json"), reverse=True)[:50]:
|
|
1473
|
+
try:
|
|
1474
|
+
meta = json_module.loads(sidecar.read_text())
|
|
1475
|
+
except (OSError, ValueError):
|
|
1476
|
+
continue
|
|
1477
|
+
entries.append({"path": str(sidecar.with_name(meta.get("file", sidecar.stem + ".png"))), **meta})
|
|
1478
|
+
if not entries:
|
|
1479
|
+
c.print(f"[dim]No images in {media.shown(agent, folder)} yet.[/]")
|
|
1480
|
+
return
|
|
1481
|
+
c.print(f"[bold]{len(entries)} latest image(s) in {escape(media.shown(agent, folder))}[/]")
|
|
1482
|
+
else:
|
|
1483
|
+
entries = agent.media_results
|
|
1484
|
+
if not entries:
|
|
1485
|
+
c.print("[dim]No images in this session yet: describe one. /gallery all lists every image here.[/]")
|
|
1486
|
+
return
|
|
1487
|
+
for entry in entries:
|
|
1488
|
+
number = f"#{entry['n']:<3}" if "n" in entry else " "
|
|
1489
|
+
c.print(
|
|
1490
|
+
f" [bold green]{number}[/] {escape(media.shown(agent, Path(entry['path'])))}\n"
|
|
1491
|
+
f" [dim]{escape(entry.get('prompt', ''))} · {entry.get('width')}×{entry.get('height')} · "
|
|
1492
|
+
f"{escape(str(entry.get('model', '')))} · seed {entry.get('seed')}[/]"
|
|
1493
|
+
)
|
|
1494
|
+
|
|
1495
|
+
|
|
1496
|
+
def make_image(agent: Agent, prompt: str, options=None) -> None:
|
|
1497
|
+
"""Image mode: ComfyUI makes the images; Esc or Ctrl+C cancels them there too."""
|
|
1399
1498
|
steering = Steering()
|
|
1400
1499
|
reader = KeyReader(steering)
|
|
1401
1500
|
reading = agent.interactive and reader.start() # Esc sets steering.pause: here it cancels
|
|
@@ -1405,7 +1504,11 @@ def make_image(agent: Agent, prompt: str) -> None:
|
|
|
1405
1504
|
return answer.strip().lower() in ("y", "yes")
|
|
1406
1505
|
|
|
1407
1506
|
try:
|
|
1408
|
-
|
|
1507
|
+
asking = ask if agent.interactive else None
|
|
1508
|
+
if options is None:
|
|
1509
|
+
media.request(agent, prompt, cancelled=lambda: steering.pause, ask=asking)
|
|
1510
|
+
else:
|
|
1511
|
+
media.make(agent, prompt, options, cancelled=lambda: steering.pause, ask=asking)
|
|
1409
1512
|
except media.MediaError as e:
|
|
1410
1513
|
agent.console.print(f"[yellow]{escape(str(e))}[/]")
|
|
1411
1514
|
except KeyboardInterrupt: # while ComfyUI was starting: nothing queued yet
|
|
@@ -1419,6 +1522,7 @@ def make_image(agent: Agent, prompt: str) -> None:
|
|
|
1419
1522
|
if typed:
|
|
1420
1523
|
agent.unsent = typed
|
|
1421
1524
|
agent.console.print(Text("✎ What you typed is in the prompt below.", style="cyan"))
|
|
1525
|
+
agent.save() # the gallery and options carry over when the session is resumed
|
|
1422
1526
|
|
|
1423
1527
|
|
|
1424
1528
|
def preload(agent: Agent, session: PromptSession | None = None) -> None:
|
lcode/sessions.py
CHANGED
|
@@ -24,7 +24,7 @@ class SessionInfo:
|
|
|
24
24
|
title: str # the first request, shortened
|
|
25
25
|
model: str
|
|
26
26
|
updated: float # modification time of the session file
|
|
27
|
-
turns: int # number of requests from the user
|
|
27
|
+
turns: int # number of requests from the user (and images made, in image mode)
|
|
28
28
|
|
|
29
29
|
@property
|
|
30
30
|
def label(self) -> str:
|
|
@@ -69,7 +69,7 @@ def read_info(path: Path) -> SessionInfo | None:
|
|
|
69
69
|
title=data.get("title") or title_from(messages),
|
|
70
70
|
model=data.get("model", ""),
|
|
71
71
|
updated=updated,
|
|
72
|
-
turns=sum(1 for m in messages if m.get("role") == "user"),
|
|
72
|
+
turns=sum(1 for m in messages if m.get("role") == "user") + len((data.get("media") or {}).get("results") or []),
|
|
73
73
|
)
|
|
74
74
|
|
|
75
75
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: lcode-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.42.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,9 +1,9 @@
|
|
|
1
|
-
lcode/__init__.py,sha256=
|
|
1
|
+
lcode/__init__.py,sha256=v3apJPFRjkc0xQqftHToQJR2cWw-oucwV0aezidZmyA,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
|
|
6
|
-
lcode/api.py,sha256=
|
|
5
|
+
lcode/agent.py,sha256=DmKlZ86NPzicwc_pXDEJ3_vgRwri2XknFq9M01f2Sgk,88527
|
|
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
|
|
9
9
|
lcode/bench_backend.py,sha256=X2fo-zRX92Xp3KMxSsFlWioc2vRupBTtowj5kLME8mw,14287
|
|
@@ -11,12 +11,12 @@ 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=3HPENwGw8rufYAgfmCHVK4R3gkEU2uDfgUyF1gnadzw,51385
|
|
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=
|
|
19
|
-
lcode/config.py,sha256=
|
|
18
|
+
lcode/comfy.py,sha256=SpIoY9fSvtm1TVtHTkUZ5W59RG55vtMncI-bRogqGBY,26490
|
|
19
|
+
lcode/config.py,sha256=F7CRPdlqW6i9CSapqeU83ClOHOsf-Mxx1sWGXRYI1ac,13861
|
|
20
20
|
lcode/context.py,sha256=wgK16ZcNNnq4ngHx0EmtyKHDFTImciGEmtC5NP8tKJQ,20435
|
|
21
21
|
lcode/escalate.py,sha256=f-_6_moPaHi2kLuJw2JtkzvTkCl6DoBQgYBOCYLhunM,15120
|
|
22
22
|
lcode/extensions.py,sha256=W0HFDl0BvROzr7i_qtabHdIbacBgP5o5qIJLzoM2oNE,16806
|
|
@@ -24,13 +24,14 @@ lcode/frontmatter.py,sha256=Bx0n4vLPhp7ZCWx-g7xqzqNy5VoKBxyeeikh5UFB_gA,2441
|
|
|
24
24
|
lcode/gitflow.py,sha256=6j_nnCBZXkX1qhTI7K4CyZ5D0NAZLpH96weFEo64AkQ,27913
|
|
25
25
|
lcode/hardware.py,sha256=2VbknZCxTZJwJUD8YWdQ7ryEQo_LcjtoOBOHOU9Y0fw,3085
|
|
26
26
|
lcode/hooks.py,sha256=K0itOiAuH4rxndJ9gne_uCrqby6oWAzFPRrfB3tCq-E,9324
|
|
27
|
+
lcode/imageopts.py,sha256=CQBvuEE-aK5C0wFqgTp1jxcpw-bOU2DJZpBYuqyA88I,7791
|
|
27
28
|
lcode/jobs.py,sha256=P1P3FqI6EqL4LgdMX9YGZwCW0Bh5BRXFA_FnQxpHNjE,5618
|
|
28
29
|
lcode/limits.py,sha256=oJzVQoMyTZ0_Fkpu5t3qxrf9v7Hv9S8U8BRQR5M1aKA,1237
|
|
29
30
|
lcode/lsp.py,sha256=NtSW7xapv7fg2HAQLuYK1nwIqT7nG0LK1hiTpOmla_4,26086
|
|
30
31
|
lcode/matching.py,sha256=2N3_qwKJ5gd8el2m_y_egKkFM9rsF1eUhYJcRCVIp98,14015
|
|
31
|
-
lcode/media.py,sha256=
|
|
32
|
-
lcode/media.toml,sha256=
|
|
33
|
-
lcode/media_catalog.py,sha256=
|
|
32
|
+
lcode/media.py,sha256=FHvghSTDsJxXhhpivdlpHrm4FyLEH7HeG2StHPo7bj0,19012
|
|
33
|
+
lcode/media.toml,sha256=Lv82r8YIA8CFEybXYV3qHtKGYJDk9hCflVXBG_xlfe0,3545
|
|
34
|
+
lcode/media_catalog.py,sha256=y7kR4hzLW6b4tRQjk7b1_Oqq7zc5SWEG7Wux7pksCXI,13716
|
|
34
35
|
lcode/memory.py,sha256=WZEHTp-lVxAwS-yyLQR2xB3TMZ9sejmX2Ivg7T-tl3w,21949
|
|
35
36
|
lcode/models.toml,sha256=lt2G64zzo_Ryxv2ISRSNZoVD_JqCqYqsM01n8WoXGs8,4098
|
|
36
37
|
lcode/modes.py,sha256=Ke_mEeYF_SskbVUXMpJMeV5d5fFEQ-Vb-MKp4SIvT0Y,4990
|
|
@@ -38,16 +39,17 @@ lcode/notify.py,sha256=uieDeXe7XwuVHjOk4tqVyZEW_85XWkoWQmFOWEf77oc,1538
|
|
|
38
39
|
lcode/ollama.py,sha256=xFHU2XW47ANJ34-1aeSMQVj28GhqcWiFi6zASH9FiYY,8053
|
|
39
40
|
lcode/permissions.py,sha256=q7CjDsc_Xrblw7t3_BbxA5LUjxFSswGs1RiJmtMi0po,12288
|
|
40
41
|
lcode/planning.py,sha256=Q2upaoZPnXDMQT2F-ETxX1lk_wDntTzhSKNVYIQSoQA,6540
|
|
42
|
+
lcode/preview.py,sha256=ATzEs6yaI1__bfo2_juWznrTDXUD4QTc_aQMPRCytgA,6253
|
|
41
43
|
lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
|
|
42
44
|
lcode/repeats.py,sha256=OTrPdb6BKArDLQEgHaXmJZ-tIFsY_1bk4P0wzzANKLY,5964
|
|
43
|
-
lcode/repl.py,sha256=
|
|
45
|
+
lcode/repl.py,sha256=c_XpqiOfQjnQ6Ng1Zff3yqBOb5QJrlzQxDnHo6P_2d4,67500
|
|
44
46
|
lcode/repomap.py,sha256=1GRJgEUjBl9XHSXsF7tSGXBCEgsY-3CzwMohaTudZ_M,13969
|
|
45
47
|
lcode/residency.py,sha256=B9pwqG6yx5aQD2M5xyQ-bMnYmhD7fv3wCVw_X6VIeAo,7012
|
|
46
48
|
lcode/review.py,sha256=0QBIS-R1B2b3zqx5TcC1qZptpKBbrDhYvMKSNCaDAUo,4686
|
|
47
49
|
lcode/sandbox.py,sha256=u_pkrVRXhCGhh-wFDPwuZ3jGG7UeXxXRAeBNsm37ZvE,8608
|
|
48
50
|
lcode/screenshots.py,sha256=pmrw_tp8eXzNyCZa5uot5rTgsxqsi3AdmkGn-XF9saY,57056
|
|
49
51
|
lcode/secretscan.py,sha256=6Euvfk2gCtK7lZbuQD84zgWuSECi6OnSw8V5rFu1tfU,13575
|
|
50
|
-
lcode/sessions.py,sha256=
|
|
52
|
+
lcode/sessions.py,sha256=ri3MZXGzkbMyVmMm9sZlYc0HWh56ORnRVch2mA9N16w,4272
|
|
51
53
|
lcode/steer.py,sha256=0vaqLxgRKgC0yGp8xoQfV1f9VEOzH3U4rCIb74eFnZ8,12127
|
|
52
54
|
lcode/subagents.py,sha256=thR2ZzFZ1DeoAf0Ctr_oeKjYajhvd3uRs2kGdw6LlJM,29349
|
|
53
55
|
lcode/tools.py,sha256=lpCIlaabydbmutaXWuOZWS4X-Z_7aIsyNUQdHJzkSC4,68902
|
|
@@ -74,8 +76,8 @@ lcode/servers/xai.py,sha256=h2emAvHSnkuB9WmJInlynsDhvxJwNVD2AHFX7vqMXNc,13627
|
|
|
74
76
|
lcode/workflows/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
75
77
|
lcode/workflows/flux2-klein-4b-t2i.json,sha256=yGrYDT8pG2bns9DS1JavhlX5fwhDgIKfAwrHrRCyE8k,1788
|
|
76
78
|
lcode/workflows/z-image-turbo-t2i.json,sha256=sHsL7xnnuULOuWGYlQrDl4jOTySdAmb2T82c_cChv0s,1585
|
|
77
|
-
lcode_cli-0.
|
|
78
|
-
lcode_cli-0.
|
|
79
|
-
lcode_cli-0.
|
|
80
|
-
lcode_cli-0.
|
|
81
|
-
lcode_cli-0.
|
|
79
|
+
lcode_cli-0.42.0.dist-info/METADATA,sha256=k7PWFqR8Oqms3gNa4-HR6NMUUVSsfS2eupoJvVdPO0w,13386
|
|
80
|
+
lcode_cli-0.42.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
81
|
+
lcode_cli-0.42.0.dist-info/entry_points.txt,sha256=Hxsn_itbnbgft3U0FwxWdmEt0yoAV2LB-nJDnL9WT20,172
|
|
82
|
+
lcode_cli-0.42.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
|
|
83
|
+
lcode_cli-0.42.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|