lcode-cli 0.43.0__tar.gz → 0.44.0__tar.gz

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.
Files changed (200) hide show
  1. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/CHANGELOG.md +17 -1
  2. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/PKG-INFO +4 -3
  3. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/README.md +3 -2
  4. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/docs/configuration.md +1 -0
  5. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/docs/modes.md +36 -6
  6. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/docs/quickstart.md +1 -1
  7. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/docs/usage.md +1 -0
  8. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/__init__.py +1 -1
  9. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/agent.py +4 -0
  10. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/api.py +1 -0
  11. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/cli.py +3 -11
  12. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/config.py +4 -3
  13. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/imageopts.py +21 -2
  14. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/media.py +103 -19
  15. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/media.toml +33 -0
  16. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/media_catalog.py +1 -0
  17. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/modes.py +9 -9
  18. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/repl.py +35 -20
  19. lcode_cli-0.44.0/src/lcode/workflows/wan22-ti2v-5b.json +152 -0
  20. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/comfy_standin.py +6 -0
  21. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_media_catalog.py +2 -1
  22. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_modes.py +12 -16
  23. lcode_cli-0.44.0/tests/test_video.py +145 -0
  24. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/.editorconfig +0 -0
  25. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/.github/CODEOWNERS +0 -0
  26. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
  27. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
  28. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
  29. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/.github/ISSUE_TEMPLATE/model_request.yml +0 -0
  30. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
  31. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/.github/dependabot.yml +0 -0
  32. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/.github/workflows/ci.yml +0 -0
  33. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/.github/workflows/docs.yml +0 -0
  34. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/.github/workflows/release.yml +0 -0
  35. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/.gitignore +0 -0
  36. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/.pre-commit-config.yaml +0 -0
  37. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/AGENTS.md +0 -0
  38. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/CODE_OF_CONDUCT.md +0 -0
  39. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/CONTRIBUTING.md +0 -0
  40. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/LICENSE +0 -0
  41. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/SECURITY.md +0 -0
  42. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/action.yml +0 -0
  43. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/docs/agents.md +0 -0
  44. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/docs/assets/demo.svg +0 -0
  45. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/docs/assets/extra.css +0 -0
  46. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/docs/automation.md +0 -0
  47. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/docs/bench.md +0 -0
  48. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/docs/changelog.md +0 -0
  49. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/docs/commands.md +0 -0
  50. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/docs/contributing.md +0 -0
  51. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/docs/editors.md +0 -0
  52. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/docs/git.md +0 -0
  53. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/docs/hooks.md +0 -0
  54. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/docs/how-it-works.md +0 -0
  55. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/docs/index.md +0 -0
  56. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/docs/installation.md +0 -0
  57. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/docs/lsp.md +0 -0
  58. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/docs/mcp.md +0 -0
  59. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/docs/memory.md +0 -0
  60. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/docs/models.md +0 -0
  61. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/docs/sandbox.md +0 -0
  62. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/docs/search.md +0 -0
  63. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/docs/servers.md +0 -0
  64. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/docs/troubleshooting.md +0 -0
  65. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/examples/README.md +0 -0
  66. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/examples/api/fix_tests.py +0 -0
  67. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/examples/commands/review.md +0 -0
  68. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/examples/skills/commit-message/SKILL.md +0 -0
  69. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/examples/skills/commit-message/scripts/staged.sh +0 -0
  70. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/install.sh +0 -0
  71. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/mkdocs.yml +0 -0
  72. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/overrides/main.html +0 -0
  73. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/pyproject.toml +0 -0
  74. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/scripts/make_logos.py +0 -0
  75. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/scripts/record_demo.py +0 -0
  76. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/scripts/social-preview.html +0 -0
  77. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/__main__.py +0 -0
  78. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/acp.py +0 -0
  79. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/action.py +0 -0
  80. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/backends.py +0 -0
  81. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/bench.py +0 -0
  82. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/bench_backend.py +0 -0
  83. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/bench_runtime.py +0 -0
  84. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/catalog.py +0 -0
  85. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/checkpoints.py +0 -0
  86. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/checks.py +0 -0
  87. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/clipboard.py +0 -0
  88. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/cloud.py +0 -0
  89. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/codesearch.py +0 -0
  90. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/comfy.py +0 -0
  91. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/context.py +0 -0
  92. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/edits.py +0 -0
  93. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/escalate.py +0 -0
  94. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/extensions.py +0 -0
  95. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/frontmatter.py +0 -0
  96. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/gitflow.py +0 -0
  97. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/hardware.py +0 -0
  98. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/hooks.py +0 -0
  99. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/jobs.py +0 -0
  100. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/limits.py +0 -0
  101. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/lsp.py +0 -0
  102. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/matching.py +0 -0
  103. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/mcp/__init__.py +0 -0
  104. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/mcp/auth.py +0 -0
  105. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/mcp/catalog.py +0 -0
  106. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/mcp/catalog.toml +0 -0
  107. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/mcp/client.py +0 -0
  108. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/mcp/commands.py +0 -0
  109. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/mcp/config.py +0 -0
  110. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/mcp/http.py +0 -0
  111. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/mcp/manager.py +0 -0
  112. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/mcp/protocol.py +0 -0
  113. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/mcp/server.py +0 -0
  114. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/mcp/stdio.py +0 -0
  115. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/memory.py +0 -0
  116. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/models.toml +0 -0
  117. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/notify.py +0 -0
  118. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/ollama.py +0 -0
  119. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/permissions.py +0 -0
  120. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/planning.py +0 -0
  121. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/preview.py +0 -0
  122. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/render.py +0 -0
  123. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/repeats.py +0 -0
  124. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/repomap.py +0 -0
  125. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/residency.py +0 -0
  126. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/review.py +0 -0
  127. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/sandbox.py +0 -0
  128. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/screenshots.py +0 -0
  129. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/secretscan.py +0 -0
  130. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/servers/__init__.py +0 -0
  131. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/servers/encord.py +0 -0
  132. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/servers/valohai.py +0 -0
  133. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/servers/xai.py +0 -0
  134. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/sessions.py +0 -0
  135. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/steer.py +0 -0
  136. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/subagents.py +0 -0
  137. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/tools.py +0 -0
  138. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/unreachable.py +0 -0
  139. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/update.py +0 -0
  140. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/vision.py +0 -0
  141. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/web.py +0 -0
  142. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/workflows/__init__.py +0 -0
  143. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/workflows/flux2-klein-4b-t2i.json +0 -0
  144. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/workflows/remove-background.json +0 -0
  145. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/workflows/upscale-esrgan.json +0 -0
  146. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/src/lcode/workflows/z-image-turbo-t2i.json +0 -0
  147. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/conftest.py +0 -0
  148. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/fake_lsp_server.py +0 -0
  149. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/fake_mcp_server.py +0 -0
  150. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_acp.py +0 -0
  151. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_action.py +0 -0
  152. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_agent.py +0 -0
  153. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_api.py +0 -0
  154. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_backends.py +0 -0
  155. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_bench.py +0 -0
  156. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_catalog.py +0 -0
  157. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_checkpoints.py +0 -0
  158. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_checks.py +0 -0
  159. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_cli.py +0 -0
  160. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_clipboard.py +0 -0
  161. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_cloud.py +0 -0
  162. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_codesearch.py +0 -0
  163. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_comfy.py +0 -0
  164. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_config.py +0 -0
  165. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_context.py +0 -0
  166. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_context_picker.py +0 -0
  167. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_escalate.py +0 -0
  168. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_extensions.py +0 -0
  169. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_gitflow.py +0 -0
  170. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_hooks.py +0 -0
  171. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_image_edit.py +0 -0
  172. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_image_generate.py +0 -0
  173. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_jobs.py +0 -0
  174. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_look.py +0 -0
  175. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_lsp.py +0 -0
  176. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_matching.py +0 -0
  177. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_mcp.py +0 -0
  178. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_memory.py +0 -0
  179. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_model_picker.py +0 -0
  180. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_permissions.py +0 -0
  181. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_plan_mode.py +0 -0
  182. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_repeats.py +0 -0
  183. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_repomap.py +0 -0
  184. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_residency.py +0 -0
  185. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_review.py +0 -0
  186. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_robustness.py +0 -0
  187. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_sandbox.py +0 -0
  188. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_screenshots.py +0 -0
  189. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_secretscan.py +0 -0
  190. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_servers.py +0 -0
  191. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_sessions.py +0 -0
  192. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_steer.py +0 -0
  193. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_stuck.py +0 -0
  194. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_subagents.py +0 -0
  195. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_tools.py +0 -0
  196. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_update.py +0 -0
  197. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_vision.py +0 -0
  198. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_web.py +0 -0
  199. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/tests/test_xai.py +0 -0
  200. {lcode_cli-0.43.0 → lcode_cli-0.44.0}/uv.lock +0 -0
