|
|
import functools
|
|
|
import json
|
|
|
import os
|
|
|
import shutil
|
|
|
import subprocess
|
|
|
import sys
|
|
|
import tempfile
|
|
|
import zipfile
|
|
|
from pathlib import Path
|
|
|
|
|
|
import click
|
|
|
|
|
|
from .const import COMFY_PACK_REPO, COMFYUI_MANAGER_REPO, COMFYUI_REPO, WORKSPACE_DIR
|
|
|
from .hash import get_sha256
|
|
|
from .utils import get_self_git_commit
|
|
|
|
|
|
|
|
|
def _ensure_uv() -> None:
|
|
|
"""Ensure uv is installed, raise error if not."""
|
|
|
try:
|
|
|
subprocess.run(
|
|
|
["uv", "--version"],
|
|
|
check=True,
|
|
|
capture_output=True,
|
|
|
)
|
|
|
except (subprocess.SubprocessError, FileNotFoundError):
|
|
|
raise RuntimeError(
|
|
|
"uv is not installed. Please install it first:\n"
|
|
|
"curl -LsSf https://astral.sh/uv/install.sh | sh"
|
|
|
)
|
|
|
|
|
|
|
|
|
@click.group()
|
|
|
@click.version_option()
|
|
|
def main():
|
|
|
"""comfy-pack CLI"""
|
|
|
pass
|
|
|
|
|
|
|
|
|
@main.command(
|
|
|
name="init",
|
|
|
help="Install latest ComfyUI and comfy-pack custom nodes and create a virtual environment",
|
|
|
)
|
|
|
@click.option(
|
|
|
"--dir",
|
|
|
"-d",
|
|
|
default="ComfyUI",
|
|
|
help="Target directory to install ComfyUI",
|
|
|
type=click.Path(file_okay=False),
|
|
|
)
|
|
|
@click.option(
|
|
|
"--verbose",
|
|
|
"-v",
|
|
|
count=True,
|
|
|
help="Increase verbosity level",
|
|
|
)
|
|
|
def init(dir: str, verbose: int):
|
|
|
import os
|
|
|
|
|
|
import rich
|
|
|
|
|
|
|
|
|
try:
|
|
|
install_dir = Path(dir).absolute()
|
|
|
if install_dir.exists() and not install_dir.is_dir():
|
|
|
rich.print(f"[red]Error: {dir} exists but is not a directory[/red]")
|
|
|
return 1
|
|
|
|
|
|
|
|
|
if install_dir.exists():
|
|
|
contents = list(install_dir.iterdir())
|
|
|
if contents and not (install_dir / ".git").exists():
|
|
|
rich.print(
|
|
|
f"[red]Error: Directory {dir} is not empty and doesn't appear to be a ComfyUI installation[/red]"
|
|
|
)
|
|
|
return 1
|
|
|
except Exception as e:
|
|
|
rich.print(f"[red]Error: Invalid directory path - {str(e)}[/red]")
|
|
|
return 1
|
|
|
|
|
|
|
|
|
try:
|
|
|
subprocess.run(
|
|
|
["git", "--version"],
|
|
|
check=True,
|
|
|
capture_output=True,
|
|
|
)
|
|
|
except (subprocess.SubprocessError, FileNotFoundError):
|
|
|
rich.print("[red]Error: git is not installed or not in PATH[/red]")
|
|
|
return 1
|
|
|
|
|
|
|
|
|
try:
|
|
|
if not install_dir.exists():
|
|
|
install_dir.mkdir(parents=True)
|
|
|
test_file = install_dir / ".write_test"
|
|
|
test_file.touch()
|
|
|
test_file.unlink()
|
|
|
except (OSError, PermissionError) as e:
|
|
|
rich.print(f"[red]Error: No write permission in {dir} - {str(e)}[/red]")
|
|
|
return 1
|
|
|
|
|
|
|
|
|
if sys.version_info < (3, 8):
|
|
|
rich.print("[red]Error: Python 3.8 or higher is required[/red]")
|
|
|
return 1
|
|
|
|
|
|
|
|
|
try:
|
|
|
_ensure_uv()
|
|
|
except RuntimeError as e:
|
|
|
rich.print(f"[red]Error: {str(e)}[/red]")
|
|
|
return 1
|
|
|
|
|
|
|
|
|
try:
|
|
|
free_space = shutil.disk_usage(install_dir).free
|
|
|
if free_space < 2 * 1024 * 1024 * 1024:
|
|
|
rich.print(
|
|
|
"[yellow]Warning: Less than 2GB free disk space available[/yellow]"
|
|
|
)
|
|
|
except Exception as e:
|
|
|
rich.print(
|
|
|
f"[yellow]Warning: Could not check free disk space - {str(e)}[/yellow]"
|
|
|
)
|
|
|
|
|
|
|
|
|
if not (install_dir / ".git").exists():
|
|
|
rich.print("[green]Cloning ComfyUI...[/green]")
|
|
|
subprocess.run(
|
|
|
[
|
|
|
"git",
|
|
|
"clone",
|
|
|
COMFYUI_REPO,
|
|
|
str(install_dir),
|
|
|
],
|
|
|
check=True,
|
|
|
)
|
|
|
|
|
|
|
|
|
rich.print("[green]Updating ComfyUI...[/green]")
|
|
|
subprocess.run(
|
|
|
["git", "pull"],
|
|
|
cwd=install_dir,
|
|
|
check=True,
|
|
|
)
|
|
|
|
|
|
|
|
|
venv_dir = install_dir / ".venv"
|
|
|
rich.print("[green]Creating virtual environment with uv...[/green]")
|
|
|
if venv_dir.exists():
|
|
|
shutil.rmtree(venv_dir)
|
|
|
subprocess.run(
|
|
|
["uv", "venv", str(venv_dir)],
|
|
|
check=True,
|
|
|
)
|
|
|
|
|
|
|
|
|
if sys.platform == "win32":
|
|
|
python = str(venv_dir / "Scripts" / "python.exe")
|
|
|
|
|
|
else:
|
|
|
python = str(venv_dir / "bin" / "python")
|
|
|
|
|
|
|
|
|
rich.print("[green]Installing ComfyUI requirements with uv...[/green]")
|
|
|
subprocess.run(
|
|
|
["uv", "pip", "install", "pip", "--upgrade"],
|
|
|
env={
|
|
|
"VIRTUAL_ENV": str(venv_dir),
|
|
|
"PATH": str(venv_dir / "bin") + os.pathsep + os.environ["PATH"],
|
|
|
},
|
|
|
check=True,
|
|
|
)
|
|
|
subprocess.run(
|
|
|
["uv", "pip", "install", "-r", str(install_dir / "requirements.txt")],
|
|
|
env={
|
|
|
"VIRTUAL_ENV": str(venv_dir),
|
|
|
"PATH": str(venv_dir / "bin") + os.pathsep + os.environ["PATH"],
|
|
|
},
|
|
|
check=True,
|
|
|
)
|
|
|
|
|
|
|
|
|
rich.print("[green]Installing comfy-pack custom nodes...[/green]")
|
|
|
custom_nodes_dir = install_dir / "custom_nodes"
|
|
|
custom_nodes_dir.mkdir(exist_ok=True)
|
|
|
|
|
|
comfyui_manager_dir = custom_nodes_dir / "ComfyUI-Manager"
|
|
|
if not (comfyui_manager_dir / ".git").exists():
|
|
|
|
|
|
subprocess.run(
|
|
|
["git", "clone", COMFYUI_MANAGER_REPO, str(comfyui_manager_dir)],
|
|
|
check=True,
|
|
|
)
|
|
|
|
|
|
comfy_pack_dir = custom_nodes_dir / "comfy-pack"
|
|
|
if not (comfy_pack_dir / ".git").exists():
|
|
|
|
|
|
subprocess.run(
|
|
|
["git", "clone", COMFY_PACK_REPO, str(comfy_pack_dir)],
|
|
|
check=True,
|
|
|
)
|
|
|
|
|
|
|
|
|
subprocess.run(
|
|
|
["git", "pull"],
|
|
|
cwd=comfy_pack_dir,
|
|
|
check=True,
|
|
|
)
|
|
|
|
|
|
|
|
|
if (comfy_pack_dir / "requirements.txt").exists():
|
|
|
subprocess.run(
|
|
|
[
|
|
|
python,
|
|
|
"-m",
|
|
|
"pip",
|
|
|
"install",
|
|
|
"-r",
|
|
|
str(comfy_pack_dir / "requirements.txt"),
|
|
|
],
|
|
|
check=True,
|
|
|
)
|
|
|
|
|
|
version = get_self_git_commit() or "unknown"
|
|
|
rich.print(
|
|
|
f"\n[green]✓ Installation completed! (comfy-pack version: {version})[/green]"
|
|
|
)
|
|
|
rich.print(f"ComfyUI directory: {install_dir}")
|
|
|
|
|
|
rich.print(
|
|
|
"\n[green]Next steps:[/green]\n"
|
|
|
f"1. cd {dir}\n"
|
|
|
"2. source .venv/bin/activate # On Windows: .venv\\Scripts\\activate\n"
|
|
|
"3. python main.py"
|
|
|
)
|
|
|
|
|
|
|
|
|
@main.command(
|
|
|
name="unpack",
|
|
|
help="Restore the ComfyUI workspace to specified directory",
|
|
|
)
|
|
|
@click.argument("cpack", type=click.Path(exists=True))
|
|
|
@click.option(
|
|
|
"--dir",
|
|
|
"-d",
|
|
|
default="ComfyUI",
|
|
|
help="target directory to restore the ComfyUI project",
|
|
|
type=click.Path(file_okay=False),
|
|
|
)
|
|
|
@click.option(
|
|
|
"--include-disabled-models",
|
|
|
default=False,
|
|
|
type=click.BOOL,
|
|
|
is_flag=True,
|
|
|
)
|
|
|
@click.option(
|
|
|
"--no-models",
|
|
|
default=False,
|
|
|
type=click.BOOL,
|
|
|
is_flag=True,
|
|
|
help="Do not install models",
|
|
|
)
|
|
|
@click.option(
|
|
|
"--no-venv",
|
|
|
is_flag=True,
|
|
|
help="Do not create a virtual environment for ComfyUI",
|
|
|
default=False,
|
|
|
)
|
|
|
@click.option(
|
|
|
"--verbose",
|
|
|
"-v",
|
|
|
count=True,
|
|
|
help="Increase verbosity level (use multiple times for more verbosity)",
|
|
|
)
|
|
|
@click.option(
|
|
|
"--preheat",
|
|
|
is_flag=True,
|
|
|
help="Preheat the workspace after unpacking",
|
|
|
default=False,
|
|
|
)
|
|
|
def unpack_cmd(
|
|
|
cpack: str,
|
|
|
dir: str,
|
|
|
include_disabled_models: bool,
|
|
|
no_models: bool,
|
|
|
no_venv: bool,
|
|
|
verbose: int,
|
|
|
preheat: bool,
|
|
|
):
|
|
|
import rich
|
|
|
|
|
|
from .package import install
|
|
|
|
|
|
install(
|
|
|
cpack,
|
|
|
dir,
|
|
|
verbose=verbose,
|
|
|
all_models=include_disabled_models,
|
|
|
prepare_models=not no_models,
|
|
|
no_venv=no_venv,
|
|
|
preheat=preheat,
|
|
|
)
|
|
|
rich.print("\n[green]✓ ComfyUI Workspace is restored at:[/green]")
|
|
|
rich.print(os.path.abspath(dir))
|
|
|
steps = [f"Change directory to the restored workspace: `cd {dir}`"]
|
|
|
if not no_venv:
|
|
|
steps.append(
|
|
|
"Source the virtual environment by running `source .venv/bin/activate`"
|
|
|
)
|
|
|
steps.append("Run the ComfyUI project by running `python main.py`")
|
|
|
|
|
|
rich.print(f"\n[green]⏭️ Next steps: [/green]\n1. {steps[0]}\n2. {steps[1]}")
|
|
|
if len(steps) > 2:
|
|
|
rich.print(f"3. {steps[2]}")
|
|
|
|
|
|
|
|
|
def _print_schema(schema, verbose: int = 0):
|
|
|
import rich
|
|
|
from rich.table import Table
|
|
|
|
|
|
table = Table(title="")
|
|
|
|
|
|
|
|
|
table.add_column("Input", style="cyan")
|
|
|
table.add_column("Type", style="green")
|
|
|
table.add_column("Required", style="yellow")
|
|
|
table.add_column("Default", style="blue")
|
|
|
table.add_column("Range", style="magenta")
|
|
|
|
|
|
|
|
|
required = schema.get("required", [])
|
|
|
|
|
|
|
|
|
for field, info in schema["properties"].items():
|
|
|
range_str = ""
|
|
|
if "minimum" in info or "maximum" in info:
|
|
|
min_val = info.get("minimum", "")
|
|
|
max_val = info.get("maximum", "")
|
|
|
range_str = f"{min_val} to {max_val}"
|
|
|
|
|
|
table.add_row(
|
|
|
field,
|
|
|
info.get("format", "") or info.get("type", ""),
|
|
|
"✓" if field in required else "",
|
|
|
str(info.get("default", "")),
|
|
|
range_str,
|
|
|
)
|
|
|
|
|
|
rich.print(table)
|
|
|
|
|
|
|
|
|
@functools.lru_cache
|
|
|
def _get_cache_workspace(cpack: str):
|
|
|
sha = get_sha256(cpack)
|
|
|
return WORKSPACE_DIR / sha[0:8]
|
|
|
|
|
|
|
|
|
@main.command(
|
|
|
context_settings={
|
|
|
"ignore_unknown_options": True,
|
|
|
"allow_extra_args": True,
|
|
|
},
|
|
|
help="Run a ComfyUI package with the given inputs",
|
|
|
add_help_option=False,
|
|
|
)
|
|
|
@click.argument("cpack", type=click.Path(exists=True, dir_okay=False))
|
|
|
@click.option("--output-dir", "-o", type=click.Path(), default=".")
|
|
|
@click.option("--help", "-h", is_flag=True, help="Show this message and input schema")
|
|
|
@click.option(
|
|
|
"--verbose",
|
|
|
"-v",
|
|
|
count=True,
|
|
|
help="Increase verbosity level (use multiple times for more verbosity)",
|
|
|
)
|
|
|
@click.pass_context
|
|
|
def run(ctx, cpack: str, output_dir: str, help: bool, verbose: int):
|
|
|
import rich
|
|
|
from pydantic import ValidationError
|
|
|
|
|
|
from .utils import generate_input_model
|
|
|
|
|
|
inputs = dict(
|
|
|
zip([k.lstrip("-").replace("-", "_") for k in ctx.args[::2]], ctx.args[1::2])
|
|
|
)
|
|
|
|
|
|
with zipfile.ZipFile(cpack) as z:
|
|
|
workflow = json.loads(z.read("workflow_api.json"))
|
|
|
|
|
|
input_model = generate_input_model(workflow)
|
|
|
|
|
|
|
|
|
if help:
|
|
|
rich.print(
|
|
|
'Usage: comfy-pack run [OPTIONS] CPACK --input1 "value1" --input2 "value2" ...'
|
|
|
)
|
|
|
rich.print("Run a ComfyUI package with the given inputs:")
|
|
|
_print_schema(input_model.model_json_schema(), verbose)
|
|
|
return 0
|
|
|
|
|
|
try:
|
|
|
validated_data = input_model(**inputs)
|
|
|
rich.print("[green]✓ Input is valid![/green]")
|
|
|
for field, value in validated_data.model_dump().items():
|
|
|
rich.print(f"{field}: {value}")
|
|
|
except ValidationError as e:
|
|
|
rich.print("[red]✗ Validation failed![/red]")
|
|
|
for error in e.errors():
|
|
|
rich.print(f"- {error['loc'][0]}: {error['msg']}")
|
|
|
|
|
|
rich.print("\n[yellow]Expected inputs:[/yellow]")
|
|
|
_print_schema(input_model.model_json_schema(), verbose)
|
|
|
return 1
|
|
|
|
|
|
from .package import install
|
|
|
|
|
|
workspace = _get_cache_workspace(cpack)
|
|
|
if not (workspace / "DONE").exists():
|
|
|
rich.print("\n[green]✓ Restoring ComfyUI Workspace...[/green]")
|
|
|
if workspace.exists():
|
|
|
shutil.rmtree(workspace)
|
|
|
install(cpack, workspace, verbose=verbose)
|
|
|
with open(workspace / "DONE", "w") as f:
|
|
|
f.write("DONE")
|
|
|
rich.print("\n[green]✓ ComfyUI Workspace is restored![/green]")
|
|
|
rich.print(f"{workspace}")
|
|
|
|
|
|
from .run import ComfyUIServer, run_workflow
|
|
|
|
|
|
with ComfyUIServer(str(workspace.absolute()), verbose=verbose) as server:
|
|
|
rich.print("\n[green]✓ ComfyUI is launched in the background![/green]")
|
|
|
results = run_workflow(
|
|
|
server.host,
|
|
|
server.port,
|
|
|
workflow,
|
|
|
Path(output_dir).absolute(),
|
|
|
verbose=verbose,
|
|
|
workspace=server.workspace,
|
|
|
**validated_data.model_dump(),
|
|
|
)
|
|
|
rich.print("\n[green]✓ Workflow is executed successfully![/green]")
|
|
|
if results:
|
|
|
rich.print("\n[green]✓ Retrieved outputs:[/green]")
|
|
|
if isinstance(results, dict):
|
|
|
for field, value in results.items():
|
|
|
rich.print(f"{field}: {value}")
|
|
|
elif isinstance(results, list):
|
|
|
for i, value in enumerate(results):
|
|
|
rich.print(f"{i}: {value}")
|
|
|
else:
|
|
|
rich.print(results)
|
|
|
|
|
|
|
|
|
@main.command(name="build-bento")
|
|
|
@click.argument("source")
|
|
|
@click.option("--name", help="Name of the bento service")
|
|
|
@click.option("--version", help="Version of the bento service")
|
|
|
def bento_cmd(source: str, name: str | None, version: str | None):
|
|
|
"""Build a bento from the source, which can be either a .cpack.zip file or a bento tag."""
|
|
|
import bentoml
|
|
|
from bentoml.bentos import BentoBuildConfig
|
|
|
|
|
|
from .package import build_bento
|
|
|
|
|
|
with tempfile.TemporaryDirectory() as temp_dir:
|
|
|
if source.endswith(".cpack.zip"):
|
|
|
name = name or os.path.basename(source).replace(".cpack.zip", "")
|
|
|
shutil.unpack_archive(source, temp_dir)
|
|
|
system_packages = None
|
|
|
include_default_system_packages = True
|
|
|
else:
|
|
|
existing_bento = bentoml.get(source)
|
|
|
name = name or existing_bento.tag.name
|
|
|
shutil.copytree(existing_bento.path_of("src"), temp_dir, dirs_exist_ok=True)
|
|
|
build_config = BentoBuildConfig.from_bento_dir(
|
|
|
existing_bento.path_of("src")
|
|
|
)
|
|
|
requirements_txt = Path(temp_dir) / "requirements.txt"
|
|
|
if (
|
|
|
requirements_txt.exists()
|
|
|
and "comfy-pack" not in requirements_txt.read_text()
|
|
|
):
|
|
|
with open(requirements_txt, "a") as f:
|
|
|
f.write("\ncomfy-pack")
|
|
|
system_packages = build_config.docker.system_packages
|
|
|
include_default_system_packages = False
|
|
|
|
|
|
build_bento(
|
|
|
name,
|
|
|
Path(temp_dir),
|
|
|
version=version,
|
|
|
system_packages=system_packages,
|
|
|
include_default_system_packages=include_default_system_packages,
|
|
|
)
|
|
|
|
|
|
|
|
|
def setup_cloud_client(
|
|
|
ctx: click.Context, param: click.Parameter, value: str | None
|
|
|
) -> str | None:
|
|
|
from bentoml._internal.configuration.containers import BentoMLContainer
|
|
|
|
|
|
if value:
|
|
|
BentoMLContainer.cloud_context.set(value)
|
|
|
os.environ["BENTOML_CLOUD_CONTEXT"] = value
|
|
|
return value
|
|
|
|
|
|
|
|
|
@main.command()
|
|
|
@click.argument("bento")
|
|
|
@click.option(
|
|
|
"-w",
|
|
|
"--workspace",
|
|
|
type=click.Path(file_okay=False, path_type=Path),
|
|
|
default="workspace",
|
|
|
help="Workspace directory, defaults to './workspace'.",
|
|
|
)
|
|
|
@click.option("-v", "--verbose", count=True, help="Increase verbosity level")
|
|
|
@click.option(
|
|
|
"--context",
|
|
|
help="BentoCloud context name.",
|
|
|
expose_value=False,
|
|
|
callback=setup_cloud_client,
|
|
|
)
|
|
|
def unpack_bento(bento: str, workspace: Path, verbose: int):
|
|
|
"""Restore the ComfyUI workspace from a given bento."""
|
|
|
import bentoml
|
|
|
|
|
|
from .package import install
|
|
|
|
|
|
try:
|
|
|
bento_obj = bentoml.get(bento)
|
|
|
except bentoml.exceptions.NotFound:
|
|
|
click.echo(
|
|
|
f"Bento {bento} not found in the local repository, trying to pull from BentoCloud",
|
|
|
err=True,
|
|
|
)
|
|
|
bentoml.pull(bento)
|
|
|
bento_obj = bentoml.get(bento)
|
|
|
|
|
|
install(bento_obj.path_of("src"), workspace, verbose=verbose, prepare_models=False)
|
|
|
|
|
|
if os.name == "nt":
|
|
|
exe = "Scripts/python.exe"
|
|
|
else:
|
|
|
exe = "bin/python"
|
|
|
click.echo(
|
|
|
f"Workspace is ready at {workspace}\n"
|
|
|
f"You can start ComfyUI by running `cd {workspace} && .venv/{exe} main.py`",
|
|
|
color="green",
|
|
|
)
|
|
|
|