lcode-cli 0.43.0__py3-none-any.whl → 0.45.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.45.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",
@@ -34,6 +35,21 @@ STYLES = {
34
35
  "comic": "comic book art, bold ink outlines, halftone shading",
35
36
  "flat": "flat vector illustration, minimal, bold shapes",
36
37
  }
38
+ CAMERA = { # video: --camera name -> the camera model's pose
39
+ "static": "Static",
40
+ "pan-left": "Pan Left",
41
+ "pan-right": "Pan Right",
42
+ "pan-up": "Pan Up",
43
+ "pan-down": "Pan Down",
44
+ "tilt-up": "Pan Up",
45
+ "tilt-down": "Pan Down",
46
+ "zoom-in": "Zoom In",
47
+ "zoom-out": "Zoom Out",
48
+ "dolly-in": "Zoom In",
49
+ "dolly-out": "Zoom Out",
50
+ "rotate-left": "Anti Clockwise (ACW)",
51
+ "rotate-right": "ClockWise (CW)",
52
+ }
37
53
  ALIASES = {
38
54
  "n": "n",
39
55
  "count": "n",
@@ -46,6 +62,9 @@ ALIASES = {
46
62
  "no": "negative",
47
63
  "negative": "negative",
48
64
  "style": "style",
65
+ "seconds": "seconds",
66
+ "duration": "seconds",
67
+ "camera": "camera",
49
68
  }
50
69
  FLAG = re.compile(r"(?:^|\s)--?(" + "|".join(sorted(ALIASES, key=len, reverse=True)) + r")\b(?=[\s=]|$)")
51
70
  DIMENSIONS = re.compile(r"^(\d{2,4})\s*[x×]\s*(\d{2,4})$")
@@ -64,10 +83,16 @@ class Options:
64
83
  quality: str = "balanced"
65
84
  negative: str = ""
66
85
  style: str = ""
86
+ seconds: float = 5.0 # video only
87
+ camera: str = "" # video only: a move from CAMERA, made by a camera-control model
67
88
 
68
- def describe(self) -> str:
89
+ def describe(self, video: bool = False) -> str:
69
90
  seed = "random" if self.seed is None else str(self.seed)
70
91
  parts = [f"n={self.n}", f"size={self.size}", f"seed={seed}", f"quality={self.quality}"]
92
+ if video:
93
+ parts.append(f"seconds={self.seconds:g}")
94
+ if self.camera:
95
+ parts.append(f"camera={self.camera}")
71
96
  if self.style:
72
97
  parts.append(f"style={self.style}")
73
98
  if self.negative:
@@ -92,7 +117,9 @@ def set_values(options: Options, values: dict[str, object]) -> Options:
92
117
  for raw_key, raw in values.items():
93
118
  key = ALIASES.get(str(raw_key).lower().lstrip("-"))
94
119
  if key is None:
95
- raise OptionError(f"unknown option {raw_key!r}: use n, ar (or size), seed, quality, style or no")
120
+ raise OptionError(
121
+ f"unknown option {raw_key!r}: use n, ar (or size), seed, quality, style, no, seconds or camera"
122
+ )
96
123
  value = str(raw).strip() if raw is not None else ""
97
124
  if key == "n":
98
125
  if not value.isdigit() or not 1 <= int(value) <= MAX_COUNT:
@@ -120,6 +147,21 @@ def set_values(options: Options, values: dict[str, object]) -> Options:
120
147
  raise OptionError(f"style is one of {', '.join(STYLES)} (or none)")
121
148
  elif key == "negative":
122
149
  out.negative = "" if value.lower() in ("none", "off") else value
150
+ elif key == "camera":
151
+ if value.lower() in ("none", "off", ""):
152
+ out.camera = ""
153
+ elif value.lower() in CAMERA:
154
+ out.camera = value.lower()
155
+ else:
156
+ raise OptionError(f"camera is one of {', '.join(CAMERA)} (or none)")
157
+ elif key == "seconds":
158
+ try:
159
+ seconds = float(value.rstrip("s"))
160
+ except ValueError:
161
+ seconds = 0
162
+ if not 1 <= seconds <= MAX_SECONDS:
163
+ raise OptionError(f"seconds is how long a video is: 1 to {MAX_SECONDS}")
164
+ out.seconds = seconds
123
165
  return out
124
166
 
125
167
 
@@ -203,5 +245,10 @@ def replace_seed(options: Options, n: int | None = None) -> Options:
203
245
  return replace(options, seed=None, n=n or options.n)
204
246
 
205
247
 
248
+ def frames(seconds: float, fps: int) -> int:
249
+ """Frames for a clip: a multiple of 4 plus 1, as video models that compress time 4 to 1 need."""
250
+ return max(1, round(seconds * fps / 4)) * 4 + 1
251
+
252
+
206
253
  def styled(prompt: str, style: str) -> str:
207
254
  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[:2]],
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,112 @@ 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
+ sources = [Path(p) for p in job.get("sources") or []]
492
+ start = sources[0] if sources else None
493
+ end = sources[1] if len(sources) > 1 else None
494
+ clip = Path(job["extend"]) if job.get("extend") else None
495
+ model = video_model(agent, "first-last-frame" if end else ("camera" if options.camera else
496
+ ("image-to-video" if clip else ""))) # fmt: skip
497
+ files = ensure_installed(agent, model, server, ask)
498
+ workflow = comfy.load_workflow(model.workflow)
499
+ seed = options.seed if options.seed is not None else random.randrange(2**32)
500
+ steps, megapixels = model.preset(options.quality)
501
+ ratio = options.size
502
+ if job.get("keep_shape", True) and options.size == imageopts.Options().size: # no shape asked for
503
+ ratio = "16:9"
504
+ if start is not None:
505
+ try:
506
+ w, h = edits.image_size(start)
507
+ ratio = "9:16" if h > w else "16:9" # a square or odd shape breaks up; this crops it
508
+ except (OSError, edits.EditError):
509
+ pass
510
+ width, height = imageopts.dimensions(imageopts.SIZES.get(ratio, ratio), megapixels, multiple=32)
511
+ if clip is not None: # the new part continues the clip, so it has its size
512
+ width, height = job["size"]
513
+ fps = int(workflow.defaults.get("fps", 24))
514
+ text = imageopts.styled(prompt, options.style)
515
+ values = {**workflow.defaults, **files, "prompt": text, "seed": seed, "width": width, "height": height,
516
+ "length": imageopts.frames(options.seconds, fps), "fps": fps} # fmt: skip
517
+ if steps:
518
+ values["steps"] = steps
519
+ if options.negative:
520
+ values["negative"] = options.negative
521
+ values["camera"] = imageopts.CAMERA[options.camera or "static"]
522
+ graph = comfy.fill(workflow.graph, values)
523
+ latent = next((edits.node_of(graph, c) for c in VIDEO_LATENTS if edits.node_of(graph, c)), None)
524
+ try:
525
+ if start is not None:
526
+ graph["8100"] = {"class_type": "LoadImage", "inputs": {"image": server.upload(start)}}
527
+ graph[latent]["inputs"]["start_image"] = ["8100", 0]
528
+ loader = edits.node_of(graph, "CLIPVisionLoader")
529
+ if loader is not None: # the camera model also looks at the start image
530
+ look = {"clip_vision": [loader, 0], "image": ["8100", 0], "crop": "none"}
531
+ graph["8102"] = {"class_type": "CLIPVisionEncode", "inputs": look}
532
+ graph[latent]["inputs"]["clip_vision_output"] = ["8102", 0]
533
+ if end is not None:
534
+ graph["8101"] = {"class_type": "LoadImage", "inputs": {"image": server.upload(end)}}
535
+ graph[latent]["inputs"]["end_image"] = ["8101", 0]
536
+ if clip is not None:
537
+ continue_clip(graph, latent, server.upload(clip), values["length"])
538
+ except (comfy.ComfyError, OSError) as e:
539
+ raise MediaError(f"Couldn't hand the files to ComfyUI: {e}") from e
540
+ add_thumbnail(graph, width, height)
541
+ meta = describe_job(model, workflow, prompt, text, seed, values, options, files)
542
+ meta.update({"seconds": round((values["length"] - 1) / fps, 2), "fps": fps, "frames": values["length"],
543
+ "start_image": shown(agent, start) if start else "", "end_image": shown(agent, end) if end else "",
544
+ "camera": options.camera, "extends": shown(agent, clip) if clip else ""}) # fmt: skip
545
+ remembered = {k: job.get(k) for k in ("kind", "prompt", "sources", "mask", "pads", "keep_shape", "extend", "size")}
546
+ stem = f"{clip.stem}-extended" if clip else ""
547
+ plan = Plan(model, workflow, graph, meta, prompt, seed, options, "video", job.get("sources") or [], remembered,
548
+ stem) # fmt: skip
549
+ return run_plan(agent, server, plan, cancelled, label)
550
+
551
+
552
+ VIDEO_LATENTS = ("Wan22ImageToVideoLatent", "WanFunInpaintToVideo", "WanCameraImageToVideo")
553
+ WHY = {
554
+ "first-last-frame": "a clip between two frames",
555
+ "camera": "a camera move",
556
+ "image-to-video": "continuing a clip",
557
+ }
558
+
559
+
560
+ def video_model(agent: Agent, task: str) -> catalog.MediaModel:
561
+ """The video model for a job: the chosen one, or the first that does what the job needs."""
562
+ chosen = current(agent, "video")
563
+ if not task or task in chosen.tasks:
564
+ return chosen
565
+ model = catalog.load().for_task("video", task)
566
+ if model is None:
567
+ raise MediaError(f"None of lcode's video models does {WHY.get(task, task)} yet.")
568
+ agent.say(f"{model.name} makes this one ({WHY.get(task, task)}).")
569
+ return model
570
+
571
+
572
+ def continue_clip(graph: dict, latent: str, clip_name: str, length: int) -> None:
573
+ """Extending a clip: start from its last frame, and join the new frames (without that first one) after it."""
574
+ graph["8200"] = {"class_type": "LoadVideo", "inputs": {"file": clip_name}}
575
+ graph["8201"] = {"class_type": "GetVideoComponents", "inputs": {"video": ["8200", 0]}}
576
+ graph["8202"] = {"class_type": "ImageFromBatch", "inputs": {"image": ["8201", 0], "batch_index": -1, "length": 1}}
577
+ graph[latent]["inputs"]["start_image"] = ["8202", 0]
578
+ video = edits.node_of(graph, "CreateVideo")
579
+ decoded = graph[video]["inputs"]["images"]
580
+ graph["8203"] = {"class_type": "ImageFromBatch", "inputs": {"image": decoded, "batch_index": 1, "length": length}}
581
+ graph["8204"] = {"class_type": "ImageBatch", "inputs": {"image1": ["8201", 0], "image2": ["8203", 0]}}
582
+ graph[video]["inputs"]["images"] = ["8204", 0]
583
+
584
+
456
585
  def apply_tool(
457
586
  agent: Agent,
458
587
  task: str,
@@ -580,6 +709,8 @@ def run_plan(agent: Agent, server: comfy.Server, plan: Plan, cancelled, label: s
580
709
  pass
581
710
  made = Made(saved, plan.model.name, plan.seed, width, height, time.monotonic() - line.started, plan.prompt)
582
711
  made.thumbnail = thumbnail(server, outputs)
712
+ if plan.kind == "video":
713
+ made.poster = thumbnail(server, outputs, POSTER_NODES[1])
583
714
  made.numbers = record(agent, made, plan)
584
715
  show(agent, made)
585
716
  return made
@@ -626,18 +757,21 @@ def show(agent: Agent, made: Made) -> None:
626
757
  + Text(f" {made.width}×{made.height} · {made.model}{seed} · {made.seconds:.1f} s", style="dim")
627
758
  )
628
759
  if made.files:
629
- show_preview(agent, made.files[0], made.thumbnail)
760
+ show_preview(agent, made.files[0], made.thumbnail, made.poster)
630
761
 
631
762
 
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."""
763
+ def show_preview(agent: Agent, path: Path, thumb: bytes = b"", poster: bytes = b"") -> None:
764
+ """The image (or a video's first frame) in the terminal: itself where the terminal can show pictures, else a
765
+ half-block preview."""
634
766
  setting = agent.settings.preview
635
767
  how = preview.protocol(setting)
636
768
  if how == "off" or (setting == "auto" and not agent.console.is_terminal):
637
769
  return
638
770
  try:
639
771
  if how in ("kitty", "iterm"):
640
- data = path.read_bytes()
772
+ data = path.read_bytes() if edits.is_image(path) else poster
773
+ if not data:
774
+ return
641
775
  sequence = preview.kitty(data) if how == "kitty" else preview.iterm(data)
642
776
  agent.console.file.write(sequence)
643
777
  agent.console.file.flush()
lcode/media.toml CHANGED
@@ -113,3 +113,86 @@ 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
149
+
150
+ [files.wan22-fun-inp-5b]
151
+ folder = "diffusion_models"
152
+ name = "wan2.2_fun_inpaint_5B_bf16.safetensors"
153
+ url = "https://huggingface.co/Comfy-Org/Wan_2.2_ComfyUI_Repackaged/resolve/main/split_files/diffusion_models/wan2.2_fun_inpaint_5B_bf16.safetensors"
154
+ gb = 10.00
155
+
156
+ [files.wan21-fun-camera-1-3b]
157
+ folder = "diffusion_models"
158
+ name = "wan2.1_fun_camera_v1.1_1.3B_bf16.safetensors"
159
+ url = "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/diffusion_models/wan2.1_fun_camera_v1.1_1.3B_bf16.safetensors"
160
+ gb = 3.23
161
+
162
+ [files.clip-vision-h]
163
+ folder = "clip_vision"
164
+ name = "clip_vision_h.safetensors"
165
+ url = "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/clip_vision/clip_vision_h.safetensors"
166
+ gb = 1.26
167
+
168
+ [files.wan21-vae]
169
+ folder = "vae"
170
+ name = "wan_2.1_vae.safetensors"
171
+ url = "https://huggingface.co/Comfy-Org/Wan_2.1_ComfyUI_repackaged/resolve/main/split_files/vae/wan_2.1_vae.safetensors"
172
+ gb = 0.25
173
+
174
+ [models.wan22-fun-inp-5b]
175
+ name = "Wan 2.2 Fun InP 5B"
176
+ mode = "video"
177
+ publisher = "Alibaba PAI"
178
+ license = "Apache 2.0"
179
+ tasks = ["first-last-frame"]
180
+ workflow = "wan22-fun-inp-5b"
181
+ files = { model = "wan22-fun-inp-5b", text_encoder = "umt5-xxl-fp8", vae = "wan22-vae" }
182
+ measured = "3 s clip, fast: 62 s (864×480) on an RTX 4080 Laptop, 12 GB"
183
+ notes = "a clip from a first frame to a last frame: @start.png @end.png"
184
+ quality = { fast = [12, 0.4], balanced = [20, 0.4], best = [30, 0.859] }
185
+ negative = true
186
+
187
+ [models.wan21-fun-camera-1-3b]
188
+ name = "Wan 2.1 Fun Camera 1.3B"
189
+ mode = "video"
190
+ publisher = "Alibaba PAI"
191
+ license = "Apache 2.0"
192
+ tasks = ["camera"]
193
+ workflow = "wan21-fun-camera"
194
+ files = { model = "wan21-fun-camera-1-3b", text_encoder = "umt5-xxl-fp8", vae = "wan21-vae", clip_vision = "clip-vision-h" }
195
+ measured = "3 s clip (49 frames, 832×480): 115 s on an RTX 4080 Laptop, 12 GB"
196
+ notes = "camera moves (--camera pan-left, zoom-in, …), small and fast, 16 fps"
197
+ quality = { fast = [12, 0.38], balanced = [20, 0.38], best = [30, 0.38] }
198
+ 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
@@ -111,6 +112,10 @@ class Catalog:
111
112
  def for_mode(self, mode: str) -> list[MediaModel]:
112
113
  return [m for m in self.models.values() if m.mode == mode]
113
114
 
115
+ def for_task(self, mode: str, task: str) -> MediaModel | None:
116
+ """The first model of a mode that does a task (first-last-frame, camera, image-to-video…)."""
117
+ return next((m for m in self.for_mode(mode) if task in m.tasks), None)
118
+
114
119
  def tool(self, task: str) -> MediaModel:
115
120
  """The tool for a task (upscale, remove-background)."""
116
121
  return next(m for m in self.models.values() if m.mode == "tool" and task in m.tasks)
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)