lcode-cli 0.42.0__tar.gz → 0.43.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.
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/CHANGELOG.md +16 -1
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/PKG-INFO +1 -1
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/modes.md +40 -5
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/quickstart.md +1 -1
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/usage.md +1 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/__init__.py +1 -1
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/agent.py +4 -2
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/cli.py +6 -2
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/comfy.py +3 -2
- lcode_cli-0.43.0/src/lcode/edits.py +283 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/media.py +247 -37
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/media.toml +37 -2
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/media_catalog.py +12 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/repl.py +107 -37
- lcode_cli-0.43.0/src/lcode/workflows/remove-background.json +13 -0
- lcode_cli-0.43.0/src/lcode/workflows/upscale-esrgan.json +12 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/comfy_standin.py +16 -1
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_comfy.py +1 -1
- lcode_cli-0.43.0/tests/test_image_edit.py +214 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_media_catalog.py +12 -3
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/.editorconfig +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/.github/CODEOWNERS +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/.github/ISSUE_TEMPLATE/bug_report.yml +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/.github/ISSUE_TEMPLATE/feature_request.yml +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/.github/ISSUE_TEMPLATE/model_request.yml +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/.github/PULL_REQUEST_TEMPLATE.md +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/.github/dependabot.yml +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/.github/workflows/ci.yml +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/.github/workflows/docs.yml +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/.github/workflows/release.yml +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/.gitignore +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/.pre-commit-config.yaml +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/AGENTS.md +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/CODE_OF_CONDUCT.md +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/CONTRIBUTING.md +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/LICENSE +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/README.md +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/SECURITY.md +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/action.yml +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/agents.md +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/assets/demo.svg +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/assets/extra.css +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/automation.md +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/bench.md +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/changelog.md +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/commands.md +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/configuration.md +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/contributing.md +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/editors.md +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/git.md +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/hooks.md +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/how-it-works.md +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/index.md +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/installation.md +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/lsp.md +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/mcp.md +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/memory.md +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/models.md +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/sandbox.md +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/search.md +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/servers.md +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/docs/troubleshooting.md +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/examples/README.md +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/examples/api/fix_tests.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/examples/commands/review.md +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/examples/skills/commit-message/SKILL.md +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/examples/skills/commit-message/scripts/staged.sh +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/install.sh +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/mkdocs.yml +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/overrides/main.html +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/pyproject.toml +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/scripts/make_logos.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/scripts/record_demo.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/scripts/social-preview.html +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/__main__.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/acp.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/action.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/api.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/backends.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/bench.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/bench_backend.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/bench_runtime.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/catalog.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/checkpoints.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/checks.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/clipboard.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/cloud.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/codesearch.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/config.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/context.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/escalate.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/extensions.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/frontmatter.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/gitflow.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/hardware.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/hooks.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/imageopts.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/jobs.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/limits.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/lsp.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/matching.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/mcp/__init__.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/mcp/auth.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/mcp/catalog.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/mcp/catalog.toml +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/mcp/client.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/mcp/commands.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/mcp/config.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/mcp/http.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/mcp/manager.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/mcp/protocol.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/mcp/server.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/mcp/stdio.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/memory.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/models.toml +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/modes.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/notify.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/ollama.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/permissions.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/planning.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/preview.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/render.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/repeats.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/repomap.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/residency.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/review.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/sandbox.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/screenshots.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/secretscan.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/servers/__init__.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/servers/encord.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/servers/valohai.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/servers/xai.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/sessions.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/steer.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/subagents.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/tools.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/unreachable.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/update.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/vision.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/web.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/workflows/__init__.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/workflows/flux2-klein-4b-t2i.json +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/src/lcode/workflows/z-image-turbo-t2i.json +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/conftest.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/fake_lsp_server.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/fake_mcp_server.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_acp.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_action.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_agent.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_api.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_backends.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_bench.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_catalog.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_checkpoints.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_checks.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_cli.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_clipboard.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_cloud.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_codesearch.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_config.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_context.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_context_picker.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_escalate.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_extensions.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_gitflow.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_hooks.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_image_generate.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_jobs.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_look.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_lsp.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_matching.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_mcp.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_memory.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_model_picker.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_modes.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_permissions.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_plan_mode.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_repeats.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_repomap.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_residency.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_review.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_robustness.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_sandbox.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_screenshots.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_secretscan.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_servers.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_sessions.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_steer.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_stuck.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_subagents.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_tools.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_update.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_vision.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_web.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/tests/test_xai.py +0 -0
- {lcode_cli-0.42.0 → lcode_cli-0.43.0}/uv.lock +0 -0
|
@@ -6,6 +6,20 @@ All notable changes to lcode are documented here. The format follows
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.43.0] - 2026-10-24
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **Image mode edits pictures** (#152). See [Editing](https://nasser1941.github.io/lcode/modes/#editing).
|
|
14
|
+
- **Edit by instruction** (`/edit 3 make the sky purple`, or `@photo.png make the sky purple`)
|
|
15
|
+
and **references** (`@photo.png @#3 put the mug from image 1 on the table in image 2`), with
|
|
16
|
+
FLUX.2 klein 4B. `/use 3` makes every next request follow an image until `/use off`.
|
|
17
|
+
- **Inpainting** (`--mask mask.png`: only the white part changes) and **outpainting** (`/extend 3
|
|
18
|
+
--ar 16:9`, or by sides: only the new border is painted, the original stays as it was).
|
|
19
|
+
- **`/upscale 3`** (Real-ESRGAN, 2× or 4×) and **`/nobg 3`** (BiRefNet, a transparent PNG), installed
|
|
20
|
+
on first use. `/vary` on an edit makes new edits of the same images.
|
|
21
|
+
- ComfyUI's reasons for refusing a workflow now include its explanation, not just the input's name.
|
|
22
|
+
|
|
9
23
|
## [0.42.0] - 2026-10-24
|
|
10
24
|
|
|
11
25
|
### Added
|
|
@@ -940,7 +954,8 @@ First public release.
|
|
|
940
954
|
- `AGENTS.md` project instructions and `/init` to generate them.
|
|
941
955
|
- One-line installer for Linux and macOS.
|
|
942
956
|
|
|
943
|
-
[Unreleased]: https://github.com/nasser1941/lcode/compare/v0.
|
|
957
|
+
[Unreleased]: https://github.com/nasser1941/lcode/compare/v0.43.0...HEAD
|
|
958
|
+
[0.43.0]: https://github.com/nasser1941/lcode/compare/v0.42.0...v0.43.0
|
|
944
959
|
[0.42.0]: https://github.com/nasser1941/lcode/compare/v0.41.0...v0.42.0
|
|
945
960
|
[0.41.0]: https://github.com/nasser1941/lcode/compare/v0.40.0...v0.41.0
|
|
946
961
|
[0.40.0]: https://github.com/nasser1941/lcode/compare/v0.39.0...v0.40.0
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: lcode-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.43.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/
|
|
@@ -5,7 +5,7 @@ lcode makes three kinds of things, each in its own mode with its own models:
|
|
|
5
5
|
| Mode | What it makes | The model doing the work |
|
|
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
|
-
| **image** | Images from a description
|
|
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
9
|
| **video** | Videos from a description, a start image or references | A video model, run by ComfyUI on your GPU |
|
|
10
10
|
|
|
11
11
|
```text
|
|
@@ -81,6 +81,8 @@ alone shows them.
|
|
|
81
81
|
- **Sessions:** the gallery and `/options` are saved with the session, so `lcode -c` or `/resume`
|
|
82
82
|
brings them back. An image session is listed under its first prompt.
|
|
83
83
|
|
|
84
|
+
### While it works
|
|
85
|
+
|
|
84
86
|
- **Progress:** the status line says what ComfyUI is doing (loading the model, reading the
|
|
85
87
|
prompt, drawing, decoding), the step, the time left and the time so far. If other jobs are ahead
|
|
86
88
|
in ComfyUI's queue, it says how many.
|
|
@@ -99,6 +101,42 @@ alone shows them.
|
|
|
99
101
|
- **No Ollama needed:** `lcode --mode image` starts even when Ollama isn't running or the coding
|
|
100
102
|
model isn't installed; `/mode code` then says what's missing.
|
|
101
103
|
|
|
104
|
+
### Editing
|
|
105
|
+
|
|
106
|
+
FLUX.2 klein 4B edits pictures as well as making them: it takes images as references and an
|
|
107
|
+
instruction as the prompt. When the chosen model can't edit (Z-Image Turbo), lcode hands edits to
|
|
108
|
+
FLUX.2 klein and says so.
|
|
109
|
+
|
|
110
|
+
```text
|
|
111
|
+
image ❯ /edit 1 make the mug matte black with a small gold star
|
|
112
|
+
◆ #2 .lcode/media/20261024-100504-make-the-mug-matte-black-with-a-small-go.png 1024×1024 · FLUX.2 klein 4B · seed 1798225303 · 6.1 s
|
|
113
|
+
image ❯ /extend 1 --ar 16:9
|
|
114
|
+
Extending #1 from 1024×1024 to 1824×1024 before scaling.
|
|
115
|
+
◆ #4 .lcode/media/…-extended.png 1360×768 · FLUX.2 klein 4B · seed 2994584653 · 6.4 s
|
|
116
|
+
image ❯ /nobg 2
|
|
117
|
+
◆ #6 .lcode/media/…-nobg.png 1024×1024 · BiRefNet · 0.7 s
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
| What | How | What happens |
|
|
121
|
+
|---|---|---|
|
|
122
|
+
| **Edit by instruction** | `/edit 3 make the sky purple` (the last image without a number), or `@photo.png make the sky purple` | The image goes in as a reference, the instruction is the prompt. The result keeps the image's shape unless you give one (`--ar`). |
|
|
123
|
+
| **References** | `@photo.png @#3 put the mug from image 1 on the table in image 2`, or `/edit 3 … @style.png` | Several images, in order ("image 1", "image 2" in the instruction): a subject, a style, a place. `@"a file with spaces.png"` works too. |
|
|
124
|
+
| **Keep following an image** | `/use 3` (or `/use photo.png`), then plain requests; `/use off` | Every request is an edit of those images until you stop it: handy for one subject in many scenes. |
|
|
125
|
+
| **Inpaint** | `@photo.png --mask mask.png make the sky purple` | Only where the mask image is white changes (with a soft edge); the rest stays exactly as it was. |
|
|
126
|
+
| **Outpaint** | `/extend 3 --ar 16:9`, or `/extend 3 --left 256 --top 128`, plus words about the new part | The canvas grows, the model paints only the new border, and the original is pasted back as it was. |
|
|
127
|
+
| **Upscale** | `/upscale 3` (2×) or `/upscale 3 4` | Real-ESRGAN, a few seconds: a 1024×1024 image becomes 2048×2048. |
|
|
128
|
+
| **Remove the background** | `/nobg 3` | BiRefNet cuts out the subject: a PNG with a transparent background. |
|
|
129
|
+
|
|
130
|
+
- **Follow-ups work on edits too:** `/vary` on an edit makes new edits of the same images with the
|
|
131
|
+
same instruction, and `/again` repeats the last request, edit or not.
|
|
132
|
+
- **The tools install on first use,** after you agree: Real-ESRGAN (0.07 GB) and BiRefNet (0.44 GB).
|
|
133
|
+
`lcode media` lists them under "Tools for images".
|
|
134
|
+
- **Text in images:** FLUX.2 klein and Z-Image Turbo write short text you put in quotes
|
|
135
|
+
(`a café sign that says "OPEN"`).
|
|
136
|
+
- **Measured** on the RTX 4080 Laptop GPU (12 GB): an edit takes 5 to 7 s, two references 10 s,
|
|
137
|
+
inpainting and outpainting 6 to 12 s, a 2× upscale 6 s, and a background cut-out under a second
|
|
138
|
+
once the tools are loaded.
|
|
139
|
+
|
|
102
140
|
## ComfyUI
|
|
103
141
|
|
|
104
142
|
ComfyUI runs the image and video models; lcode drives it through its API.
|
|
@@ -198,10 +236,7 @@ themselves:
|
|
|
198
236
|
|
|
199
237
|
Video mode doesn't generate yet: a request there says what's coming instead of going anywhere.
|
|
200
238
|
|
|
201
|
-
- **#
|
|
202
|
-
- **#151, #152 Image mode:** generating, variations, sizes and seeds, a gallery, editing,
|
|
203
|
-
references, inpainting, upscaling.
|
|
204
|
-
- **#153, #154 Video mode:** from text or a start image, first and last frames, several
|
|
239
|
+
- **#153, #154 Video mode:** with video models in the catalog, from text or a start image, first and last frames, several
|
|
205
240
|
references, camera moves, longer clips, sound.
|
|
206
241
|
- **#155 Movies:** storyboards with a consistent cast, shots stitched into one film.
|
|
207
242
|
- **#156 Prompt assistant:** a small local model for prompts and follow-ups.
|
|
@@ -82,6 +82,7 @@ reasoning is on.
|
|
|
82
82
|
| `/verbose` | Toggle showing the reasoning text |
|
|
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
|
+
| `/edit N`, `/use N`, `/extend N`, `/upscale N`, `/nobg N` | Image mode: [edit, follow, outpaint, upscale, cut out](modes.md#editing) |
|
|
85
86
|
| `/permissions [ask\|plan\|auto-edit\|yolo]` | Set the permission mode |
|
|
86
87
|
| `/cd DIR` | Change the working directory |
|
|
87
88
|
| `/todos` | Show the model's task list |
|
|
@@ -380,7 +380,8 @@ class Agent:
|
|
|
380
380
|
|
|
381
381
|
self.image_options = ImageOptions() # image mode's defaults for this session (/options)
|
|
382
382
|
self.media_results: list[dict] = [] # the session's images, numbered (/gallery, /vary, /open)
|
|
383
|
-
self.last_request:
|
|
383
|
+
self.last_request: dict | None = None # what /again repeats (lcode.media.redo)
|
|
384
|
+
self.image_refs: list[str] = [] # images every next request follows (/use)
|
|
384
385
|
self.reflected = 0 # messages before this index were already checked for notes to remember
|
|
385
386
|
self._memory: memory_notes.Memory | None = None
|
|
386
387
|
self._agent_types: tuple[Path, dict[str, subagents.AgentType], list[str]] | None = None
|
|
@@ -697,7 +698,7 @@ class Agent:
|
|
|
697
698
|
"model": self.settings.model,
|
|
698
699
|
"mode": self.settings.mode,
|
|
699
700
|
"image_model": self.settings.image_model,
|
|
700
|
-
"media": {"results": self.media_results, "options": self.image_options.to_json()},
|
|
701
|
+
"media": {"results": self.media_results, "options": self.image_options.to_json(), "refs": self.image_refs},
|
|
701
702
|
"name": self.session_name,
|
|
702
703
|
"title": self.session_title,
|
|
703
704
|
"messages": self.messages,
|
|
@@ -751,6 +752,7 @@ class Agent:
|
|
|
751
752
|
media = data.get("media") or {}
|
|
752
753
|
self.media_results = [r for r in media.get("results") or [] if isinstance(r, dict) and "path" in r]
|
|
753
754
|
self.image_options = type(self.image_options).from_json(media.get("options") or {})
|
|
755
|
+
self.image_refs = [r for r in media.get("refs") or [] if isinstance(r, str)]
|
|
754
756
|
self.session_title = data.get("title") or sessions.title_from(self.messages)
|
|
755
757
|
self.tools.read_mtimes.clear() # files may have changed since; the model must read them again
|
|
756
758
|
self.tools.todos = list(data.get("todos") or [])
|
|
@@ -1107,9 +1107,13 @@ def cmd_media(args) -> None:
|
|
|
1107
1107
|
f"ComfyUI at {server.url}" if running else (f"{install.path} (not running)" if install else "no ComfyUI found")
|
|
1108
1108
|
)
|
|
1109
1109
|
console.print(f"[dim]{hw.describe()} · {where} · image model: {cfg['image_model']}[/]\n")
|
|
1110
|
-
for mode in ("image", "video"):
|
|
1110
|
+
for mode in ("image", "video", "tool"):
|
|
1111
1111
|
models = cat.for_mode(mode)
|
|
1112
|
-
console.print(
|
|
1112
|
+
console.print(
|
|
1113
|
+
"[bold]Tools for images[/] [dim](/upscale, /nobg)[/]"
|
|
1114
|
+
if mode == "tool"
|
|
1115
|
+
else f"[bold]{mode.capitalize()} models[/]"
|
|
1116
|
+
)
|
|
1113
1117
|
if not models:
|
|
1114
1118
|
console.print(f" [dim]{media.SOON[mode]}[/]\n")
|
|
1115
1119
|
continue
|
|
@@ -336,8 +336,9 @@ def describe_rejection(r: requests.Response) -> str:
|
|
|
336
336
|
lines = [str((data.get("error") or {}).get("message") or "ComfyUI refused the workflow")]
|
|
337
337
|
for node, info in (data.get("node_errors") or {}).items():
|
|
338
338
|
for err in info.get("errors", [])[:3]:
|
|
339
|
-
detail = err.get("
|
|
340
|
-
|
|
339
|
+
message, detail = err.get("message") or "", err.get("details") or ""
|
|
340
|
+
why = f"{message}: {detail}" if message and detail and detail not in message else (detail or message)
|
|
341
|
+
lines.append(f"{info.get('class_type', 'node')} ({node}): {why}")
|
|
341
342
|
return "; ".join(lines)
|
|
342
343
|
|
|
343
344
|
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Image edits as changes to a ComfyUI workflow: reference images, inpainting through a mask, outpainting.
|
|
2
|
+
|
|
3
|
+
A model that edits with reference latents (FLUX.2 klein) takes the images through its own text-to-image workflow:
|
|
4
|
+
each image is scaled, encoded by the VAE and attached to both conditionings before the guider, and the instruction is
|
|
5
|
+
the prompt. On top of that:
|
|
6
|
+
|
|
7
|
+
- **Inpainting** keeps the original outside a mask: the edited picture is pasted into the original only where the
|
|
8
|
+
mask is white (with a soft edge).
|
|
9
|
+
- **Outpainting** puts the image on a bigger canvas, lets the model fill the new border, and pastes the original back,
|
|
10
|
+
so it stays exactly as it was.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
import struct
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
|
|
19
|
+
BASE = 8000 # node ids lcode adds start here (the workflows' own are small numbers)
|
|
20
|
+
SIZE_NODE = str(BASE + 9)
|
|
21
|
+
IMAGE_TYPES = (".png", ".jpg", ".jpeg", ".webp", ".bmp")
|
|
22
|
+
EXTEND_HINT = (
|
|
23
|
+
"Replace the blurry border around the sharp picture with sharp, detailed scenery that continues the picture "
|
|
24
|
+
"outward, matching its light, perspective and style. Keep the sharp middle exactly as it is. No frame, no bars."
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class EditError(ValueError):
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def node_of(graph: dict, class_type: str) -> str | None:
|
|
33
|
+
return next((nid for nid, node in graph.items() if node.get("class_type") == class_type), None)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def save_node(graph: dict) -> str:
|
|
37
|
+
nid = node_of(graph, "SaveImage")
|
|
38
|
+
if nid is None:
|
|
39
|
+
raise EditError("the workflow saves no image")
|
|
40
|
+
return nid
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def load_images(graph: dict, names: list[str]) -> list[list]:
|
|
44
|
+
"""LoadImage nodes for uploaded images; their IMAGE outputs."""
|
|
45
|
+
links = []
|
|
46
|
+
for k, name in enumerate(names):
|
|
47
|
+
nid = str(BASE + 100 + k)
|
|
48
|
+
graph[nid] = {"class_type": "LoadImage", "inputs": {"image": name}}
|
|
49
|
+
links.append([nid, 0])
|
|
50
|
+
return links
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def add_references(graph: dict, images: list[list], megapixels: float, size_from_first: bool) -> list:
|
|
54
|
+
"""Attach the images as reference latents to the guider's positive and negative conditionings.
|
|
55
|
+
|
|
56
|
+
Each is scaled to `megapixels` first. With `size_from_first`, the picture made has the first image's size
|
|
57
|
+
(its width and height go to every node of the workflow that takes them). Returns the first scaled image.
|
|
58
|
+
"""
|
|
59
|
+
guider = node_of(graph, "CFGGuider")
|
|
60
|
+
vae = node_of(graph, "VAELoader")
|
|
61
|
+
if guider is None or vae is None:
|
|
62
|
+
raise EditError("this workflow can't take reference images")
|
|
63
|
+
positive, negative = graph[guider]["inputs"]["positive"], graph[guider]["inputs"]["negative"]
|
|
64
|
+
first = None
|
|
65
|
+
for k, image in enumerate(images):
|
|
66
|
+
scale, encode, ref_pos, ref_neg = (str(BASE + 10 * (k + 1) + i) for i in range(4))
|
|
67
|
+
graph[scale] = {
|
|
68
|
+
"class_type": "ImageScaleToTotalPixels",
|
|
69
|
+
"inputs": {"image": image, "upscale_method": "lanczos", "megapixels": megapixels, "resolution_steps": 16},
|
|
70
|
+
}
|
|
71
|
+
graph[encode] = {"class_type": "VAEEncode", "inputs": {"pixels": [scale, 0], "vae": [vae, 0]}}
|
|
72
|
+
graph[ref_pos] = {"class_type": "ReferenceLatent", "inputs": {"conditioning": positive, "latent": [encode, 0]}}
|
|
73
|
+
graph[ref_neg] = {"class_type": "ReferenceLatent", "inputs": {"conditioning": negative, "latent": [encode, 0]}}
|
|
74
|
+
positive, negative = [ref_pos, 0], [ref_neg, 0]
|
|
75
|
+
first = first or [scale, 0]
|
|
76
|
+
graph[guider]["inputs"]["positive"], graph[guider]["inputs"]["negative"] = positive, negative
|
|
77
|
+
if size_from_first and first:
|
|
78
|
+
set_size(graph, first)
|
|
79
|
+
return first
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def set_size(graph: dict, image: list) -> None:
|
|
83
|
+
"""The picture made takes `image`'s size: its width and height go to every workflow node that takes them."""
|
|
84
|
+
graph[SIZE_NODE] = {"class_type": "GetImageSize", "inputs": {"image": image}}
|
|
85
|
+
for nid, node in graph.items():
|
|
86
|
+
if nid.isdigit() and int(nid) >= BASE:
|
|
87
|
+
continue # lcode's own nodes keep their sizes
|
|
88
|
+
inputs = node.get("inputs", {})
|
|
89
|
+
for key, out in (("width", 0), ("height", 1)):
|
|
90
|
+
if key in inputs and not isinstance(inputs[key], list):
|
|
91
|
+
inputs[key] = [SIZE_NODE, out]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def scale_to(graph: dict, image: list, megapixels: float) -> list:
|
|
95
|
+
nid = str(BASE + 301)
|
|
96
|
+
graph[nid] = {
|
|
97
|
+
"class_type": "ImageScaleToTotalPixels",
|
|
98
|
+
"inputs": {"image": image, "upscale_method": "lanczos", "megapixels": megapixels, "resolution_steps": 16},
|
|
99
|
+
}
|
|
100
|
+
return [nid, 0]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def mask_from_file(graph: dict, mask_name: str) -> list:
|
|
104
|
+
"""The user's mask image (white: change), at the picture's size, with a soft edge."""
|
|
105
|
+
if SIZE_NODE not in graph:
|
|
106
|
+
raise EditError("inpainting needs the picture at the original's size")
|
|
107
|
+
load, to_image, scale, blur, to_mask = (str(BASE + 200 + i) for i in range(5))
|
|
108
|
+
graph[load] = {"class_type": "LoadImageMask", "inputs": {"image": mask_name, "channel": "red"}}
|
|
109
|
+
graph[to_image] = {"class_type": "MaskToImage", "inputs": {"mask": [load, 0]}}
|
|
110
|
+
graph[scale] = {
|
|
111
|
+
"class_type": "ImageScale",
|
|
112
|
+
"inputs": {"image": [to_image, 0], "upscale_method": "bilinear", "width": [SIZE_NODE, 0],
|
|
113
|
+
"height": [SIZE_NODE, 1], "crop": "disabled"},
|
|
114
|
+
} # fmt: skip
|
|
115
|
+
graph[blur] = {"class_type": "ImageBlur", "inputs": {"image": [scale, 0], "blur_radius": 8, "sigma": 4.0}}
|
|
116
|
+
graph[to_mask] = {"class_type": "ImageToMask", "inputs": {"image": [blur, 0], "channel": "red"}}
|
|
117
|
+
return [to_mask, 0]
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def mask_scaled(graph: dict, mask: list, megapixels: float) -> list:
|
|
121
|
+
"""A mask brought to the same size as an image scaled to `megapixels` (outpainting's border)."""
|
|
122
|
+
to_image, scale, to_mask = (str(BASE + 310 + i) for i in range(3))
|
|
123
|
+
graph[to_image] = {"class_type": "MaskToImage", "inputs": {"mask": mask}}
|
|
124
|
+
graph[scale] = {
|
|
125
|
+
"class_type": "ImageScaleToTotalPixels",
|
|
126
|
+
"inputs": {"image": [to_image, 0], "upscale_method": "bilinear", "megapixels": megapixels,
|
|
127
|
+
"resolution_steps": 16},
|
|
128
|
+
} # fmt: skip
|
|
129
|
+
graph[to_mask] = {"class_type": "ImageToMask", "inputs": {"image": [scale, 0], "channel": "red"}}
|
|
130
|
+
return [to_mask, 0]
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def sample_inside(graph: dict, image: list, mask: list) -> None:
|
|
134
|
+
"""Start from the image itself and let the sampler change only what the mask covers."""
|
|
135
|
+
sampler = node_of(graph, "SamplerCustomAdvanced") or node_of(graph, "KSampler")
|
|
136
|
+
vae = node_of(graph, "VAELoader")
|
|
137
|
+
if sampler is None or vae is None:
|
|
138
|
+
raise EditError("this workflow can't paint inside a mask")
|
|
139
|
+
encode, masked = str(BASE + 400), str(BASE + 401)
|
|
140
|
+
graph[encode] = {"class_type": "VAEEncode", "inputs": {"pixels": image, "vae": [vae, 0]}}
|
|
141
|
+
graph[masked] = {"class_type": "SetLatentNoiseMask", "inputs": {"samples": [encode, 0], "mask": mask}}
|
|
142
|
+
graph[sampler]["inputs"]["latent_image"] = [masked, 0]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def paste(graph: dict, destination: list, source: list, mask: list) -> None:
|
|
146
|
+
"""The saved picture becomes `destination` with `source` pasted where the mask is white."""
|
|
147
|
+
save = save_node(graph)
|
|
148
|
+
nid = str(BASE + 500)
|
|
149
|
+
graph[nid] = {
|
|
150
|
+
"class_type": "ImageCompositeMasked",
|
|
151
|
+
"inputs": {"destination": destination, "source": source, "x": 0, "y": 0, "resize_source": False,
|
|
152
|
+
"mask": mask},
|
|
153
|
+
} # fmt: skip
|
|
154
|
+
graph[save]["inputs"]["images"] = [nid, 0]
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def inpaint(graph: dict, original: list, mask_name: str) -> None:
|
|
158
|
+
"""Change only where the mask image is white: sampled there from the original, then pasted into it."""
|
|
159
|
+
mask = mask_from_file(graph, mask_name)
|
|
160
|
+
sample_inside(graph, original, mask)
|
|
161
|
+
paste(graph, original, graph[save_node(graph)]["inputs"]["images"], mask)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def pad_canvas(graph: dict, image: list, pads: tuple[int, int, int, int]) -> tuple[list, list]:
|
|
165
|
+
"""Outpainting's canvas, bigger by `pads` (left, top, right, bottom); (canvas, mask of the new border).
|
|
166
|
+
|
|
167
|
+
The border starts as a blurred, stretched copy of the image rather than flat grey: a fast model keeps a little of
|
|
168
|
+
what it starts from, and grey would show.
|
|
169
|
+
"""
|
|
170
|
+
pad, size, stretched, blurred, canvas = (str(BASE + 300 + i) for i in (0, 2, 3, 4, 5))
|
|
171
|
+
left, top, right, bottom = pads
|
|
172
|
+
graph[pad] = {
|
|
173
|
+
"class_type": "ImagePadForOutpaint",
|
|
174
|
+
"inputs": {"image": image, "left": left, "top": top, "right": right, "bottom": bottom, "feathering": 48},
|
|
175
|
+
}
|
|
176
|
+
graph[size] = {"class_type": "GetImageSize", "inputs": {"image": [pad, 0]}}
|
|
177
|
+
graph[stretched] = {
|
|
178
|
+
"class_type": "ImageScale",
|
|
179
|
+
"inputs": {"image": image, "upscale_method": "bilinear", "width": [size, 0], "height": [size, 1],
|
|
180
|
+
"crop": "disabled"},
|
|
181
|
+
} # fmt: skip
|
|
182
|
+
graph[blurred] = {"class_type": "ImageBlur", "inputs": {"image": [stretched, 0], "blur_radius": 31, "sigma": 10.0}}
|
|
183
|
+
graph[canvas] = {
|
|
184
|
+
"class_type": "ImageCompositeMasked",
|
|
185
|
+
"inputs": {"destination": [blurred, 0], "source": image, "x": left, "y": top, "resize_source": False},
|
|
186
|
+
}
|
|
187
|
+
return [canvas, 0], [pad, 1]
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def outpaint(graph: dict, canvas_scaled: list, border: list, megapixels: float) -> None:
|
|
191
|
+
"""Fill only the new border, sampled around the original, and keep the original exactly as it was."""
|
|
192
|
+
mask = mask_scaled(graph, border, megapixels)
|
|
193
|
+
sample_inside(graph, canvas_scaled, mask)
|
|
194
|
+
invert = str(BASE + 501)
|
|
195
|
+
graph[invert] = {"class_type": "InvertMask", "inputs": {"mask": mask}}
|
|
196
|
+
paste(graph, graph[save_node(graph)]["inputs"]["images"], canvas_scaled, [invert, 0])
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ----------------------------------------------------------------------------- sizes and arguments
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def image_size(path: Path) -> tuple[int, int]:
|
|
203
|
+
"""Width and height from a PNG, JPEG or WebP file's header."""
|
|
204
|
+
data = path.read_bytes()[:65536]
|
|
205
|
+
if data.startswith(b"\x89PNG\r\n\x1a\n"):
|
|
206
|
+
return struct.unpack(">II", data[16:24])
|
|
207
|
+
if data[:2] == b"\xff\xd8":
|
|
208
|
+
i = 2
|
|
209
|
+
while i + 9 < len(data):
|
|
210
|
+
if data[i] != 0xFF:
|
|
211
|
+
i += 1
|
|
212
|
+
continue
|
|
213
|
+
marker = data[i + 1]
|
|
214
|
+
length = struct.unpack(">H", data[i + 2 : i + 4])[0]
|
|
215
|
+
if marker in (0xC0, 0xC1, 0xC2, 0xC3, 0xC5, 0xC6, 0xC7, 0xC9, 0xCA, 0xCB, 0xCD, 0xCE, 0xCF):
|
|
216
|
+
h, w = struct.unpack(">HH", data[i + 5 : i + 9])
|
|
217
|
+
return w, h
|
|
218
|
+
i += 2 + length
|
|
219
|
+
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
|
220
|
+
kind = data[12:16]
|
|
221
|
+
if kind == b"VP8X":
|
|
222
|
+
return int.from_bytes(data[24:27], "little") + 1, int.from_bytes(data[27:30], "little") + 1
|
|
223
|
+
if kind == b"VP8 ":
|
|
224
|
+
w, h = struct.unpack("<HH", data[26:30])
|
|
225
|
+
return w & 0x3FFF, h & 0x3FFF
|
|
226
|
+
if kind == b"VP8L":
|
|
227
|
+
bits = int.from_bytes(data[21:25], "little")
|
|
228
|
+
return (bits & 0x3FFF) + 1, ((bits >> 14) & 0x3FFF) + 1
|
|
229
|
+
raise EditError(f"can't read the size of {path.name}: use a PNG, JPEG or WebP image")
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def pads_for_ratio(width: int, height: int, ratio: str) -> tuple[int, int, int, int]:
|
|
233
|
+
"""Borders that bring the image to an aspect ratio (a:b), split evenly, in steps of 8 pixels."""
|
|
234
|
+
m = re.fullmatch(r"(\d{1,2}):(\d{1,2})", ratio)
|
|
235
|
+
if not m or not int(m.group(1)) or not int(m.group(2)):
|
|
236
|
+
raise EditError("the shape to extend to is a ratio, e.g. --ar 16:9")
|
|
237
|
+
a, b = int(m.group(1)), int(m.group(2))
|
|
238
|
+
if abs(width / height - a / b) < 0.01:
|
|
239
|
+
raise EditError(f"it's {a}:{b} already: choose another shape, or --left/--right/--top/--bottom pixels")
|
|
240
|
+
if width / height < a / b: # wider
|
|
241
|
+
extra = round(height * a / b) - width
|
|
242
|
+
left = (extra // 2) // 8 * 8
|
|
243
|
+
return left, 0, (extra - left + 7) // 8 * 8, 0
|
|
244
|
+
extra = round(width * b / a) - height
|
|
245
|
+
top = (extra // 2) // 8 * 8
|
|
246
|
+
return 0, top, 0, (extra - top + 7) // 8 * 8
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
EXTEND_FLAG = re.compile(r"--(left|top|right|bottom)\s+(\d+)")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def parse_extend(text: str, width: int, height: int) -> tuple[tuple[int, int, int, int], str]:
|
|
253
|
+
"""`/extend` arguments: --ar 16:9, or --left/--top/--right/--bottom pixels; the rest describes the new part."""
|
|
254
|
+
sides = {k: int(v) for k, v in EXTEND_FLAG.findall(text)}
|
|
255
|
+
text = EXTEND_FLAG.sub(" ", text)
|
|
256
|
+
ratio = re.search(r"--(?:ar|size)\s+(\S+)", text)
|
|
257
|
+
text = re.sub(r"--(?:ar|size)\s+\S+", " ", text)
|
|
258
|
+
if ratio and sides:
|
|
259
|
+
raise EditError("extend by a shape (--ar 16:9) or by sides (--left 256), not both")
|
|
260
|
+
if ratio:
|
|
261
|
+
from lcode.imageopts import SIZES
|
|
262
|
+
|
|
263
|
+
pads = pads_for_ratio(width, height, SIZES.get(ratio.group(1), ratio.group(1)))
|
|
264
|
+
elif sides:
|
|
265
|
+
pads = tuple((sides.get(k, 0) + 7) // 8 * 8 for k in ("left", "top", "right", "bottom"))
|
|
266
|
+
if any(p > 2048 for p in pads):
|
|
267
|
+
raise EditError("a side grows by at most 2048 pixels")
|
|
268
|
+
else:
|
|
269
|
+
pads = pads_for_ratio(width, height, "16:9" if width <= height * 16 / 9 - 1 else "1:1")
|
|
270
|
+
return pads, " ".join(text.split())
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
REFERENCE = re.compile(r'(?:^|(?<=\s))@(#\d+|"[^"]+"|\S+)')
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def split_references(text: str) -> tuple[str, list[str]]:
|
|
277
|
+
"""The prompt without its @references, and the references (#3, or file paths) in order."""
|
|
278
|
+
refs = [m.group(1).strip('"') for m in REFERENCE.finditer(text)]
|
|
279
|
+
return " ".join(REFERENCE.sub(" ", text).split()), refs
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def is_image(path: Path) -> bool:
|
|
283
|
+
return path.suffix.lower() in IMAGE_TYPES
|