@@ -6,6 +6,21 @@ All notable changes to lcode are documented here. The format follows
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.44.0] - 2026-10-24
10
+
11
+ ### Added
12
+
13
+ - **Video mode makes videos** (#153). Describe a clip, or start it from a picture (`@photo.png`),
14
+ and Wan 2.2 TI2V 5B makes it on your GPU. See [Video mode](https://nasser1941.github.io/lcode/modes/#video-mode).
15
+ - **Options:** `--seconds` (1 to 8, 5 by default, 24 fps) plus the image options. Quality
16
+ presets: 864×480 in 12 or 20 steps, or 1280×704 in 30. A 5-second clip takes 100 to 150 s on a
17
+ 12 GB laptop GPU. Clips are 16:9 (9:16 from an upright start image) unless you give a shape.
18
+ - **Results:** an MP4 with its settings in a JSON file next to it, in the session's gallery; the
19
+ first frame shows in the terminal, `/play` opens it, and `/vary`/`/again` make new clips.
20
+ - **`/animate 3`** in image mode makes a clip from image #3. `lcode media` lists video models, and
21
+ `/models` in video mode picks one (the `video_model` setting).
22
+ - With `notify` on, a long render ends with a desktop notification.
23
+
9
24
  ## [0.43.0] - 2026-10-24
10
25
 
11
26
  ### Added
@@ -954,7 +969,8 @@ First public release.
954
969
  - `AGENTS.md` project instructions and `/init` to generate them.
955
970
  - One-line installer for Linux and macOS.
956
971
 
957
- [Unreleased]: https://github.com/nasser1941/lcode/compare/v0.43.0...HEAD
972
+ [Unreleased]: https://github.com/nasser1941/lcode/compare/v0.44.0...HEAD
973
+ [0.44.0]: https://github.com/nasser1941/lcode/compare/v0.43.0...v0.44.0
958
974
  [0.43.0]: https://github.com/nasser1941/lcode/compare/v0.42.0...v0.43.0
959
975
  [0.42.0]: https://github.com/nasser1941/lcode/compare/v0.41.0...v0.42.0
960
976
  [0.41.0]: https://github.com/nasser1941/lcode/compare/v0.40.0...v0.41.0
@@ -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
@@ -71,8 +71,9 @@ through [Ollama](https://ollama.com), and when it needs current information it c
71
71
  `lcode --worktree` runs parallel sessions on one repository.
72
72
  - **In your editor.** Zed, JetBrains IDEs, Neovim and other editors that speak the Agent Client
73
73
  Protocol run lcode with `lcode acp`, with diffs and approvals in the editor.
74
- - **Image mode.** `/mode image`, describe a picture, and FLUX.2 klein 4B makes it on your GPU in
75
- seconds through ComfyUI, which lcode starts, follows and stops itself. Video mode is next
74
+ - **Image and video modes.** `/mode image`, describe a picture, and FLUX.2 klein 4B makes it on your
75
+ GPU in seconds; edit it, extend it, upscale it or cut it out. `/mode video` makes clips with
76
+ Wan 2.2, from a description or a start image. lcode drives ComfyUI itself
76
77
  ([modes](https://nasser1941.github.io/lcode/modes/)).
77
78
  - **Automation.** `lcode -p --output json` for scripts and CI, a Python API (`from lcode import
78
79
  Session`), and a GitHub Action that answers `@lcode` in issues and pull requests on your own
@@ -28,6 +28,7 @@ lcode config path # print the file location
28
28
  | `comfyui_path` | (found) | Your ComfyUI folder, if lcode doesn't find it; lcode starts it when needed ([ComfyUI](modes.md#comfyui)) |
29
29
  | `media_dir` | `.lcode/media` | Where images and videos go: a folder in the project, or an absolute path |
30
30
  | `preview` | `auto` | Images in the terminal: `auto`, `kitty`, `iterm`, `blocks` (colour text) or `off` ([results](modes.md#results)) |
31
+ | `video_model` | `wan22-ti2v-5b` | The model video mode uses: a key from `lcode media` ([video mode](modes.md#video-mode)) |
31
32
  | `image_model` | `flux2-klein-4b` | The model image mode uses: a key from `lcode media` ([image models](modes.md#image-models)) |
32
33
  | `think` | `true` | Let the model reason before answering |
33
34
  | `web` | `on` | Web search and page fetching: `on`, `ask` (before each search/website) or `off` |
@@ -6,7 +6,7 @@ lcode makes three kinds of things, each in its own mode with its own models:
6
6
  |---|---|---|
7
7
  | **code** (default) | Code: it reads, writes, runs and checks your project | A coding language model in Ollama (or another server) |
8
8
  | **image** | Images from a description, edits of your images (references, inpainting, outpainting), upscales and cut-outs | An image model, run by ComfyUI on your GPU |
9
- | **video** | Videos from a description, a start image or references | A video model, run by ComfyUI on your GPU |
9
+ | **video** | Video clips from a description or a start image | A video model, run by ComfyUI on your GPU |
10
10
 
11
11
  ```text
12
12
  ❯ /mode image
@@ -137,7 +137,39 @@ image ❯ /nobg 2
137
137
  inpainting and outpainting 6 to 12 s, a 2× upscale 6 s, and a background cut-out under a second
138
138
  once the tools are loaded.
139
139
 
140
- ## ComfyUI
140
+ ## Video mode
141
+
142
+ Video mode works like image mode: what you type describes a clip, and **Wan 2.2 TI2V 5B** makes it on
143
+ your GPU. Start it from a picture with `@photo.png` (or `@#3`, an image from this session), and the
144
+ clip begins with that frame. From image mode, `/animate 3 the waves roll in` switches to video mode
145
+ and animates image #3.
146
+
147
+ ```text
148
+ video ❯ waves rolling onto a beach at sunset --seconds 5
149
+ ◆ #1 .lcode/media/20261024-….mp4 864×480 · Wan 2.2 TI2V 5B · seed 1234 · …
150
+ video ❯ /play
151
+ ```
152
+
153
+ - **Options:** the image options work here too (`--n`, `--ar`, `--seed`, `--quality`, `--style`,
154
+ `--no`, and `/options`), plus `--seconds` (1 to 8; 5 by default, at 24 frames per second).
155
+ Clips are 16:9, or 9:16 from an upright start picture (cropped to it), unless you give a shape:
156
+ Wan 2.2 knows those two shapes best, and a square or small clip can break up into streaks.
157
+ - **Quality:** `fast` makes 864×480 in 12 steps, `balanced` (the default) 864×480 in 20 steps, and
158
+ `best` the model's own 1280×704 in 30 steps. Smaller than 864×480 breaks this model up into
159
+ streaks, so lcode doesn't go lower.
160
+ - **Camera moves:** describe them as shots ("slow zoom in", "dolly shot along the shore", "pan
161
+ left"). "The camera moves in" can put a camera in the picture.
162
+ - **Negative prompt:** Wan uses one. lcode passes Wan's own default (it avoids static, blurry,
163
+ over-exposed clips) unless you give `--no "…"`.
164
+ - **Results:** an MP4 with a JSON file next to it (prompt, seed, size, frames, fps, start image),
165
+ numbered in the session's gallery with the images. The first frame shows in the terminal.
166
+ `/play 4` opens a clip in your player, and `/vary` and `/again` make new clips from the same
167
+ description and start image.
168
+ - **Measured** on the RTX 4080 Laptop GPU (12 GB), a 5-second clip (121 frames): `fast` 100 s,
169
+ `balanced` 150 s (the same from a start image), `best` 10.5 minutes.
170
+ - **Long renders:** progress shows the step and the time left. With `notify` on, lcode sends a
171
+ desktop notification when a clip that took longer than `notify_after` seconds is done.
172
+
141
173
 
142
174
  ComfyUI runs the image and video models; lcode drives it through its API.
143
175
 
@@ -234,10 +266,8 @@ themselves:
234
266
 
235
267
  ## Coming next
236
268
 
237
- Video mode doesn't generate yet: a request there says what's coming instead of going anywhere.
238
-
239
- - **#153, #154 Video mode:** with video models in the catalog, from text or a start image, first and last frames, several
240
- references, camera moves, longer clips, sound.
269
+ - **#154 More video:** first and last frames, several references, camera moves, longer clips,
270
+ sound, and more video models in the catalog.
241
271
  - **#155 Movies:** storyboards with a consistent cast, shots stitched into one film.
242
272
  - **#156 Prompt assistant:** a small local model for prompts and follow-ups.
243
273
  - **#157 Cloud generation**, with costs shown (Grok Imagine first).
@@ -12,7 +12,7 @@ lcode
12
12
 
13
13
  ```text
14
14
  ╭──────────────────────────────────────────────╮
15
- │ lcode v0.43.0 — local coding agent │
15
+ │ lcode v0.44.0 — local coding agent │
16
16
  │ │
17
17
  │ model lcode-qwen3.6-35b │
18
18
  │ context 256K tokens │
@@ -83,6 +83,7 @@ reasoning is on.
83
83
  | `/mode [code\|image\|video]` | Switch what lcode makes: [code, images or videos](modes.md) |
84
84
  | `/options`, `/again`, `/vary N`, `/gallery`, `/open N` | Image mode: [options for the session, follow-ups on results](modes.md#results) |
85
85
  | `/edit N`, `/use N`, `/extend N`, `/upscale N`, `/nobg N` | Image mode: [edit, follow, outpaint, upscale, cut out](modes.md#editing) |
86
+ | `/animate N`, `/play N` | [Video mode](modes.md#video-mode): animate image N; play a clip |
86
87
  | `/permissions [ask\|plan\|auto-edit\|yolo]` | Set the permission mode |
87
88
  | `/cd DIR` | Change the working directory |
88
89
  | `/todos` | Show the model's task list |
@@ -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
 
@@ -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 {})
@@ -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"],
@@ -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(
@@ -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")
@@ -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
@@ -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()
@@ -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
@@ -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
@@ -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)