lcode-cli 0.42.0__py3-none-any.whl → 0.44.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 +8 -2
- lcode/api.py +1 -0
- lcode/cli.py +9 -13
- lcode/comfy.py +3 -2
- lcode/config.py +4 -3
- lcode/edits.py +283 -0
- lcode/imageopts.py +21 -2
- lcode/media.py +347 -53
- lcode/media.toml +70 -2
- lcode/media_catalog.py +13 -0
- lcode/modes.py +9 -9
- lcode/repl.py +137 -52
- lcode/workflows/remove-background.json +13 -0
- lcode/workflows/upscale-esrgan.json +12 -0
- lcode/workflows/wan22-ti2v-5b.json +152 -0
- {lcode_cli-0.42.0.dist-info → lcode_cli-0.44.0.dist-info}/METADATA +4 -3
- {lcode_cli-0.42.0.dist-info → lcode_cli-0.44.0.dist-info}/RECORD +21 -17
- {lcode_cli-0.42.0.dist-info → lcode_cli-0.44.0.dist-info}/WHEEL +0 -0
- {lcode_cli-0.42.0.dist-info → lcode_cli-0.44.0.dist-info}/entry_points.txt +0 -0
- {lcode_cli-0.42.0.dist-info → lcode_cli-0.44.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
|
+
video_model: str = "wan22-ti2v-5b" # and video mode's
|
|
312
313
|
preview: str = "auto" # images in the terminal: auto, kitty, iterm, blocks or off (lcode.preview)
|
|
313
314
|
num_batch: int | None = None
|
|
314
315
|
keep_alive: str = "30m"
|
|
@@ -380,7 +381,8 @@ class Agent:
|
|
|
380
381
|
|
|
381
382
|
self.image_options = ImageOptions() # image mode's defaults for this session (/options)
|
|
382
383
|
self.media_results: list[dict] = [] # the session's images, numbered (/gallery, /vary, /open)
|
|
383
|
-
self.last_request:
|
|
384
|
+
self.last_request: dict | None = None # what /again repeats (lcode.media.redo)
|
|
385
|
+
self.image_refs: list[str] = [] # images every next request follows (/use)
|
|
384
386
|
self.reflected = 0 # messages before this index were already checked for notes to remember
|
|
385
387
|
self._memory: memory_notes.Memory | None = None
|
|
386
388
|
self._agent_types: tuple[Path, dict[str, subagents.AgentType], list[str]] | None = None
|
|
@@ -697,7 +699,8 @@ class Agent:
|
|
|
697
699
|
"model": self.settings.model,
|
|
698
700
|
"mode": self.settings.mode,
|
|
699
701
|
"image_model": self.settings.image_model,
|
|
700
|
-
"
|
|
702
|
+
"video_model": self.settings.video_model,
|
|
703
|
+
"media": {"results": self.media_results, "options": self.image_options.to_json(), "refs": self.image_refs},
|
|
701
704
|
"name": self.session_name,
|
|
702
705
|
"title": self.session_title,
|
|
703
706
|
"messages": self.messages,
|
|
@@ -748,9 +751,12 @@ class Agent:
|
|
|
748
751
|
self.settings.mode = data["mode"] # a session belongs to the mode it was made in
|
|
749
752
|
if isinstance(data.get("image_model"), str):
|
|
750
753
|
self.settings.image_model = data["image_model"]
|
|
754
|
+
if isinstance(data.get("video_model"), str):
|
|
755
|
+
self.settings.video_model = data["video_model"]
|
|
751
756
|
media = data.get("media") or {}
|
|
752
757
|
self.media_results = [r for r in media.get("results") or [] if isinstance(r, dict) and "path" in r]
|
|
753
758
|
self.image_options = type(self.image_options).from_json(media.get("options") or {})
|
|
759
|
+
self.image_refs = [r for r in media.get("refs") or [] if isinstance(r, str)]
|
|
754
760
|
self.session_title = data.get("title") or sessions.title_from(self.messages)
|
|
755
761
|
self.tools.read_mtimes.clear() # files may have changed since; the model must read them again
|
|
756
762
|
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
|
+
video_model=cfg["video_model"],
|
|
204
205
|
preview=cfg["preview"],
|
|
205
206
|
web=switch(options.web, cfg["web"], "on"),
|
|
206
207
|
search_backend=cfg["search_backend"],
|
lcode/cli.py
CHANGED
|
@@ -801,16 +801,8 @@ def media_request(agent, prompt: str, out: Console, quiet: bool) -> None:
|
|
|
801
801
|
"""`lcode --mode image -p "…"`: make the image, say where it went (or, with --output json, a result object)."""
|
|
802
802
|
import json
|
|
803
803
|
|
|
804
|
-
from lcode import api, media
|
|
804
|
+
from lcode import api, media
|
|
805
805
|
|
|
806
|
-
if agent.settings.mode == "video":
|
|
807
|
-
message = modes.COMING["video"]
|
|
808
|
-
if quiet:
|
|
809
|
-
print(json.dumps({"type": "result", "status": "error", "error": message}), flush=True)
|
|
810
|
-
else:
|
|
811
|
-
out.print(f"[yellow]{escape(message)}[/]")
|
|
812
|
-
api.close_agent(agent)
|
|
813
|
-
sys.exit(2)
|
|
814
806
|
try:
|
|
815
807
|
made = media.request(agent, prompt)
|
|
816
808
|
except (media.MediaError, KeyboardInterrupt) as e:
|
|
@@ -825,7 +817,7 @@ def media_request(agent, prompt: str, out: Console, quiet: bool) -> None:
|
|
|
825
817
|
result = {
|
|
826
818
|
"type": "result",
|
|
827
819
|
"status": "success",
|
|
828
|
-
"mode":
|
|
820
|
+
"mode": agent.settings.mode,
|
|
829
821
|
"files": [str(path) for one in made for path in one.files],
|
|
830
822
|
"model": made[0].model,
|
|
831
823
|
"seed": made[0].seed,
|
|
@@ -1106,10 +1098,14 @@ def cmd_media(args) -> None:
|
|
|
1106
1098
|
where = (
|
|
1107
1099
|
f"ComfyUI at {server.url}" if running else (f"{install.path} (not running)" if install else "no ComfyUI found")
|
|
1108
1100
|
)
|
|
1109
|
-
console.print(f"[dim]{hw.describe()} · {where} · image
|
|
1110
|
-
for mode in ("image", "video"):
|
|
1101
|
+
console.print(f"[dim]{hw.describe()} · {where} · image: {cfg['image_model']} · video: {cfg['video_model']}[/]\n")
|
|
1102
|
+
for mode in ("image", "video", "tool"):
|
|
1111
1103
|
models = cat.for_mode(mode)
|
|
1112
|
-
console.print(
|
|
1104
|
+
console.print(
|
|
1105
|
+
"[bold]Tools for images[/] [dim](/upscale, /nobg)[/]"
|
|
1106
|
+
if mode == "tool"
|
|
1107
|
+
else f"[bold]{mode.capitalize()} models[/]"
|
|
1108
|
+
)
|
|
1113
1109
|
if not models:
|
|
1114
1110
|
console.print(f" [dim]{media.SOON[mode]}[/]\n")
|
|
1115
1111
|
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/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
|
+
"video_model": ("wan22-ti2v-5b", str, "the model video mode uses: a key from `lcode media`"),
|
|
49
50
|
"preview": ("auto", str, "images in the terminal: auto | kitty | iterm | blocks (colour text) | off"),
|
|
50
51
|
"think": (True, bool, "let the model reason before answering (slower, better)"),
|
|
51
52
|
"web": ("on", str, "web search and page fetching for the model: on | ask | off"),
|
|
@@ -217,12 +218,12 @@ def coerce(key: str, value: object) -> object:
|
|
|
217
218
|
|
|
218
219
|
if value not in PROTOCOLS:
|
|
219
220
|
raise ConfigError(f"preview must be one of {', '.join(PROTOCOLS)}")
|
|
220
|
-
if key
|
|
221
|
+
if key in ("image_model", "video_model"):
|
|
221
222
|
from lcode import media_catalog
|
|
222
223
|
|
|
223
|
-
keys = [m.key for m in media_catalog.load().for_mode("
|
|
224
|
+
keys = [m.key for m in media_catalog.load().for_mode(key.removesuffix("_model"))]
|
|
224
225
|
if value not in keys:
|
|
225
|
-
raise ConfigError(f"
|
|
226
|
+
raise ConfigError(f"{key} must be one of {', '.join(keys)} (see lcode media)")
|
|
226
227
|
if typ is bool and isinstance(value, str):
|
|
227
228
|
if value.lower() not in ("true", "false", "1", "0", "yes", "no", "on", "off"):
|
|
228
229
|
raise ConfigError(f"{key} must be true or false")
|
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
CHANGED
|
@@ -13,6 +13,7 @@ from dataclasses import asdict, dataclass, replace
|
|
|
13
13
|
|
|
14
14
|
QUALITIES = ("fast", "balanced", "best")
|
|
15
15
|
MAX_COUNT = 8
|
|
16
|
+
MAX_SECONDS = 8 # video: longer clips need more memory than a laptop GPU has, and models drift
|
|
16
17
|
SIZES = { # names for aspect ratios
|
|
17
18
|
"square": "1:1",
|
|
18
19
|
"portrait": "3:4",
|
|
@@ -46,6 +47,8 @@ ALIASES = {
|
|
|
46
47
|
"no": "negative",
|
|
47
48
|
"negative": "negative",
|
|
48
49
|
"style": "style",
|
|
50
|
+
"seconds": "seconds",
|
|
51
|
+
"duration": "seconds",
|
|
49
52
|
}
|
|
50
53
|
FLAG = re.compile(r"(?:^|\s)--?(" + "|".join(sorted(ALIASES, key=len, reverse=True)) + r")\b(?=[\s=]|$)")
|
|
51
54
|
DIMENSIONS = re.compile(r"^(\d{2,4})\s*[x×]\s*(\d{2,4})$")
|
|
@@ -64,10 +67,13 @@ class Options:
|
|
|
64
67
|
quality: str = "balanced"
|
|
65
68
|
negative: str = ""
|
|
66
69
|
style: str = ""
|
|
70
|
+
seconds: float = 5.0 # video only
|
|
67
71
|
|
|
68
|
-
def describe(self) -> str:
|
|
72
|
+
def describe(self, video: bool = False) -> str:
|
|
69
73
|
seed = "random" if self.seed is None else str(self.seed)
|
|
70
74
|
parts = [f"n={self.n}", f"size={self.size}", f"seed={seed}", f"quality={self.quality}"]
|
|
75
|
+
if video:
|
|
76
|
+
parts.append(f"seconds={self.seconds:g}")
|
|
71
77
|
if self.style:
|
|
72
78
|
parts.append(f"style={self.style}")
|
|
73
79
|
if self.negative:
|
|
@@ -92,7 +98,7 @@ def set_values(options: Options, values: dict[str, object]) -> Options:
|
|
|
92
98
|
for raw_key, raw in values.items():
|
|
93
99
|
key = ALIASES.get(str(raw_key).lower().lstrip("-"))
|
|
94
100
|
if key is None:
|
|
95
|
-
raise OptionError(f"unknown option {raw_key!r}: use n, ar (or size), seed, quality, style or
|
|
101
|
+
raise OptionError(f"unknown option {raw_key!r}: use n, ar (or size), seed, quality, style, no or seconds")
|
|
96
102
|
value = str(raw).strip() if raw is not None else ""
|
|
97
103
|
if key == "n":
|
|
98
104
|
if not value.isdigit() or not 1 <= int(value) <= MAX_COUNT:
|
|
@@ -120,6 +126,14 @@ def set_values(options: Options, values: dict[str, object]) -> Options:
|
|
|
120
126
|
raise OptionError(f"style is one of {', '.join(STYLES)} (or none)")
|
|
121
127
|
elif key == "negative":
|
|
122
128
|
out.negative = "" if value.lower() in ("none", "off") else value
|
|
129
|
+
elif key == "seconds":
|
|
130
|
+
try:
|
|
131
|
+
seconds = float(value.rstrip("s"))
|
|
132
|
+
except ValueError:
|
|
133
|
+
seconds = 0
|
|
134
|
+
if not 1 <= seconds <= MAX_SECONDS:
|
|
135
|
+
raise OptionError(f"seconds is how long a video is: 1 to {MAX_SECONDS}")
|
|
136
|
+
out.seconds = seconds
|
|
123
137
|
return out
|
|
124
138
|
|
|
125
139
|
|
|
@@ -203,5 +217,10 @@ def replace_seed(options: Options, n: int | None = None) -> Options:
|
|
|
203
217
|
return replace(options, seed=None, n=n or options.n)
|
|
204
218
|
|
|
205
219
|
|
|
220
|
+
def frames(seconds: float, fps: int) -> int:
|
|
221
|
+
"""Frames for a clip: a multiple of 4 plus 1, as video models that compress time 4 to 1 need."""
|
|
222
|
+
return max(1, round(seconds * fps / 4)) * 4 + 1
|
|
223
|
+
|
|
224
|
+
|
|
206
225
|
def styled(prompt: str, style: str) -> str:
|
|
207
226
|
return f"{prompt}, {STYLES[style]}" if style else prompt
|