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 CHANGED
@@ -3,7 +3,7 @@
3
3
  From a program: `from lcode import Session` (see lcode.api).
4
4
  """
5
5
 
6
- __version__ = "0.42.0"
6
+ __version__ = "0.44.0"
7
7
 
8
8
  API = ("Session", "Result", "Options", "SetupError")
9
9
 
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: tuple[str, ImageOptions] | None = None # for /again
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
- "media": {"results": self.media_results, "options": self.image_options.to_json()},
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, modes
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": "image",
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 model: {cfg['image_model']}[/]\n")
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(f"[bold]{mode.capitalize()} models[/]")
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("details") or err.get("message") or ""
340
- lines.append(f"{info.get('class_type', 'node')} ({node}): {detail}")
339
+ message, detail = err.get("message") or "", err.get("details") or ""
340
+ why = f"{message}: {detail}" if message and detail and detail not in message else (detail or message)
341
+ lines.append(f"{info.get('class_type', 'node')} ({node}): {why}")
341
342
  return "; ".join(lines)
342
343
 
343
344
 
lcode/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 == "image_model":
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("image")]
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"image_model must be one of {', '.join(keys)} (see lcode media)")
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 no")
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