lcode-cli 0.43.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.43.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"
@@ -698,6 +699,7 @@ class Agent:
698
699
  "model": self.settings.model,
699
700
  "mode": self.settings.mode,
700
701
  "image_model": self.settings.image_model,
702
+ "video_model": self.settings.video_model,
701
703
  "media": {"results": self.media_results, "options": self.image_options.to_json(), "refs": self.image_refs},
702
704
  "name": self.session_name,
703
705
  "title": self.session_title,
@@ -749,6 +751,8 @@ class Agent:
749
751
  self.settings.mode = data["mode"] # a session belongs to the mode it was made in
750
752
  if isinstance(data.get("image_model"), str):
751
753
  self.settings.image_model = data["image_model"]
754
+ if isinstance(data.get("video_model"), str):
755
+ self.settings.video_model = data["video_model"]
752
756
  media = data.get("media") or {}
753
757
  self.media_results = [r for r in media.get("results") or [] if isinstance(r, dict) and "path" in r]
754
758
  self.image_options = type(self.image_options).from_json(media.get("options") 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,7 +1098,7 @@ 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")
1101
+ console.print(f"[dim]{hw.describe()} · {where} · image: {cfg['image_model']} · video: {cfg['video_model']}[/]\n")
1110
1102
  for mode in ("image", "video", "tool"):
1111
1103
  models = cat.for_mode(mode)
1112
1104
  console.print(
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/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
lcode/media.py CHANGED
@@ -27,7 +27,7 @@ from lcode import media_catalog as catalog
27
27
  if TYPE_CHECKING:
28
28
  from lcode.agent import Agent
29
29
 
30
- SOON = {"video": "Video models come with video generation (#153)."}
30
+ SOON: dict[str, str] = {}
31
31
  IGNORED = "# lcode's images and videos: not part of the project unless you add them\n*\n"
32
32
 
33
33
 
@@ -44,9 +44,11 @@ def installed_lister(agent: Agent) -> catalog.Installed | None:
44
44
  return catalog.lister(install=install) if install else None
45
45
 
46
46
 
47
- def current(agent: Agent) -> catalog.MediaModel:
48
- """The image model the agent uses (the image_model setting, or /model in image mode)."""
47
+ def current(agent: Agent, mode: str | None = None) -> catalog.MediaModel:
48
+ """The model the agent uses in image or video mode (image_model, video_model, or /model there)."""
49
49
  cat = catalog.load()
50
+ if (mode or agent.settings.mode) == "video":
51
+ return cat.find(agent.settings.video_model) or cat.models[catalog.DEFAULT_VIDEO]
50
52
  return cat.find(agent.settings.image_model) or cat.models[catalog.DEFAULT_IMAGE]
51
53
 
52
54
 
@@ -61,6 +63,7 @@ class Made:
61
63
  prompt: str = ""
62
64
  numbers: list[int] = field(default_factory=list) # in the session's gallery
63
65
  thumbnail: bytes = b"" # a small PNG, for the half-block preview
66
+ poster: bytes = b"" # a video's first frame, for terminals that show pictures
64
67
 
65
68
 
66
69
  # ----------------------------------------------------------------------------- ComfyUI, started and stopped
@@ -227,25 +230,36 @@ def ensure_installed(
227
230
 
228
231
 
229
232
  THUMB_NODES = ("9001", "9002") # the thumbnail lcode adds to a workflow, for the half-block preview
233
+ POSTER_NODES = ("9003", "9004") # a video's first frame, picked and kept (not saved) for the preview
230
234
 
231
235
 
232
236
  def add_thumbnail(graph: dict, width: int, height: int) -> None:
233
- """Have ComfyUI also make a small copy of the image (not saved, a preview), for the half-block preview."""
237
+ """Have ComfyUI also make a small copy of the image, or of a video's first frame (not saved, a preview)."""
234
238
  save = next((nid for nid, node in graph.items() if node.get("class_type") == "SaveImage"), None)
235
- if save is None:
239
+ video = next((nid for nid, node in graph.items() if node.get("class_type") == "CreateVideo"), None)
240
+ if save is not None:
241
+ frames = graph[save]["inputs"]["images"]
242
+ elif video is not None:
243
+ first, keep = POSTER_NODES
244
+ graph[first] = {
245
+ "class_type": "ImageFromBatch",
246
+ "inputs": {"image": graph[video]["inputs"]["images"], "batch_index": 0, "length": 1},
247
+ }
248
+ graph[keep] = {"class_type": "PreviewImage", "inputs": {"images": [first, 0]}}
249
+ frames = [first, 0]
250
+ else:
236
251
  return
237
252
  tw, th = preview.thumbnail_size(width, height)
238
253
  scale, show = THUMB_NODES
239
254
  graph[scale] = {
240
255
  "class_type": "ImageScale",
241
- "inputs": {"image": graph[save]["inputs"]["images"], "upscale_method": "area", "width": tw, "height": th,
242
- "crop": "disabled"},
243
- } # fmt: skip
256
+ "inputs": {"image": frames, "upscale_method": "area", "width": tw, "height": th, "crop": "disabled"},
257
+ }
244
258
  graph[show] = {"class_type": "PreviewImage", "inputs": {"images": [scale, 0]}}
245
259
 
246
260
 
247
- def thumbnail(server: comfy.Server, outputs: dict) -> bytes:
248
- for item in (outputs.get(THUMB_NODES[1]) or {}).get("images", []):
261
+ def thumbnail(server: comfy.Server, outputs: dict, node: str = THUMB_NODES[1]) -> bytes:
262
+ for item in (outputs.get(node) or {}).get("images", []):
249
263
  try:
250
264
  return server.fetch(item)
251
265
  except (comfy.ComfyError, OSError, requests.RequestException):
@@ -275,6 +289,12 @@ def request(
275
289
  sources = [resolve(agent, ref) for ref in refs] + [Path(p) for p in agent.image_refs if Path(p).exists()]
276
290
  if mask and not sources:
277
291
  raise MediaError("--mask needs the image it masks: @photo.png --mask mask.png make the sky purple")
292
+ if agent.settings.mode == "video":
293
+ if mask:
294
+ raise MediaError("Video mode doesn't take a mask: @photo.png starts a video from a picture.")
295
+ job = {"kind": "video", "prompt": prompt, "options": options, "sources": [str(p) for p in sources[:1]],
296
+ "mask": None, "pads": None, "keep_shape": not SIZE_FLAG.search(text)} # fmt: skip
297
+ return redo(agent, job, cancelled, ask)
278
298
  job = {"kind": "edit" if sources else "image", "prompt": prompt, "options": options,
279
299
  "sources": [str(p) for p in sources], "mask": str(mask) if mask else None, "pads": None,
280
300
  "keep_shape": not SIZE_FLAG.search(text)} # fmt: skip
@@ -286,9 +306,10 @@ def redo(
286
306
  ) -> list[Made]:
287
307
  """Make what `job` describes (also what /again and /vary repeat): images, or edits of its sources."""
288
308
  prompt, options = job["prompt"], job["options"]
289
- if not prompt.strip() and job["kind"] in ("image", "edit") and not job.get("pads"):
290
- raise MediaError("Describe the image you want." if job["kind"] == "image" else "Say what to change.")
291
- model = current(agent)
309
+ if not prompt.strip() and job["kind"] in ("image", "edit", "video") and not job.get("pads"):
310
+ what = {"image": "Describe the image you want.", "edit": "Say what to change."}
311
+ raise MediaError(what.get(job["kind"], "Describe the video you want."))
312
+ model = current(agent, "video" if job["kind"] == "video" else "image")
292
313
  if options.negative and not model.negative:
293
314
  agent.say(f"{model.name} doesn't use a negative prompt: describe what you want instead (--no is ignored).")
294
315
  agent.last_request = job
@@ -299,6 +320,8 @@ def redo(
299
320
  one = replace(options, seed=seed, n=1)
300
321
  if job["kind"] == "image":
301
322
  made.append(generate_image(agent, prompt, cancelled, ask, one, label))
323
+ elif job["kind"] == "video":
324
+ made.append(generate_video(agent, job, one, cancelled, ask, label))
302
325
  else:
303
326
  made.append(edit_image(agent, job, one, cancelled, ask, label))
304
327
  return made
@@ -368,7 +391,7 @@ def generate_image(
368
391
  raise MediaError("Describe the image you want.")
369
392
  options = options or imageopts.Options()
370
393
  server = prepare(agent, ask, cancelled)
371
- model = current(agent)
394
+ model = current(agent, "image")
372
395
  files = ensure_installed(agent, model, server, ask)
373
396
  workflow = comfy.load_workflow(model.workflow)
374
397
  seed = options.seed if options.seed is not None else random.randrange(2**32)
@@ -397,7 +420,7 @@ def edit_image(
397
420
  """An edit of the job's source images: by instruction, through a mask (inpainting), or on a bigger canvas
398
421
  (outpainting). The first source sets the shape unless the request gave one."""
399
422
  server = prepare(agent, ask, cancelled)
400
- chosen = current(agent)
423
+ chosen = current(agent, "image")
401
424
  model = catalog.load().editor(chosen)
402
425
  if model is None:
403
426
  raise MediaError("None of lcode's image models edits pictures yet.")
@@ -453,6 +476,62 @@ def edit_image(
453
476
  return run_plan(agent, server, plan, cancelled, label)
454
477
 
455
478
 
479
+ def generate_video(
480
+ agent: Agent,
481
+ job: dict,
482
+ options: imageopts.Options,
483
+ cancelled: Callable[[], bool] = lambda: False,
484
+ ask: Callable[[str], bool] | None = None,
485
+ label: str = "",
486
+ ) -> Made:
487
+ """One video: from the prompt, or starting from the job's image. Its shape is 16:9, or 9:16 for an upright start
488
+ image (the shapes video models know best; the image is cropped to it), unless the request gives one."""
489
+ prompt = job["prompt"].strip()
490
+ server = prepare(agent, ask, cancelled)
491
+ model = current(agent, "video")
492
+ files = ensure_installed(agent, model, server, ask)
493
+ workflow = comfy.load_workflow(model.workflow)
494
+ start = Path(job["sources"][0]) if job.get("sources") else None
495
+ seed = options.seed if options.seed is not None else random.randrange(2**32)
496
+ steps, megapixels = model.preset(options.quality)
497
+ ratio = options.size
498
+ if job.get("keep_shape", True) and options.size == imageopts.Options().size: # no shape asked for
499
+ ratio = "16:9"
500
+ if start is not None:
501
+ try:
502
+ w, h = edits.image_size(start)
503
+ ratio = "9:16" if h > w else "16:9" # a square or odd shape breaks up; this crops it
504
+ except (OSError, edits.EditError):
505
+ pass
506
+ width, height = imageopts.dimensions(imageopts.SIZES.get(ratio, ratio), megapixels, multiple=32)
507
+ fps = int(workflow.defaults.get("fps", 24))
508
+ text = imageopts.styled(prompt, options.style)
509
+ values = {**workflow.defaults, **files, "prompt": text, "seed": seed, "width": width, "height": height,
510
+ "length": imageopts.frames(options.seconds, fps), "fps": fps} # fmt: skip
511
+ if steps:
512
+ values["steps"] = steps
513
+ if options.negative:
514
+ values["negative"] = options.negative
515
+ graph = comfy.fill(workflow.graph, values)
516
+ if start is not None:
517
+ try:
518
+ name = server.upload(start)
519
+ except (comfy.ComfyError, OSError) as e:
520
+ raise MediaError(f"Couldn't hand the image to ComfyUI: {e}") from e
521
+ latent = edits.node_of(graph, "Wan22ImageToVideoLatent")
522
+ if latent is None:
523
+ raise MediaError(f"{model.name} can't start from an image.")
524
+ graph["8100"] = {"class_type": "LoadImage", "inputs": {"image": name}}
525
+ graph[latent]["inputs"]["start_image"] = ["8100", 0]
526
+ add_thumbnail(graph, width, height)
527
+ meta = describe_job(model, workflow, prompt, text, seed, values, options, files)
528
+ meta.update({"seconds": round((values["length"] - 1) / fps, 2), "fps": fps, "frames": values["length"],
529
+ "start_image": shown(agent, start) if start else ""}) # fmt: skip
530
+ remembered = {k: job.get(k) for k in ("kind", "prompt", "sources", "mask", "pads", "keep_shape")}
531
+ plan = Plan(model, workflow, graph, meta, prompt, seed, options, "video", job.get("sources") or [], remembered)
532
+ return run_plan(agent, server, plan, cancelled, label)
533
+
534
+
456
535
  def apply_tool(
457
536
  agent: Agent,
458
537
  task: str,
@@ -580,6 +659,8 @@ def run_plan(agent: Agent, server: comfy.Server, plan: Plan, cancelled, label: s
580
659
  pass
581
660
  made = Made(saved, plan.model.name, plan.seed, width, height, time.monotonic() - line.started, plan.prompt)
582
661
  made.thumbnail = thumbnail(server, outputs)
662
+ if plan.kind == "video":
663
+ made.poster = thumbnail(server, outputs, POSTER_NODES[1])
583
664
  made.numbers = record(agent, made, plan)
584
665
  show(agent, made)
585
666
  return made
@@ -626,18 +707,21 @@ def show(agent: Agent, made: Made) -> None:
626
707
  + Text(f" {made.width}×{made.height} · {made.model}{seed} · {made.seconds:.1f} s", style="dim")
627
708
  )
628
709
  if made.files:
629
- show_preview(agent, made.files[0], made.thumbnail)
710
+ show_preview(agent, made.files[0], made.thumbnail, made.poster)
630
711
 
631
712
 
632
- def show_preview(agent: Agent, path: Path, thumb: bytes = b"") -> None:
633
- """The image in the terminal: itself where the terminal can show pictures, else a half-block preview."""
713
+ def show_preview(agent: Agent, path: Path, thumb: bytes = b"", poster: bytes = b"") -> None:
714
+ """The image (or a video's first frame) in the terminal: itself where the terminal can show pictures, else a
715
+ half-block preview."""
634
716
  setting = agent.settings.preview
635
717
  how = preview.protocol(setting)
636
718
  if how == "off" or (setting == "auto" and not agent.console.is_terminal):
637
719
  return
638
720
  try:
639
721
  if how in ("kitty", "iterm"):
640
- data = path.read_bytes()
722
+ data = path.read_bytes() if edits.is_image(path) else poster
723
+ if not data:
724
+ return
641
725
  sequence = preview.kitty(data) if how == "kitty" else preview.iterm(data)
642
726
  agent.console.file.write(sequence)
643
727
  agent.console.file.flush()
lcode/media.toml CHANGED
@@ -113,3 +113,36 @@ tasks = ["remove-background"]
113
113
  workflow = "remove-background"
114
114
  files = { model = "birefnet" }
115
115
  notes = "/nobg: a transparent PNG"
116
+
117
+ [files.wan22-ti2v-5b]
118
+ folder = "diffusion_models"
119
+ name = "wan2.2_ti2v_5B_fp16.safetensors"
120
+ url = "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_ti2v_5B_fp16.safetensors"
121
+ gb = 10.00
122
+
123
+ [files.umt5-xxl-fp8]
124
+ folder = "text_encoders"
125
+ name = "umt5_xxl_fp8_e4m3fn_scaled.safetensors"
126
+ url = "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/text_encoders/umt5_xxl_fp8_e4m3fn_scaled.safetensors"
127
+ gb = 6.74
128
+ alternatives = ["umt5_xxl_fp16.safetensors"]
129
+
130
+ [files.wan22-vae]
131
+ folder = "vae"
132
+ name = "wan2.2_vae.safetensors"
133
+ url = "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/vae/wan2.2_vae.safetensors"
134
+ gb = 1.41
135
+
136
+ [models.wan22-ti2v-5b]
137
+ name = "Wan 2.2 TI2V 5B"
138
+ mode = "video"
139
+ publisher = "Wan-AI (Alibaba)"
140
+ license = "Apache 2.0"
141
+ tasks = ["text-to-video", "image-to-video"]
142
+ workflow = "wan22-ti2v-5b"
143
+ files = { model = "wan22-ti2v-5b", text_encoder = "umt5-xxl-fp8", vae = "wan22-vae" }
144
+ gpu_gb = 0.0
145
+ measured = "5 s clip: 100 s fast, 150 s balanced (864×480), 10.5 min best (1280×704) on an RTX 4080 Laptop, 12 GB"
146
+ notes = "5 s at 24 fps, up to 1280×704; text or a start image"
147
+ quality = { fast = [12, 0.4], balanced = [20, 0.4], best = [30, 0.859] }
148
+ negative = true
lcode/media_catalog.py CHANGED
@@ -29,6 +29,7 @@ else: # pragma: no cover
29
29
  import tomli as tomllib
30
30
 
31
31
  DEFAULT_IMAGE = "flux2-klein-4b"
32
+ DEFAULT_VIDEO = "wan22-ti2v-5b"
32
33
  DISK_MARGIN_GB = 2.0 # left free after a download
33
34
  GPU_MARGIN_GIB = 0.5
34
35
  WORKING_GB = 1.5 # activations and buffers on top of the largest file, for models not measured yet
lcode/modes.py CHANGED
@@ -27,23 +27,23 @@ TITLES = {"code": "local coding agent", "image": "local image generation", "vide
27
27
  MEDIA_SERVERS = frozenset({"comfyui", "xai"}) # the MCP catalog's image and video servers
28
28
  MEDIA_COMMANDS = ("comfy-mcp", "lcode-mcp-xai") # the same servers, under any name
29
29
  NOTED_FILE = "media-servers-noted"
30
- COMING = {
31
- "video": (
32
- "Video mode doesn't generate yet: the video models (#150) and video generation (#153) come next. "
33
- "/mode image makes images now; /mode code switches back."
34
- ),
35
- }
30
+ COMING: dict[str, str] = {}
36
31
 
37
32
 
38
33
  def ready(agent: Agent) -> str:
39
34
  """What to do in the agent's mode, said on entering it."""
40
- if agent.settings.mode == "image":
41
- from lcode import media
35
+ from lcode import media
42
36
 
37
+ if agent.settings.mode == "image":
43
38
  return (
44
39
  f"Describe an image and press Enter: {media.current(agent).name} makes it on your GPU, into "
45
40
  f"{agent.settings.media_dir}. /mode code switches back."
46
41
  )
42
+ if agent.settings.mode == "video":
43
+ return (
44
+ f"Describe a video and press Enter (@photo.png starts it from a picture): {media.current(agent).name} "
45
+ f"makes it on your GPU, into {agent.settings.media_dir}. /mode code switches back."
46
+ )
47
47
  return COMING.get(agent.settings.mode, "")
48
48
 
49
49
 
@@ -113,7 +113,7 @@ def switch(agent: Agent, new: str) -> list[str]:
113
113
  if new == "code" and agent.no_coding_model:
114
114
  reason = agent.no_coding_model.strip().splitlines()[0]
115
115
  lines.append(f"lcode started without its coding model ({reason}). Fix that, then restart lcode.")
116
- if new == "image":
116
+ if new in ("image", "video"):
117
117
  from lcode import media
118
118
 
119
119
  warming = media.warm(agent)
lcode/repl.py CHANGED
@@ -89,6 +89,8 @@ COMMANDS = {
89
89
  "/extend": "Image mode: outpaint an image to a shape or by sides, e.g. /extend 3 --ar 16:9 (or --left 256)",
90
90
  "/upscale": "Image mode: 2 or 4 times the pixels, e.g. /upscale 3 4",
91
91
  "/nobg": "Image mode: remove the background (a transparent PNG), e.g. /nobg 3",
92
+ "/animate": "Image mode: make a video starting from an image, e.g. /animate 3 the waves roll in (video mode)",
93
+ "/play": "Play a video in your player, e.g. /play 4 (default: the last)",
92
94
  "/cd": "Change the working directory",
93
95
  "/todos": "Show the current todo list",
94
96
  "/status": "The task sheet: your requests, the plan and notes, files changed, the last check",
@@ -230,7 +232,7 @@ def model_lines(agent: Agent) -> str:
230
232
  if s.mode == "image":
231
233
  return f"[dim]model[/] {escape(media.current(agent).name)} [dim]· ComfyUI · {escape(s.media_dir)}[/]\n"
232
234
  if s.mode == "video":
233
- return "[dim]model[/] [dim]video models come with video generation (#153)[/]\n"
235
+ return f"[dim]model[/] {escape(media.current(agent).name)} [dim]· ComfyUI · {escape(s.media_dir)}[/]\n"
234
236
  server = f" [dim]· {escape(agent.ollama.describe())}[/]" if backends.is_openai(agent.ollama) else ""
235
237
  return f"[dim]model[/] {escape(s.model)}{server}\n[dim]context[/] {format_tokens(s.context)} tokens\n"
236
238
 
@@ -931,13 +933,13 @@ def media_model_command(agent: Agent, arg: str, hardware: Hardware) -> None:
931
933
  if model is None:
932
934
  c.print(f"[red]There's no {s.mode} model {escape(arg)!r}: choose 1 to {len(choices)}, or a name.[/]")
933
935
  return
934
- s.image_model = model.key
935
- c.print(f"[green]Image model: {escape(model.name)}.[/]")
936
+ setattr(s, f"{s.mode}_model", model.key)
937
+ c.print(f"[green]{s.mode.capitalize()} model: {escape(model.name)}.[/]")
936
938
  if installed is not None:
937
939
  _, missing = catalog.chosen_files(model, installed)
938
940
  if missing:
939
941
  size = sum(f.gb for f in missing)
940
- c.print(f"[dim]Not installed yet: {size:.1f} GB download with your next image (you're asked first).[/]")
942
+ c.print(f"[dim]Not installed yet: {size:.1f} GB download with your next {s.mode} (you're asked first).[/]")
941
943
 
942
944
 
943
945
  def switch_model(agent: Agent, wanted: str, hardware: Hardware) -> None:
@@ -1347,10 +1349,7 @@ def repl(agent: Agent, prompt: str | None, hardware: Hardware, cont: bool = Fals
1347
1349
  if info:
1348
1350
  resume_session(agent, info)
1349
1351
  session_start(agent)
1350
- if prompt and agent.settings.mode == "video":
1351
- agent.console.print(f"[yellow]{escape(modes.ready(agent))}[/]")
1352
- prompt = None
1353
- if prompt and agent.settings.mode == "image":
1352
+ if prompt and agent.settings.mode in ("image", "video"):
1354
1353
  respond(agent, prompt)
1355
1354
  return
1356
1355
  if prompt:
@@ -1367,7 +1366,7 @@ def repl(agent: Agent, prompt: str | None, hardware: Hardware, cont: bool = Fals
1367
1366
  if agent.settings.mode == "code":
1368
1367
  preload(agent, session)
1369
1368
  else:
1370
- warming = media.warm(agent) if agent.settings.mode == "image" else None
1369
+ warming = media.warm(agent)
1371
1370
  if warming:
1372
1371
  agent.say(warming)
1373
1372
  agent.console.print(f"[yellow]{escape(modes.ready(agent))}[/]")
@@ -1396,17 +1395,18 @@ def repl(agent: Agent, prompt: str | None, hardware: Hardware, cont: bool = Fals
1396
1395
 
1397
1396
  def respond(agent: Agent, line: str) -> None:
1398
1397
  """What a typed request does: the coding agent works on it, or image and video mode take it."""
1399
- if agent.settings.mode == "image": # no coding model involved
1398
+ if agent.settings.mode in ("image", "video"): # no coding model involved
1400
1399
  make_image(agent, line)
1401
1400
  return
1402
- if agent.settings.mode != "code":
1403
- agent.console.print(f"[yellow]{escape(modes.ready(agent))}[/]")
1404
- return
1405
1401
  agent.console.print(Rule(style="dim"))
1406
1402
  run_safely(agent, line)
1407
1403
 
1408
1404
 
1409
- IMAGE_COMMANDS = ("/options", "/again", "/vary", "/gallery", "/open", "/edit", "/use", "/extend", "/upscale", "/nobg")
1405
+ IMAGE_COMMANDS = (
1406
+ "/options", "/again", "/vary", "/gallery", "/open", "/play", "/edit", "/use", "/extend", "/upscale", "/nobg",
1407
+ "/animate",
1408
+ ) # fmt: skip
1409
+ VIDEO_TOO = ("/options", "/again", "/vary", "/gallery", "/open", "/play", "/use") # what video mode shares
1410
1410
  NUMBER = re.compile(r"^#?(\d+)$")
1411
1411
 
1412
1412
 
@@ -1418,9 +1418,10 @@ def image_command(agent: Agent, cmd: str, arg: str) -> None:
1418
1418
  from lcode import edits, imageopts
1419
1419
 
1420
1420
  c = agent.console
1421
+ video = agent.settings.mode == "video"
1421
1422
  if cmd == "/options":
1422
1423
  if not arg.strip():
1423
- c.print(f"Image options: [bold]{escape(agent.image_options.describe())}[/]")
1424
+ c.print(f"Options: [bold]{escape(agent.image_options.describe(video))}[/]")
1424
1425
  c.print(
1425
1426
  '[dim]Set them with /options n=4 ar=16:9 seed=42 quality=best style=photo no="text"; '
1426
1427
  f"styles: {', '.join(imageopts.STYLES)}. Flags in a request (--n 4) win for that request.[/]"
@@ -1431,13 +1432,14 @@ def image_command(agent: Agent, cmd: str, arg: str) -> None:
1431
1432
  except imageopts.OptionError as e:
1432
1433
  c.print(f"[red]{escape(str(e))}[/]")
1433
1434
  return
1434
- c.print(f"Image options: [bold]{escape(agent.image_options.describe())}[/]")
1435
+ c.print(f"Options: [bold]{escape(agent.image_options.describe(video))}[/]")
1435
1436
  return
1436
1437
  if cmd == "/gallery":
1437
1438
  gallery(agent, everything=arg.strip() == "all")
1438
1439
  return
1439
- if agent.settings.mode != "image" and cmd != "/open":
1440
- c.print(f"[yellow]{cmd} works in image mode: /mode image.[/]")
1440
+ if cmd not in ("/open", "/play") and not (agent.settings.mode == "image" or (video and cmd in VIDEO_TOO)):
1441
+ where = "image or video mode" if cmd in VIDEO_TOO else "image mode"
1442
+ c.print(f"[yellow]{cmd} works in {where}: /mode {'image' if where == 'image mode' else 'video'}.[/]")
1441
1443
  return
1442
1444
  first, _, rest = arg.strip().partition(" ")
1443
1445
  number = first if NUMBER.match(first) else ""
@@ -1456,13 +1458,13 @@ def image_command(agent: Agent, cmd: str, arg: str) -> None:
1456
1458
  return
1457
1459
  entry = media.result(agent, f"#{NUMBER.match(number).group(1)}" if number else "")
1458
1460
  source = Path(entry["path"])
1459
- if cmd == "/open":
1461
+ if cmd in ("/open", "/play"):
1460
1462
  problem = media.open_file(source)
1461
1463
  c.print(f"[yellow]{escape(problem)}[/]" if problem else f"[dim]Opened #{entry['n']}.[/]")
1462
1464
  return
1463
1465
  if cmd == "/vary":
1464
1466
  job = dict(entry.get("job") or {"kind": "image", "sources": [], "mask": None, "pads": None})
1465
- if entry.get("kind") in ("upscale", "remove-background") or job["kind"] not in ("image", "edit"):
1467
+ if entry.get("kind") in ("upscale", "remove-background") or job["kind"] not in ("image", "edit", "video"):
1466
1468
  c.print(f"[yellow]#{entry['n']} is {escape(entry['prompt'])}: /vary the image it came from.[/]")
1467
1469
  return
1468
1470
  job["prompt"] = entry["prompt"] if job["kind"] == "image" else job.get("prompt", entry["prompt"])
@@ -1471,6 +1473,17 @@ def image_command(agent: Agent, cmd: str, arg: str) -> None:
1471
1473
  c.print(f"[dim]Variations of #{entry['n']}: {escape(entry['prompt'])}[/]")
1472
1474
  make_image(agent, run=lambda cancel, ask: media.redo(agent, job, cancel, ask))
1473
1475
  return
1476
+ if cmd == "/animate":
1477
+ if not edits.is_image(source):
1478
+ c.print(f"[yellow]#{entry['n']} isn't a picture: /animate takes an image.[/]")
1479
+ return
1480
+ for line in modes.switch(agent, "video"):
1481
+ c.print(f"[dim]{escape(line)}[/]")
1482
+ text, options = imageopts.parse(rest, imageopts.replace_seed(agent.image_options, n=1))
1483
+ job = {"kind": "video", "prompt": text or entry["prompt"], "options": options, "sources": [str(source)],
1484
+ "mask": None, "pads": None, "keep_shape": "--ar" not in rest and "--size" not in rest} # fmt: skip
1485
+ make_image(agent, run=lambda cancel, ask: media.redo(agent, job, cancel, ask))
1486
+ return
1474
1487
  if cmd == "/edit":
1475
1488
  text, refs = edits.split_references(rest)
1476
1489
  instruction, options = imageopts.parse(text, imageopts.replace_seed(agent.image_options))
@@ -1571,6 +1584,7 @@ def make_image(agent: Agent, prompt: str = "", options=None, run=None) -> None:
1571
1584
  answer = steering.ask(f" {question} [y/N] ") if reading else input(f" {question} [y/N] ")
1572
1585
  return answer.strip().lower() in ("y", "yes")
1573
1586
 
1587
+ started = time.monotonic()
1574
1588
  try:
1575
1589
  asking = ask if agent.interactive else None
1576
1590
  if run is not None:
@@ -1593,6 +1607,7 @@ def make_image(agent: Agent, prompt: str = "", options=None, run=None) -> None:
1593
1607
  agent.unsent = typed
1594
1608
  agent.console.print(Text("✎ What you typed is in the prompt below.", style="cyan"))
1595
1609
  agent.save() # the gallery and options carry over when the session is resumed
1610
+ agent.finished(prompt or "image or video", time.monotonic() - started) # a long render: notify
1596
1611
 
1597
1612
 
1598
1613
  def preload(agent: Agent, session: PromptSession | None = None) -> None:
@@ -0,0 +1,152 @@
1
+ {
2
+ "source": "ComfyUI's template video_wan2_2_5B_ti2v, in API format",
3
+ "negative_note": "Wan's own default negative prompt (Chinese): oversaturated, overexposed, static, blurry details, subtitles, worst quality, extra fingers, deformed limbs, cluttered background, walking backwards…",
4
+ "defaults": {
5
+ "width": 1280,
6
+ "height": 704,
7
+ "length": 121,
8
+ "fps": 24,
9
+ "steps": 20,
10
+ "negative": "色调艳丽,过曝,静态,细节模糊不清,字幕,风格,作品,画作,画面,静止,整体发灰,最差质量,低质量,JPEG压缩残留,丑陋的,残缺的,多余的手指,画得不好的手部,画得不好的脸部,畸形的,毁容的,形态畸形的肢体,手指融合,静止不动的画面,杂乱的背景,三条腿,背景人很多,倒着走"
11
+ },
12
+ "labels": {
13
+ "1": "loading the model",
14
+ "2": "loading the text encoder",
15
+ "3": "loading the VAE",
16
+ "5": "reading the prompt",
17
+ "6": "reading the prompt",
18
+ "7": "preparing the frames",
19
+ "8": "drawing the frames",
20
+ "9": "decoding the frames",
21
+ "10": "making the video",
22
+ "11": "saving"
23
+ },
24
+ "graph": {
25
+ "1": {
26
+ "class_type": "UNETLoader",
27
+ "inputs": {
28
+ "unet_name": "$model",
29
+ "weight_dtype": "default"
30
+ }
31
+ },
32
+ "2": {
33
+ "class_type": "CLIPLoader",
34
+ "inputs": {
35
+ "clip_name": "$text_encoder",
36
+ "type": "wan",
37
+ "device": "default"
38
+ }
39
+ },
40
+ "3": {
41
+ "class_type": "VAELoader",
42
+ "inputs": {
43
+ "vae_name": "$vae"
44
+ }
45
+ },
46
+ "4": {
47
+ "class_type": "ModelSamplingSD3",
48
+ "inputs": {
49
+ "model": [
50
+ "1",
51
+ 0
52
+ ],
53
+ "shift": 8
54
+ }
55
+ },
56
+ "5": {
57
+ "class_type": "CLIPTextEncode",
58
+ "inputs": {
59
+ "text": "$prompt",
60
+ "clip": [
61
+ "2",
62
+ 0
63
+ ]
64
+ }
65
+ },
66
+ "6": {
67
+ "class_type": "CLIPTextEncode",
68
+ "inputs": {
69
+ "text": "$negative",
70
+ "clip": [
71
+ "2",
72
+ 0
73
+ ]
74
+ }
75
+ },
76
+ "7": {
77
+ "class_type": "Wan22ImageToVideoLatent",
78
+ "inputs": {
79
+ "vae": [
80
+ "3",
81
+ 0
82
+ ],
83
+ "width": "$width",
84
+ "height": "$height",
85
+ "length": "$length",
86
+ "batch_size": 1
87
+ }
88
+ },
89
+ "8": {
90
+ "class_type": "KSampler",
91
+ "inputs": {
92
+ "model": [
93
+ "4",
94
+ 0
95
+ ],
96
+ "seed": "$seed",
97
+ "steps": "$steps",
98
+ "cfg": 5,
99
+ "sampler_name": "uni_pc",
100
+ "scheduler": "simple",
101
+ "positive": [
102
+ "5",
103
+ 0
104
+ ],
105
+ "negative": [
106
+ "6",
107
+ 0
108
+ ],
109
+ "latent_image": [
110
+ "7",
111
+ 0
112
+ ],
113
+ "denoise": 1
114
+ }
115
+ },
116
+ "9": {
117
+ "class_type": "VAEDecode",
118
+ "inputs": {
119
+ "samples": [
120
+ "8",
121
+ 0
122
+ ],
123
+ "vae": [
124
+ "3",
125
+ 0
126
+ ]
127
+ }
128
+ },
129
+ "10": {
130
+ "class_type": "CreateVideo",
131
+ "inputs": {
132
+ "images": [
133
+ "9",
134
+ 0
135
+ ],
136
+ "fps": "$fps"
137
+ }
138
+ },
139
+ "11": {
140
+ "class_type": "SaveVideo",
141
+ "inputs": {
142
+ "video": [
143
+ "10",
144
+ 0
145
+ ],
146
+ "filename_prefix": "lcode/video",
147
+ "format": "mp4",
148
+ "codec": "auto"
149
+ }
150
+ }
151
+ }
152
+ }
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lcode-cli
3
- Version: 0.43.0
3
+ Version: 0.44.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/
@@ -107,8 +107,9 @@ through [Ollama](https://ollama.com), and when it needs current information it c
107
107
  `lcode --worktree` runs parallel sessions on one repository.
108
108
  - **In your editor.** Zed, JetBrains IDEs, Neovim and other editors that speak the Agent Client
109
109
  Protocol run lcode with `lcode acp`, with diffs and approvals in the editor.
110
- - **Image mode.** `/mode image`, describe a picture, and FLUX.2 klein 4B makes it on your GPU in
111
- seconds through ComfyUI, which lcode starts, follows and stops itself. Video mode is next
110
+ - **Image and video modes.** `/mode image`, describe a picture, and FLUX.2 klein 4B makes it on your
111
+ GPU in seconds; edit it, extend it, upscale it or cut it out. `/mode video` makes clips with
112
+ Wan 2.2, from a description or a start image. lcode drives ComfyUI itself
112
113
  ([modes](https://nasser1941.github.io/lcode/modes/)).
113
114
  - **Automation.** `lcode -p --output json` for scripts and CI, a Python API (`from lcode import
114
115
  Session`), and a GitHub Action that answers `@lcode` in issues and pull requests on your own
@@ -1,9 +1,9 @@
1
- lcode/__init__.py,sha256=SlAe74M7eoZyKxBXWVe_w72XGAaCfPj29sJG6lAuiSc,473
1
+ lcode/__init__.py,sha256=RsG17qPpn7di8jUUOV-g7wyfYOlPM9AIXTiqeUvvG8Y,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=RVf_gQLwkIYxItYduWB2O0aZu8osKvsRxOBL3UN8ANA,88729
6
- lcode/api.py,sha256=efsUuIZneUc6Ar6KWerKAoMlMFOlSUtLX4_quplr3t8,17664
5
+ lcode/agent.py,sha256=i2Ks-VSzmBKtq1L0B3yrqsjKKHToxJAH2t3miC5qNWE,88955
6
+ lcode/api.py,sha256=DkAWgcoPk5rwp_NPrnV5RbAnA8QKrvDwpaJzTPAfxxA,17704
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=ZVafS8V-82SuEldT6VlAcqKEuBabA-HCi8Tk_0IP9Pc,51516
14
+ lcode/cli.py,sha256=gmbgWdKxLeBg-9uANbZKV9aIeafudcRfFXGE8BQK0oQ,51230
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
18
  lcode/comfy.py,sha256=_pbLVQS_1clsa7awPrxQ7xIOwp5XzyMHPDAagomEswc,26614
19
- lcode/config.py,sha256=F7CRPdlqW6i9CSapqeU83ClOHOsf-Mxx1sWGXRYI1ac,13861
19
+ lcode/config.py,sha256=cOPG7SqsTEiPwPan_0ILtdLY1m8Qbk6WGyMrWZf5iz4,13989
20
20
  lcode/context.py,sha256=wgK16ZcNNnq4ngHx0EmtyKHDFTImciGEmtC5NP8tKJQ,20435
21
21
  lcode/edits.py,sha256=iYXSbKv4ZfxP12B1ov6-CLEWvR_tWFUdHRQbER3O8d0,13340
22
22
  lcode/escalate.py,sha256=f-_6_moPaHi2kLuJw2JtkzvTkCl6DoBQgYBOCYLhunM,15120
@@ -25,17 +25,17 @@ lcode/frontmatter.py,sha256=Bx0n4vLPhp7ZCWx-g7xqzqNy5VoKBxyeeikh5UFB_gA,2441
25
25
  lcode/gitflow.py,sha256=6j_nnCBZXkX1qhTI7K4CyZ5D0NAZLpH96weFEo64AkQ,27913
26
26
  lcode/hardware.py,sha256=2VbknZCxTZJwJUD8YWdQ7ryEQo_LcjtoOBOHOU9Y0fw,3085
27
27
  lcode/hooks.py,sha256=K0itOiAuH4rxndJ9gne_uCrqby6oWAzFPRrfB3tCq-E,9324
28
- lcode/imageopts.py,sha256=CQBvuEE-aK5C0wFqgTp1jxcpw-bOU2DJZpBYuqyA88I,7791
28
+ lcode/imageopts.py,sha256=msg8Y3OoP6sg1j3L_0K_WhtwdkV2fQl4_IUMq0QsoCE,8610
29
29
  lcode/jobs.py,sha256=P1P3FqI6EqL4LgdMX9YGZwCW0Bh5BRXFA_FnQxpHNjE,5618
30
30
  lcode/limits.py,sha256=oJzVQoMyTZ0_Fkpu5t3qxrf9v7Hv9S8U8BRQR5M1aKA,1237
31
31
  lcode/lsp.py,sha256=NtSW7xapv7fg2HAQLuYK1nwIqT7nG0LK1hiTpOmla_4,26086
32
32
  lcode/matching.py,sha256=2N3_qwKJ5gd8el2m_y_egKkFM9rsF1eUhYJcRCVIp98,14015
33
- lcode/media.py,sha256=F8jRT8J2GkUVUxEMbK4DT6sykxE4JXnKOQ-7Ntl0RgY,28984
34
- lcode/media.toml,sha256=QSv1QmeR9XukBBnmTtAxAZKp2-EguIGJmbar4rDM-is,4715
35
- lcode/media_catalog.py,sha256=geAt3B4mOxTydXFFaYA0wBO9512_115eBPAjAWTUIj4,14331
33
+ lcode/media.py,sha256=MZ4ZSuehInueDRvyYCm1BXRePM5IWk5NE23VGw37C8c,33647
34
+ lcode/media.toml,sha256=gEVwlSLYWOFsYaLj_3J6crY5J6weHYKaqgITtHXs2q4,6010
35
+ lcode/media_catalog.py,sha256=ABHlhvZEAZmei1YzNs70_1kyaS0h0TEzbLZWLISNCkk,14363
36
36
  lcode/memory.py,sha256=WZEHTp-lVxAwS-yyLQR2xB3TMZ9sejmX2Ivg7T-tl3w,21949
37
37
  lcode/models.toml,sha256=lt2G64zzo_Ryxv2ISRSNZoVD_JqCqYqsM01n8WoXGs8,4098
38
- lcode/modes.py,sha256=Ke_mEeYF_SskbVUXMpJMeV5d5fFEQ-Vb-MKp4SIvT0Y,4990
38
+ lcode/modes.py,sha256=MG2OuwGxzbPxzP5ax_pdPPxMsjEd5Lv4kCQbCoFKaNo,5095
39
39
  lcode/notify.py,sha256=uieDeXe7XwuVHjOk4tqVyZEW_85XWkoWQmFOWEf77oc,1538
40
40
  lcode/ollama.py,sha256=xFHU2XW47ANJ34-1aeSMQVj28GhqcWiFi6zASH9FiYY,8053
41
41
  lcode/permissions.py,sha256=q7CjDsc_Xrblw7t3_BbxA5LUjxFSswGs1RiJmtMi0po,12288
@@ -43,7 +43,7 @@ lcode/planning.py,sha256=Q2upaoZPnXDMQT2F-ETxX1lk_wDntTzhSKNVYIQSoQA,6540
43
43
  lcode/preview.py,sha256=ATzEs6yaI1__bfo2_juWznrTDXUD4QTc_aQMPRCytgA,6253
44
44
  lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
45
45
  lcode/repeats.py,sha256=OTrPdb6BKArDLQEgHaXmJZ-tIFsY_1bk4P0wzzANKLY,5964
46
- lcode/repl.py,sha256=OkJ6ClINTE2P3JNKTP1ulu-bTNCtfro12f4w6kM069g,71932
46
+ lcode/repl.py,sha256=VLbVvjGtukGUXOP-Wuj29KbGcLiDnD57CoDexW3dUFU,73153
47
47
  lcode/repomap.py,sha256=1GRJgEUjBl9XHSXsF7tSGXBCEgsY-3CzwMohaTudZ_M,13969
48
48
  lcode/residency.py,sha256=B9pwqG6yx5aQD2M5xyQ-bMnYmhD7fv3wCVw_X6VIeAo,7012
49
49
  lcode/review.py,sha256=0QBIS-R1B2b3zqx5TcC1qZptpKBbrDhYvMKSNCaDAUo,4686
@@ -78,9 +78,10 @@ lcode/workflows/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
78
78
  lcode/workflows/flux2-klein-4b-t2i.json,sha256=yGrYDT8pG2bns9DS1JavhlX5fwhDgIKfAwrHrRCyE8k,1788
79
79
  lcode/workflows/remove-background.json,sha256=iMNaXqlkcWN-mCQWezD3NUbW94cHN3JcPYsSUsfOl64,794
80
80
  lcode/workflows/upscale-esrgan.json,sha256=quDmTnncvycggskgvw6zvfNsub7mS0WY3a4J99fWeXc,745
81
+ lcode/workflows/wan22-ti2v-5b.json,sha256=RcTehoC2pNizzlb7Ed3keJ56DiE7V5lhXmsvR2Y0smQ,3371
81
82
  lcode/workflows/z-image-turbo-t2i.json,sha256=sHsL7xnnuULOuWGYlQrDl4jOTySdAmb2T82c_cChv0s,1585
82
- lcode_cli-0.43.0.dist-info/METADATA,sha256=FjFitYxjlbB2iFty4gVvGqNJKSoys9npCyZubZmm5i4,13386
83
- lcode_cli-0.43.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
84
- lcode_cli-0.43.0.dist-info/entry_points.txt,sha256=Hxsn_itbnbgft3U0FwxWdmEt0yoAV2LB-nJDnL9WT20,172
85
- lcode_cli-0.43.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
86
- lcode_cli-0.43.0.dist-info/RECORD,,
83
+ lcode_cli-0.44.0.dist-info/METADATA,sha256=bRKpVWXONhz8JdV7UgzC7HdXXCvNHiKaet245hR8-4Q,13469
84
+ lcode_cli-0.44.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
85
+ lcode_cli-0.44.0.dist-info/entry_points.txt,sha256=Hxsn_itbnbgft3U0FwxWdmEt0yoAV2LB-nJDnL9WT20,172
86
+ lcode_cli-0.44.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
87
+ lcode_cli-0.44.0.dist-info/RECORD,,