Compare commits

...
31 Commits
Author SHA1 Message Date
Frost Ming f6552c551b fix: restore pip packages (#68)
Signed-off-by: Frost Ming <me@frostming.com>
2025-07-21 09:03:13 +08:00
Frost Ming ef4c24eced feat: save text to file node (#66)
Signed-off-by: Frost Ming <me@frostming.com>
2025-06-23 16:13:33 +08:00
Frost Ming bb9844db5b feat: make zip from multiple output if enabled (#65)
Signed-off-by: Frost Ming <me@frostming.com>
2025-06-18 09:58:52 +08:00
Frost Ming fe60c8aa2b feat: support save video node (#64)
Signed-off-by: Frost Ming <me@frostming.com>
2025-06-13 11:16:41 +08:00
Frost Ming 9f73c4b29f feat: implement a new mechanism for packing and unpacking the workspace (#57)
* feat: new packing and unpacking methods

Signed-off-by: Frost Ming <me@frostming.com>

* feat: pack and unpack bento

Signed-off-by: Frost Ming <me@frostming.com>

* fix: remove last newline

Signed-off-by: Frost Ming <me@frostming.com>

* fix: manage node detection

Signed-off-by: Frost Ming <me@frostming.com>

* fix: don't fetch sources when building bento

Signed-off-by: Frost Ming <me@frostming.com>

* fix: no venv when unpacking bento

Signed-off-by: Frost Ming <me@frostming.com>

* fix: no venv

Signed-off-by: Frost Ming <me@frostming.com>

* fix: don't start comfyui when unpacking

Signed-off-by: Frost Ming <me@frostming.com>

---------

Signed-off-by: Frost Ming <me@frostming.com>
2025-06-03 10:38:35 +08:00
Sebastian Elsner 06cab6a0a1 feat: add support for hf-token and gated models (#61) 2025-05-30 11:25:31 +08:00
Sebastian Elsner 4a22f94071 fix the packaging for huggingface (#60) 2025-05-21 08:28:33 +08:00
Frost Ming 17e2fe77f3 fix: output audio node (#59)
Signed-off-by: Frost Ming <me@frostming.com>
2025-05-19 15:09:40 +08:00
Frost Ming 06c122af0d feat(nodes): new node: Save Audio (#58)
Signed-off-by: Frost Ming <me@frostming.com>
2025-05-16 15:43:06 +08:00
Jonas Z. 2c9b65c2c5 add output img + txt file batch support (#55) 2025-04-22 14:19:00 +08:00
Frost Ming 38a8427970 fix: write the done file after done (#51)
Signed-off-by: Frost Ming <me@frostming.com>
2025-03-10 15:09:41 +08:00
Frost Ming 8063b7b60f feat: pull bento from cloud if not exists (#50)
* feat: pull bento from cloud if not exists

Signed-off-by: Frost Ming <me@frostming.com>

* fix: convert to pathlib.Path

Signed-off-by: Frost Ming <me@frostming.com>
2025-03-07 12:30:11 +08:00
Frost Ming 9ae75d5ed7 feat: install reqs.txt from src directory (#49)
Signed-off-by: Frost Ming <me@frostming.com>
2025-03-05 09:56:17 +08:00
Frost Ming a717e3b40f feat: unpack-bento command (#46)
Signed-off-by: Frost Ming <me@frostming.com>
2025-02-12 17:34:49 +08:00
Frost Ming d5dbda8ce9 fix: serve from UI has no workspace (#45)
Signed-off-by: Frost Ming <me@frostming.com>
2025-02-12 08:19:41 +08:00
Frost Ming cbf66db398 fix: remove trailing whitespace (#43)
Signed-off-by: Frost Ming <me@frostming.com>
2025-02-04 22:01:50 +08:00
Frost Ming 56a9cb88ac fix: pin bentoml version (#42)
Signed-off-by: Frost Ming <me@frostming.com>
2025-02-04 19:19:59 +08:00
Frost Ming d15c4c54ad fix: don't import bentoml at setup (#41)
Signed-off-by: Frost Ming <me@frostming.com>
2025-02-04 19:18:53 +08:00
Frost Ming f54ff9aa92 fix: remove on shutdown hook (#40)
Signed-off-by: Frost Ming <me@frostming.com>
2025-02-03 09:28:22 +08:00
Frost Ming 3f07d59b58 fix: specify the workspace for run (#38)
Signed-off-by: Frost Ming <me@frostming.com>
2025-01-24 09:40:04 +08:00
bojiang e181b07499 fix: prevent terminal prompt for git clone (#37) 2025-01-23 16:11:09 +08:00
bojiang 48ba44cc6c chore: verbose setup workspace (#36) 2025-01-23 15:16:50 +08:00
Frost Ming dfe00bfc21 fix: skip seen and excluded packages (#35)
Signed-off-by: Frost Ming <me@frostming.com>
2025-01-23 11:39:23 +08:00
Frost Ming bcb5b4b809 fix: build bento api error (#34)
Signed-off-by: Frost Ming <me@frostming.com>
2025-01-23 11:30:56 +08:00
bojiang 2cbc0ee754 fix: append comfy-pack to requirements (#31) 2025-01-21 20:50:04 +08:00
bojiang b850017f39 fix: add comfy-pack (#30) 2025-01-21 20:10:22 +08:00
bojiang 8f7289c97e fix: permission issue 2025-01-21 19:51:18 +08:00
bojiang eae4e789fe fix: undefined variable (#28) 2025-01-21 19:43:33 +08:00
Frost Ming 0ac43cf5c3 fix: make setup script self-contained (#27)
Signed-off-by: Frost Ming <me@frostming.com>
2025-01-21 19:35:03 +08:00
Frost Ming 1b553459a5 fix: service py location (#26)
Signed-off-by: Frost Ming <me@frostming.com>
2025-01-16 16:31:42 +08:00
bojiang 2f0cab20d2 fix: build-bento (#24) 2025-01-11 12:02:26 +08:00
17 changed files with 1833 additions and 1545 deletions
+2
View File
@@ -161,3 +161,5 @@ cython_debug/
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
/temp/
src/.DS_Store
.DS_Store
+7
View File
@@ -73,6 +73,13 @@ To unpack:
comfy-pack unpack workflow.cpack.zip
```
Huggingface gated models can be accessed by setting your `HF_TOKEN` as an environment variable before unpacking:
```bash
export HF_TOKEN=hf_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXx
comfy-pack unpack workflow.cpack.zip
```
For example cpack files, check our [examples folder](examples/).
### Deploy a workflow as an API
+2 -3
View File
@@ -1,8 +1,7 @@
import sys
import pathlib
import sys
SRC_DIR = pathlib.Path(__file__).parent.parent / "src"
if str(SRC_DIR) not in sys.path:
sys.path.append(str(SRC_DIR))
sys.path.insert(0, str(SRC_DIR))
+61 -89
View File
@@ -11,7 +11,7 @@ import tempfile
import time
import uuid
import zipfile
from importlib.metadata import Distribution, distributions
from fnmatch import fnmatch
from pathlib import Path
from typing import Any, Union
@@ -26,53 +26,63 @@ from comfy_pack.package import build_bento
ZPath = Union[Path, zipfile.Path]
TEMP_FOLDER = Path(__file__).parent.parent / "temp"
COMFY_PACK_DIR = Path(__file__).parent.parent / "src" / "comfy_pack"
EXCLUDE_PACKAGES = ["bentoml", "onnxruntime", "conda"] # TODO: standardize this
EXCLUDE_PACKAGES = ["bentoml", "onnxruntime", "conda", "nvidia-*"]
def _get_requirement_string(dist: Distribution) -> str:
direct_url_text = dist.read_text("direct_url.json")
pinned_str = f'{dist.metadata["Name"]}=={dist.version}'
if not direct_url_text:
return pinned_str
direct_url = json.loads(direct_url_text)
if url := direct_url.get("url"):
if url.startswith("file://"):
# we are not able to share local files
return pinned_str
if vcs_info := direct_url.get("vcs_info"):
url = f"{vcs_info['vcs']}+{url}@{vcs_info['commit_id']}"
if subdirectory := direct_url.get("subdirectory"):
url += f"#subdirectory={subdirectory}"
return f"{dist.metadata['Name']} @ {url}"
else:
return pinned_str
def normalize_name(name: str) -> str:
import re
return re.sub(r"[-_.]+", "-", name).lower()
async def _write_requirements(path: ZPath, extras: list[str] | None = None) -> None:
print("Package => Writing requirements.txt")
with path.joinpath("requirements.txt").open("w") as f:
for dist in distributions():
f.write(_get_requirement_string(dist) + "\n")
if extras:
f.write("\n".join(extras) + "\n")
async def _write_snapshot(path: ZPath, data: dict, models: list | None = None) -> None:
proc = await asyncio.subprocess.create_subprocess_exec(
"git", "rev-parse", "HEAD", stdout=subprocess.PIPE, cwd=folder_paths.base_path
def get_snapshot_path() -> Path | None:
manager_file_path = Path(
folder_paths.get_user_directory(), "default", "ComfyUI-Manager"
)
stdout, _ = await proc.communicate()
if models is None:
print("Package => Writing models")
models = await _get_models()
return manager_file_path / "snapshots"
async def _save_snapshot() -> dict[str, Any]:
save_snapshot_route = next(
(
route
for route in PromptServer.instance.routes
if route.path == "/snapshot/save"
),
None,
)
if not save_snapshot_route:
raise RuntimeError("ComfyUI-Manager must be installed to save snapshot")
await save_snapshot_route.handler(None)
snapshot_path = get_snapshot_path()
if not snapshot_path.exists():
raise RuntimeError("Snapshot save failed")
most_recent = max(
snapshot_path.glob("*.json"), key=lambda x: x.stat().st_mtime, default=None
)
if not most_recent:
raise RuntimeError("Snapshot save failed")
with most_recent.open("r") as f:
return json.load(f)
async def _write_snapshot(path: ZPath, data: dict, models: list) -> None:
snapshot = await _save_snapshot()
for package in list(snapshot["pips"]):
if any(
fnmatch(normalize_name(package.split("==")[0]), pat)
for pat in EXCLUDE_PACKAGES
):
del snapshot["pips"][package]
with path.joinpath("snapshot.json").open("w") as f:
data = {
"python": f"{sys.version_info.major}.{sys.version_info.minor}",
"comfyui": stdout.decode().strip(),
"models": models,
"custom_nodes": await _get_custom_nodes(),
}
f.write(json.dumps(data, indent=2))
snapshot.update(
{
"python": f"{sys.version_info.major}.{sys.version_info.minor}",
"models": models,
}
)
f.write(json.dumps(snapshot, indent=2))
def _is_port_in_use(port: int | str, host="localhost"):
@@ -158,7 +168,7 @@ async def _get_models(
if should_store:
import bentoml
model_tag = f'cpack-model:{model_data["sha256"][:16]}'
model_tag = f"cpack-model:{model_data['sha256'][:16]}"
try:
model = bentoml.models.get(model_tag)
except bentoml.exceptions.NotFound:
@@ -174,47 +184,6 @@ async def _get_models(
return models
async def _get_custom_nodes() -> list:
print("Package => Writing custom nodes")
custom_nodes = os.path.join(folder_paths.base_path, "custom_nodes")
coros = []
async def get_node_info(subdir: Path) -> dict:
proc = await asyncio.subprocess.create_subprocess_exec(
"git",
"config",
"--get",
"remote.origin.url",
cwd=subdir,
stdout=subprocess.PIPE,
)
stdout, _ = await proc.communicate()
url = stdout.decode().strip()
proc = await asyncio.subprocess.create_subprocess_exec(
"git",
"rev-parse",
"HEAD",
cwd=subdir,
stdout=subprocess.PIPE,
)
stdout, _ = await proc.communicate()
commit_hash = stdout.decode().strip()
return {
"url": url,
"commit_hash": commit_hash,
"disabled": subdir.name.endswith(".disabled"),
"path": str(subdir.relative_to(custom_nodes)),
}
for subdir in Path(custom_nodes).iterdir():
if not subdir.is_dir() or not subdir.joinpath(".git").exists():
continue
coros.append(get_node_info(subdir))
return await asyncio.gather(*coros)
async def _write_workflow(path: ZPath, data: dict) -> None:
print("Package => Writing workflow")
with path.joinpath("workflow_api.json").open("w") as f:
@@ -277,6 +246,8 @@ class DevServer:
@classmethod
def start(cls, workflow_api: dict, port: int = 3000):
from comfy_pack import __file__ as comfy_pack_file
cls.stop()
cls.port = port
@@ -285,7 +256,7 @@ class DevServer:
with cls.run_dir.joinpath("workflow_api.json").open("w") as f:
f.write(json.dumps(workflow_api, indent=2))
shutil.copy(
Path(__file__).with_name("service.py"),
Path(comfy_pack_file).with_name("service.py"),
cls.run_dir / "service.py",
)
shutil.copytree(COMFY_PACK_DIR, cls.run_dir / COMFY_PACK_DIR.name)
@@ -436,7 +407,7 @@ async def serve(request):
)
@PromptServer.instance.routes.post("/bentoml/serve/heartbeat")
@PromptServer.instance.routes.get("/bentoml/serve/heartbeat")
async def heartbeat(_):
running = DevServer.feed_watch_dog()
@@ -462,14 +433,15 @@ async def _prepare_pack(
working_dir: ZPath,
data: dict,
store_models: bool = False,
ensure_source: bool = True,
) -> None:
model_filter = set(data.get("models", []))
models = await _get_models(
store_models=store_models,
model_filter=model_filter,
ensure_source=ensure_source,
)
await _write_requirements(working_dir, ["comfy-cli", "fastapi"])
await _write_snapshot(working_dir, data, models)
await _write_workflow(working_dir, data)
await _write_inputs(working_dir, data)
@@ -535,14 +507,14 @@ async def build_bento_api(request):
with tempfile.TemporaryDirectory(suffix="-bento", prefix="comfy-pack-") as temp_dir:
temp_dir_path = Path(temp_dir)
await _prepare_pack(temp_dir_path, data, store_models=True)
await _prepare_pack(temp_dir_path, data, store_models=True, ensure_source=False)
# create a bento
try:
bento = build_bento(
data["bento_name"],
temp_dir_path,
data.get("system_packages"),
system_packages=data.get("system_packages"),
)
except bentoml.exceptions.BentoMLException as e:
return web.json_response(
+191 -2
View File
@@ -1,14 +1,19 @@
import glob
import hashlib
import json
import os
import sys
import shutil
import sys
import zipfile
from io import BytesIO
import folder_paths
import node_helpers
import numpy as np
import torch
from PIL import Image, ImageOps, ImageSequence
from comfy_extras.nodes_audio import SaveAudio
from comfy_extras.nodes_video import SaveVideo
from PIL import Image, ImageOps, ImageSequence, PngImagePlugin
from PIL.PngImagePlugin import PngInfo
from .monkeypatch import set_bentoml_output
@@ -163,6 +168,100 @@ class OutputImage:
return {"ui": {"images": results}}
class OutputImageWithStringTxt:
COLOR = (142, 36, 170)
def __init__(self):
self.output_dir = folder_paths.get_output_directory()
self.type = "output"
self.prefix_append = ""
self.compress_level = 4
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"images": ("IMAGE", {"tooltip": "The images to save."}),
"filename_prefix": ("STRING", {"default": "cpack_output_"}),
"text": ("STRING", {"default": ""}),
},
"hidden": {
"prompt": "PROMPT",
"extra_pnginfo": "EXTRA_PNGINFO",
},
}
RETURN_TYPES = ()
FUNCTION = "save_images"
CPACK_NODE = True
OUTPUT_NODE = True
CATEGORY = "ComfyPack/output"
DESCRIPTION = (
"Saves the input images (and optional text) to your ComfyUI output directory."
)
def save_images(
self,
images,
filename_prefix="cpack_output_",
text="",
prompt=None,
extra_pnginfo=None,
):
filename_prefix += self.prefix_append
full_output_folder, filename, counter, subfolder, filename_prefix = (
get_save_image_path(
filename_prefix, self.output_dir, images[0].shape[1], images[0].shape[0]
)
)
base_counter = counter # use for name zip
zip_filename = f"{filename}_batch_{base_counter:05}.zip"
zip_path = os.path.join(full_output_folder, zip_filename)
# create ZIP file
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zipf:
for batch_number, image in enumerate(images):
# temp store img to RAM
i = 255.0 * image.cpu().numpy()
img = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8))
# write meta data
metadata = PngImagePlugin.PngInfo()
if prompt is not None:
metadata.add_text("prompt", json.dumps(prompt))
if extra_pnginfo is not None:
for x in extra_pnginfo:
metadata.add_text(x, json.dumps(extra_pnginfo[x]))
# write img file to RAM buffer
img_buffer = BytesIO()
img.save(
img_buffer,
format="PNG",
pnginfo=metadata,
compress_level=self.compress_level,
)
img_buffer.seek(0)
# write img into ZIP file
image_filename = f"image_{batch_number:05}.png"
zipf.writestr(image_filename, img_buffer.read())
# write txt into ZIP file
text_filename = f"text_{batch_number:05}.txt"
zipf.writestr(text_filename, text)
# return zip as output
out = [{"filename": zip_filename, "subfolder": subfolder, "type": "zip"}]
return {
"ui": {
"zip": out,
}
}
class ImageInput:
COLOR = (142, 36, 170)
@@ -353,14 +452,99 @@ class AnyInput:
return True
class OutputZip:
CATEGORY = "ComfyPack/output"
@classmethod
def INPUT_TYPES(cls):
return {}
RETURN_TYPES = ()
FUNCTION = "null_op"
def null_op(self):
return ()
class OutputAudio(SaveAudio):
CPACK_NODE = True
CATEGORY = "ComfyPack/output"
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"audio": ("AUDIO",),
"filename_prefix": ("STRING", {"default": "cpack_output_"}),
},
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
}
class OutputVideo(SaveVideo):
CPACK_NODE = True
CATEGORY = "ComfyPack/output"
class OutputTextFile:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"text": ("STRING", {"forceInput": True}),
"filename_prefix": ("STRING", {"default": "cpack_output_"}),
},
"optional": {
"file_extension": ("STRING", {"default": ".txt"}),
},
}
OUTPUT_NODE = True
RETURN_TYPES = ()
FUNCTION = "save_text_file"
CATEGORY = "ComfyPack/output"
CPACK_NODE = True
def save_text_file(
self, text: str, filename_prefix: str, file_extension: str = ".txt"
):
subfolder, filename_prefix = os.path.split(os.path.normpath(filename_prefix))
output_dir = folder_paths.get_output_directory()
full_output_folder = os.path.join(output_dir, subfolder)
full_output_filename = self.get_output_filename(
full_output_folder, filename_prefix, file_extension
)
with open(full_output_filename, "w", encoding="utf-8", newline="\n") as f:
f.write(text)
return (text, {"ui": {"string": text}})
@staticmethod
def get_output_filename(folder: str, prefix: str, extension: str) -> str:
matched_files = [
os.path.basename(f)[len(prefix) + 1 : -len(extension)]
for f in glob.glob(os.path.join(folder, f"{prefix}_*{extension}"))
]
print("MATCHING", matched_files)
max_count = max(
(int(name) for name in matched_files if name.isdigit()), default=0
)
return os.path.join(folder, f"{prefix}_{max_count + 1:04d}{extension}")
NODE_CLASS_MAPPINGS = {
"CPackOutputFile": OutputFile,
"CPackOutputImage": OutputImage,
"CPackOutputAudio": OutputAudio,
"CPackOutputVideo": OutputVideo,
"CPackOutputZip": OutputImageWithStringTxt,
"CPackOutputZipSwitch": OutputZip,
"CPackInputImage": ImageInput,
"CPackInputString": StringInput,
"CPackInputInt": IntInput,
"CPackInputFile": FileInput,
"CPackInputAny": AnyInput,
"CPackOutputTextFile": OutputTextFile,
}
NODE_DISPLAY_NAME_MAPPINGS = {
@@ -370,5 +554,10 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"CPackInputFile": "File Input",
"CPackInputAny": "Any Input",
"CPackOutputImage": "Image Output",
"CPackOutputAudio": "Audio Output",
"CPackOutputVideo": "Video Output",
"CPackOutputFile": "File Output",
"CPackOutputZip": "Zip Output(img + txt file)",
"CPackOutputZipSwitch": "Enable Zip Output",
"CPackOutputTextFile": "Output Text to File",
}
+1 -1
View File
@@ -8,7 +8,7 @@ authors = [{ name = "Frost Ming", email = "frost@bentoml.com" }]
readme = "README.md"
requires-python = ">=3.9"
dependencies = [
"bentoml>=1.3.13",
"bentoml>=1.3.21",
"click>=8.1.7",
"comfy-cli>=1.2.8",
]
+1 -1
View File
@@ -1,4 +1,4 @@
bentoml
bentoml>=1.3.21
fastapi
comfy-cli
duckduckgo-search
+149 -56
View File
@@ -5,6 +5,7 @@ import shutil
import subprocess
import sys
import tempfile
import zipfile
from pathlib import Path
import click
@@ -30,6 +31,7 @@ def _ensure_uv() -> None:
@click.group()
@click.version_option()
def main():
"""comfy-pack CLI"""
pass
@@ -55,27 +57,25 @@ def main():
def init(dir: str, verbose: int):
import os
from rich.console import Console
console = Console()
import rich
# Check if directory path is valid
try:
install_dir = Path(dir).absolute()
if install_dir.exists() and not install_dir.is_dir():
console.print(f"[red]Error: {dir} exists but is not a directory[/red]")
rich.print(f"[red]Error: {dir} exists but is not a directory[/red]")
return 1
# Check if directory is empty or contains ComfyUI
if install_dir.exists():
contents = list(install_dir.iterdir())
if contents and not (install_dir / ".git").exists():
console.print(
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:
console.print(f"[red]Error: Invalid directory path - {str(e)}[/red]")
rich.print(f"[red]Error: Invalid directory path - {str(e)}[/red]")
return 1
# Check git installation
@@ -86,7 +86,7 @@ def init(dir: str, verbose: int):
capture_output=True,
)
except (subprocess.SubprocessError, FileNotFoundError):
console.print("[red]Error: git is not installed or not in PATH[/red]")
rich.print("[red]Error: git is not installed or not in PATH[/red]")
return 1
# Check if we have write permissions
@@ -97,36 +97,36 @@ def init(dir: str, verbose: int):
test_file.touch()
test_file.unlink()
except (OSError, PermissionError) as e:
console.print(f"[red]Error: No write permission in {dir} - {str(e)}[/red]")
rich.print(f"[red]Error: No write permission in {dir} - {str(e)}[/red]")
return 1
# Check if Python version is compatible
if sys.version_info < (3, 8):
console.print("[red]Error: Python 3.8 or higher is required[/red]")
rich.print("[red]Error: Python 3.8 or higher is required[/red]")
return 1
# Check if uv is installed
try:
_ensure_uv()
except RuntimeError as e:
console.print(f"[red]Error: {str(e)}[/red]")
rich.print(f"[red]Error: {str(e)}[/red]")
return 1
# Check if enough disk space is available (rough estimate: 2GB)
try:
free_space = shutil.disk_usage(install_dir).free
if free_space < 2 * 1024 * 1024 * 1024: # 2GB in bytes
console.print(
rich.print(
"[yellow]Warning: Less than 2GB free disk space available[/yellow]"
)
except Exception as e:
console.print(
rich.print(
f"[yellow]Warning: Could not check free disk space - {str(e)}[/yellow]"
)
# Clone ComfyUI if not exists
if not (install_dir / ".git").exists():
console.print("[green]Cloning ComfyUI...[/green]")
rich.print("[green]Cloning ComfyUI...[/green]")
subprocess.run(
[
"git",
@@ -138,7 +138,7 @@ def init(dir: str, verbose: int):
)
# Update ComfyUI
console.print("[green]Updating ComfyUI...[/green]")
rich.print("[green]Updating ComfyUI...[/green]")
subprocess.run(
["git", "pull"],
cwd=install_dir,
@@ -147,7 +147,7 @@ def init(dir: str, verbose: int):
# Create and activate venv
venv_dir = install_dir / ".venv"
console.print("[green]Creating virtual environment with uv...[/green]")
rich.print("[green]Creating virtual environment with uv...[/green]")
if venv_dir.exists():
shutil.rmtree(venv_dir)
subprocess.run(
@@ -163,7 +163,7 @@ def init(dir: str, verbose: int):
python = str(venv_dir / "bin" / "python")
# Install requirements with uv
console.print("[green]Installing ComfyUI requirements with uv...[/green]")
rich.print("[green]Installing ComfyUI requirements with uv...[/green]")
subprocess.run(
["uv", "pip", "install", "pip", "--upgrade"],
env={
@@ -182,7 +182,7 @@ def init(dir: str, verbose: int):
)
# Install comfy-pack as custom node
console.print("[green]Installing comfy-pack custom nodes...[/green]")
rich.print("[green]Installing comfy-pack custom nodes...[/green]")
custom_nodes_dir = install_dir / "custom_nodes"
custom_nodes_dir.mkdir(exist_ok=True)
@@ -224,12 +224,12 @@ def init(dir: str, verbose: int):
)
version = get_self_git_commit() or "unknown"
console.print(
rich.print(
f"\n[green]✓ Installation completed! (comfy-pack version: {version})[/green]"
)
console.print(f"ComfyUI directory: {install_dir}")
rich.print(f"ComfyUI directory: {install_dir}")
console.print(
rich.print(
"\n[green]Next steps:[/green]\n"
f"1. cd {dir}\n"
"2. source .venv/bin/activate # On Windows: .venv\\Scripts\\activate\n"
@@ -241,7 +241,7 @@ def init(dir: str, verbose: int):
name="unpack",
help="Restore the ComfyUI workspace to specified directory",
)
@click.argument("cpack", type=click.Path(exists=True, dir_okay=False))
@click.argument("cpack", type=click.Path(exists=True))
@click.option(
"--dir",
"-d",
@@ -255,24 +255,57 @@ def init(dir: str, verbose: int):
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)",
)
def unpack_cmd(cpack: str, dir: str, include_disabled_models: bool, verbose: int):
from rich.console import Console
@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
console = Console()
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![/green]")
rich.print(f"{dir}")
install(cpack, dir, verbose=verbose, all_models=include_disabled_models)
console.print("\n[green]✓ ComfyUI Workspace is restored![/green]")
console.print(f"{dir}")
console.print(
rich.print(
"\n[green] Next steps: [/green]\n"
"1. Change directory to the restored workspace\n"
"2. Source the virtual environment by running `source .venv/bin/activate`\n"
@@ -281,7 +314,7 @@ def unpack_cmd(cpack: str, dir: str, include_disabled_models: bool, verbose: int
def _print_schema(schema, verbose: int = 0):
from rich.console import Console
import rich
from rich.table import Table
table = Table(title="")
@@ -312,7 +345,7 @@ def _print_schema(schema, verbose: int = 0):
range_str,
)
Console().print(table)
rich.print(table)
@functools.lru_cache
@@ -340,8 +373,8 @@ def _get_cache_workspace(cpack: str):
)
@click.pass_context
def run(ctx, cpack: str, output_dir: str, help: bool, verbose: int):
import rich
from pydantic import ValidationError
from rich.console import Console
from .utils import generate_input_model
@@ -349,35 +382,31 @@ def run(ctx, cpack: str, output_dir: str, help: bool, verbose: int):
zip([k.lstrip("-").replace("-", "_") for k in ctx.args[::2]], ctx.args[1::2])
)
console = Console()
with tempfile.TemporaryDirectory() as temp_dir:
pack_dir = Path(temp_dir) / ".cpack"
shutil.unpack_archive(cpack, pack_dir)
workflow = json.loads((pack_dir / "workflow_api.json").read_text())
with zipfile.ZipFile(cpack) as z:
workflow = json.loads(z.read("workflow_api.json"))
input_model = generate_input_model(workflow)
# If help is requested, show command help and input schema
if help:
console.print(
rich.print(
'Usage: comfy-pack run [OPTIONS] CPACK --input1 "value1" --input2 "value2" ...'
)
console.print("Run a ComfyUI package with the given inputs:")
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)
console.print("[green]✓ Input is valid![/green]")
rich.print("[green]✓ Input is valid![/green]")
for field, value in validated_data.model_dump().items():
console.print(f"{field}: {value}")
rich.print(f"{field}: {value}")
except ValidationError as e:
console.print("[red]✗ Validation failed![/red]")
rich.print("[red]✗ Validation failed![/red]")
for error in e.errors():
console.print(f"- {error['loc'][0]}: {error['msg']}")
rich.print(f"- {error['loc'][0]}: {error['msg']}")
console.print("\n[yellow]Expected inputs:[/yellow]")
rich.print("\n[yellow]Expected inputs:[/yellow]")
_print_schema(input_model.model_json_schema(), verbose)
return 1
@@ -385,44 +414,45 @@ def run(ctx, cpack: str, output_dir: str, help: bool, verbose: int):
workspace = _get_cache_workspace(cpack)
if not (workspace / "DONE").exists():
console.print("\n[green]✓ Restoring ComfyUI Workspace...[/green]")
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")
console.print("\n[green]✓ ComfyUI Workspace is restored![/green]")
console.print(f"{workspace}")
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:
console.print("\n[green]✓ ComfyUI is launched in the background![/green]")
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(),
)
console.print("\n[green]✓ Workflow is executed successfully![/green]")
rich.print("\n[green]✓ Workflow is executed successfully![/green]")
if results:
console.print("\n[green]✓ Retrieved outputs:[/green]")
rich.print("\n[green]✓ Retrieved outputs:[/green]")
if isinstance(results, dict):
for field, value in results.items():
console.print(f"{field}: {value}")
rich.print(f"{field}: {value}")
elif isinstance(results, list):
for i, value in enumerate(results):
console.print(f"{i}: {value}")
rich.print(f"{i}: {value}")
else:
console.print(results)
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")
@click.argument("source")
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
@@ -439,10 +469,17 @@ def bento_cmd(source: str, name: str | None, version: str | None):
else:
existing_bento = bentoml.get(source)
name = name or existing_bento.tag.name
shutil.copytree(existing_bento.path, temp_dir, dirs_exist_ok=True)
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
@@ -453,3 +490,59 @@ def bento_cmd(source: str, name: str | None, version: str | None):
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",
)
+6 -8
View File
@@ -1,8 +1,9 @@
from .const import MODEL_SOURCE_CACHE_FILE
import asyncio
import json
import re
from .const import MODEL_SOURCE_CACHE_FILE
# MODEL_NAME = r"[a-zA-Z0-9-._]+"
# COMMIT = r"[a-f0-9]+"
@@ -14,9 +15,8 @@ PATH_PATTERN = re.compile(
async def _lookup_huggingface_model(model_sha: str) -> dict:
from duckduckgo_search import DDGS
import aiohttp
from duckduckgo_search import DDGS
query = f"site:huggingface.co blob {model_sha}"
try:
@@ -25,7 +25,7 @@ async def _lookup_huggingface_model(model_sha: str) -> dict:
async with aiohttp.ClientSession(trust_env=True) as session:
for result in search_results:
url = result['link']
url = result['href']
if "blob" not in url:
continue
@@ -38,11 +38,9 @@ async def _lookup_huggingface_model(model_sha: str) -> dict:
repo, commit = commit_match.groups()
if path_match := PATH_PATTERN.search(text):
path = path_match.group(1)
download_url = f"https://huggingface.co/{repo}/resolve/{commit}/{path}?download=true"
url = f"https://huggingface.co/{repo}/blob/{commit}/{path}"
info = {
"download_url": download_url,
"url": url,
"download_url": path,
"url": path,
"repo": repo,
"commit": commit,
"path": path,
+136 -68
View File
@@ -1,5 +1,6 @@
from __future__ import annotations
import contextlib
import json
import os
import shutil
@@ -25,28 +26,33 @@ COMFY_PACK_DIR = Path(__file__).parent
def _clone_commit(url: str, commit: str, dir: Path, verbose: int = 0):
stdout = None if verbose > 0 else subprocess.DEVNULL
stderr = None if verbose > 1 else subprocess.DEVNULL
env = {**os.environ, "GIT_TERMINAL_PROMPT": "0"}
subprocess.check_call(
["git", "clone", "--recurse-submodules", "--filter=blob:none", url, dir],
stdout=stdout,
stderr=stderr,
env=env,
)
subprocess.check_call(
["git", "fetch", "-q", url, commit],
cwd=dir,
stdout=stdout,
stderr=stderr,
env=env,
)
subprocess.check_call(
["git", "checkout", "FETCH_HEAD"],
cwd=dir,
stdout=stdout,
stderr=stderr,
env=env,
)
subprocess.check_call(
["git", "submodule", "update", "--init", "--recursive"],
cwd=dir,
stdout=stdout,
stderr=stderr,
env=env,
)
@@ -61,6 +67,18 @@ def install_comfyui(snapshot, workspace: Path, verbose: int = 0):
return
shutil.rmtree(workspace)
_clone_commit(COMFYUI_REPO, comfyui_commit, workspace, verbose=verbose)
manager_node = next(
url for url in snapshot.get("git_custom_nodes", {}) if "ComfyUI-Manager" in url
)
if manager_node:
manager_commit = snapshot["git_custom_nodes"][manager_node]["hash"].strip()
print("Installing ComfyUI-Manager")
_clone_commit(
manager_node,
manager_commit,
workspace / "custom_nodes" / "ComfyUI-Manager",
verbose=verbose,
)
with open(workspace / ".DONE", "w") as f:
f.write(comfyui_commit)
@@ -83,6 +101,7 @@ def install_custom_modules(snapshot, workspace: Path, verbose: int = 0):
continue
shutil.rmtree(module_dir)
print(f"Installing custom node {url}")
commit_hash = module["commit_hash"]
_clone_commit(url, commit_hash, module_dir, verbose=verbose)
@@ -117,39 +136,39 @@ def install_custom_modules(snapshot, workspace: Path, verbose: int = 0):
def install_dependencies(
snapshot: dict,
req_file: str,
python_version: str,
req_files: list[str],
workspace: Path,
verbose: int = 0,
):
no_deps: bool = False,
no_venv: bool = False,
) -> Path:
print("Installing Python dependencies")
python_version = snapshot["python"]
stdout = None if verbose > 0 else subprocess.DEVNULL
stderr = None if verbose > 1 else subprocess.DEVNULL
subprocess.check_call(
["uv", "python", "install", python_version],
cwd=workspace,
stdout=stdout,
stderr=stderr,
)
venv = workspace / ".venv"
if (venv / "DONE").exists():
return
venv_py = (
venv / "Scripts" / "python.exe" if os.name == "nt" else venv / "bin" / "python"
)
subprocess.check_call(
[
"uv",
"venv",
"--python",
python_version,
venv,
],
stdout=stdout,
stderr=stderr,
)
if no_venv:
print("Using the current Python environment")
venv_py = Path(sys.executable)
else:
venv = workspace / ".venv"
venv_py = (
venv / "Scripts" / "python.exe"
if os.name == "nt"
else venv / "bin" / "python"
)
if (venv / "DONE").exists():
return venv_py
subprocess.check_call(
[
"uv",
"venv",
"--python",
python_version,
venv,
],
stdout=stdout,
stderr=stderr,
)
subprocess.check_call(
[
"uv",
@@ -163,28 +182,28 @@ def install_dependencies(
stderr=stderr,
)
if verbose > 0:
print(f"Installing dependencies from {req_file}")
print(f"Installing dependencies from {req_files}")
install_cmd = [
"uv",
"pip",
"install",
"-p",
str(venv_py),
"-r",
req_file,
"--no-deps",
]
if STRICT_MODE:
pass
else:
for req_file in req_files:
install_cmd.extend(["-r", str(req_file)])
if not STRICT_MODE:
install_cmd.extend(["--index-strategy", "unsafe-best-match"])
if no_deps:
install_cmd.append("--no-deps")
subprocess.check_call(
install_cmd,
stdout=stdout,
stderr=stderr,
)
with open(venv / "DONE", "w") as f:
f.write("DONE")
if not no_venv:
with open(venv / "DONE", "w") as f:
f.write("DONE")
return venv_py
@@ -199,13 +218,23 @@ def get_search_url(sha: str) -> str:
def download_file(url: str, dest_path: Path, progress_callback=None):
"""Download file with progress tracking"""
if subprocess.call(["curl", "--version"], stdout=subprocess.DEVNULL) == 0:
subprocess.check_call(
["curl", "-L", url, "-o", str(dest_path)],
)
return True
# prepare auth token from huggingface if possible
if (token := os.getenv("HF_TOKEN")) and ("huggingface" in url):
bearer = f"Bearer {token}"
urllib_request = urllib.request.Request(url, headers={"Authorization": bearer})
curl_auth = ["-H", f"Authorization: {bearer}"]
else:
curl_auth = []
urllib_request = url
try:
with urllib.request.urlopen(url) as response:
if shutil.which("curl"):
subprocess.check_call(
["curl", "-L", url, *curl_auth, "--fail", "-o", str(dest_path)],
)
return True
with urllib.request.urlopen(urllib_request) as response:
total_size = int(response.headers.get("content-length", 0))
block_size = 8192
downloaded = 0
@@ -382,49 +411,82 @@ def retrieve_models(
def install(
cpack: str | Path,
workspace: str | Path = "workspace",
preheat: bool = True,
preheat: bool = False,
prepare_models: bool = True,
all_models: bool = False,
no_venv: bool = False,
verbose: int = 0,
):
workspace = Path(workspace)
cpack = Path(cpack)
print(f"Installing package {cpack} to {workspace} (verbose={verbose})")
with tempfile.TemporaryDirectory() as temp_dir:
pack_dir = Path(temp_dir) / ".cpack"
shutil.unpack_archive(cpack, pack_dir)
with contextlib.ExitStack() as stack:
if cpack.is_file():
temp_dir = stack.enter_context(tempfile.TemporaryDirectory())
pack_dir = Path(temp_dir) / ".cpack"
shutil.unpack_archive(cpack, pack_dir)
else:
pack_dir = cpack
snapshot = json.loads((pack_dir / "snapshot.json").read_text())
req_txt_file = pack_dir / "requirements.txt"
if "pips" not in snapshot:
raise RuntimeError(
"This cpack is generated by an old version of comfy-pack, "
"please use comfy-pack<0.4.0 to unpack it."
)
install_comfyui(snapshot, workspace, verbose=verbose)
install_dependencies(snapshot, str(req_txt_file), workspace, verbose=verbose)
py = install_dependencies(
snapshot["python"],
[
str(workspace / "requirements.txt"),
str(
workspace / "custom_nodes" / "ComfyUI-Manager" / "requirements.txt"
),
],
workspace,
no_venv=no_venv,
verbose=verbose,
)
cm_cli = workspace / "custom_nodes" / "ComfyUI-Manager" / "cm-cli.py"
subprocess.check_call(
[
str(py),
str(cm_cli),
"restore-snapshot",
"--pip-non-url",
"--pip-non-local-url",
str(pack_dir / "snapshot.json"),
],
cwd=workspace,
)
for f in (pack_dir / "input").glob("*"):
if f.is_file():
shutil.copy(f, workspace / "input" / f.name)
elif f.is_dir():
shutil.copytree(f, workspace / "input" / f.name, dirs_exist_ok=True)
if prepare_models:
retrieve_models(
snapshot,
workspace,
verbose=verbose,
download=False,
)
retrieve_models(
snapshot,
workspace,
verbose=verbose,
download=False,
)
install_custom_modules(snapshot, workspace, verbose=verbose)
if preheat:
from .run import ComfyUIServer
with ComfyUIServer(
str(workspace),
verbose=verbose,
venv=str(workspace / ".venv"),
venv=str(workspace / ".venv") if not no_venv else None,
) as _:
pass
retrieve_models(snapshot, workspace, verbose=verbose, all_models=all_models)
if prepare_models:
retrieve_models(snapshot, workspace, verbose=verbose, all_models=all_models)
required_files = ["snapshot.json", "requirements.txt"]
required_files = ["snapshot.json"]
def build_bento(
@@ -456,11 +518,14 @@ def build_bento(
system_packages = system_packages or []
shutil.copy2(Path(__file__).with_name("service.py"), source_dir / "service.py")
# Copy comfy-pack package
shutil.copytree(
COMFY_PACK_DIR, source_dir / COMFY_PACK_DIR.name, dirs_exist_ok=True
)
snapshot = json.loads((source_dir / "snapshot.json").read_text())
snapshot_text = (source_dir / "snapshot.json").read_text()
setup_script = source_dir / "setup_workspace.sh"
with Path(__file__).with_name("setup_workspace.sh").open() as f:
setup_script.write_text(f.read().replace("<SNAPSHOT>", snapshot_text))
# Make setup script executable in a cross-platform way
if os.name in ("posix", "mac"):
setup_script.chmod(setup_script.stat().st_mode | 0o755)
snapshot = json.loads(snapshot_text)
return bentoml.build(
"service:ComfyService",
name=bento_name,
@@ -475,7 +540,10 @@ def build_bento(
docker={
"python_version": f"{sys.version_info.major}.{sys.version_info.minor}",
"system_packages": system_packages,
"setup_script": Path(__file__).with_name("setup_workspace.py").as_posix(),
"setup_script": source_dir.joinpath("setup_workspace.sh").as_posix(),
},
python={
"lock_packages": True,
"packages": ["fastapi", "comfy-pack>=0.4.0a0"],
},
python={"requirements_txt": "requirements.txt", "lock_packages": True},
)
+4 -1
View File
@@ -81,7 +81,7 @@ class ComfyUIServer:
self.port = 8188
else:
self.port = port
self.venv = venv
self.venv = os.path.abspath(venv) if venv else None
def start(self) -> None:
"""
@@ -206,6 +206,7 @@ def run_workflow(
output_dir: Union[str, Path, None] = None,
timeout: int = 300,
verbose: int = 0,
workspace: str = ".",
**kwargs: Any,
) -> Any:
"""
@@ -258,6 +259,8 @@ def run_workflow(
command = [
"comfy",
"--skip-prompt",
"--workspace",
workspace,
"run",
"--workflow",
workflow_file_path.as_posix(),
+3 -9
View File
@@ -40,7 +40,7 @@ def _get_workspace() -> Path:
from bentoml._internal.configuration.containers import BentoMLContainer
snapshot = BASE_DIR / "snapshot.json"
checksum = hashlib.md5(snapshot.read_bytes()).hexdigest()
checksum = hashlib.md5(snapshot.read_bytes().strip()).hexdigest()
wp = (
Path(BentoMLContainer.bentoml_home.get()) / "run" / "comfy_workspace" / checksum
)
@@ -73,7 +73,7 @@ else:
snapshot = {}
@bentoml.mount_asgi_app(app, path="/comfy")
@bentoml.asgi_app(app, path="/comfy")
@bentoml.service(traffic={"timeout": REQUEST_TIMEOUT * 2}, resources={"gpu": 1})
class ComfyService:
def __init__(self):
@@ -122,19 +122,13 @@ class ComfyService:
output_dir=ctx.temp_dir,
timeout=REQUEST_TIMEOUT,
verbose=verbose,
workspace=self.server.workspace if hasattr(self, "server") else ".",
**kwargs,
)
if isinstance(ret, list):
ret = ret[-1]
return ret
@bentoml.on_shutdown
def on_shutdown(self):
logger.info("Shutting down")
if not EXISTING_COMFYUI_SERVER:
self.watch_thread.join()
logger.info("Watch thread finished")
@bentoml.on_deployment
@staticmethod
def prepare_models():
-65
View File
@@ -1,65 +0,0 @@
#!/usr/bin/env python3
# ruff: noqa: E402
from __future__ import annotations
import os
import subprocess
import sys
from pathlib import Path
virtualenv = os.environ.get("VIRTUAL_ENV")
if virtualenv and "--reload" not in sys.argv:
print("Re-executing in virtualenv:", virtualenv)
venv_python = os.path.join(virtualenv, "bin/python3")
os.execl(venv_python, venv_python, *sys.argv, "--reload")
# The script path is ./env/docker/setup_script
SRC_DIR = Path(__file__).parent.parent.parent / "src"
INPUT_DIR = SRC_DIR / "input"
sys.path.append(str(SRC_DIR))
def _get_workspace() -> tuple[Path, dict]:
import hashlib
import json
from bentoml._internal.configuration.containers import BentoMLContainer
snapshot = SRC_DIR / "snapshot.json"
checksum = hashlib.md5(snapshot.read_bytes()).hexdigest()
wp = (
Path(BentoMLContainer.bentoml_home.get()) / "run" / "comfy_workspace" / checksum
)
wp.parent.mkdir(parents=True, exist_ok=True)
return wp, json.loads(snapshot.read_text())
def prepare_comfy_workspace():
import shutil
from comfy_pack.package import install_comfyui, install_custom_modules
verbose = int("BENTOML_DEBUG" in os.environ)
comfy_workspace, snapshot = _get_workspace()
if not comfy_workspace.joinpath(".DONE").exists():
if comfy_workspace.exists():
print("Removing existing workspace")
shutil.rmtree(comfy_workspace, ignore_errors=True)
install_comfyui(snapshot, comfy_workspace, verbose=verbose)
for f in INPUT_DIR.glob("*"):
if f.is_file():
shutil.copy(f, comfy_workspace / "input" / f.name)
elif f.is_dir():
shutil.copytree(f, comfy_workspace / "input" / f.name)
install_custom_modules(snapshot, comfy_workspace, verbose=verbose)
comfy_workspace.joinpath(".DONE").touch()
subprocess.run(
["chown", "-R", "bentoml:bentoml", str(comfy_workspace)], check=True
)
if __name__ == "__main__":
prepare_comfy_workspace()
+23
View File
@@ -0,0 +1,23 @@
#!/bin/bash
set -eo pipefail
CPACK=/tmp/bento-cpack
mkdir -p "$CPACK"
cat <<EOF | head -c -1 > "$CPACK"/snapshot.json
<SNAPSHOT>
EOF
checksum=$(md5sum "$CPACK"/snapshot.json | awk '{print $1}')
workspace="${BENTOML_HOME:-$HOME/bentoml}/run/comfy_workspace/${checksum}"
if [ -n "$VIRTUAL_ENV" ]; then
# shellcheck disable=SC1091
source "$VIRTUAL_ENV"/bin/activate
fi
set -x
comfy-pack unpack "$CPACK" -d "$workspace" --no-models --no-venv -v
chown -R bentoml:bentoml "$workspace"
set +x
+39 -20
View File
@@ -1,18 +1,15 @@
from __future__ import annotations
import subprocess
from pathlib import Path
import re
import subprocess
import sys
import zipfile
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, Union
if TYPE_CHECKING:
from pydantic import BaseModel
CPACK_OUTPUT_NODES = {
"CPackOutputFile",
"CPackOutputImage",
}
CPACK_PATH_INPUT_NODES = {
"CPackInputFile",
@@ -210,7 +207,7 @@ def populate_workflow(
for _, node in output_spec.items():
node_id = node["id"]
if node["class_type"] in CPACK_OUTPUT_NODES:
if node["class_type"].startswith("CPackOutput"):
workflow[node_id]["inputs"]["filename_prefix"] = (
output_path / f"{session_id}{node_id}_"
).as_posix()
@@ -221,7 +218,7 @@ def retrieve_workflow_outputs(
workflow: dict,
output_path: Path,
session_id: str = "",
) -> Union[Path, list[Path], dict[str, Path], dict[str, list[Path]]]:
) -> Union[Path, list[Path], dict[str, Path | list[Path]]]:
"""
Gets the output file(s) from the workflow.
@@ -230,7 +227,7 @@ def retrieve_workflow_outputs(
output_path (Path): The path where output files are saved.
Returns:
Union[Path, list[Path], dict[str, Path], dict[str, list[Path]]]:
Union[Path, list[Path], dict[str, Path | list[Path]]]:
- A single Path if there's only one output file.
- A list of Paths if there are multiple files for a single output.
- A dictionary mapping output names to Paths or lists of Paths for multiple outputs.
@@ -239,26 +236,48 @@ def retrieve_workflow_outputs(
ValueError: If the output node is not of the expected type.
"""
_, outputs = _parse_workflow(workflow)
should_zip = any(
node["class_type"] == "CPackOutputZipSwitch" for node in workflow.values()
)
print("Should create zip:", should_zip, outputs, workflow)
zip_paths: list[tuple[Path, str]] = []
if len(outputs) != 1:
value_map = {}
for k, node in outputs.items():
node_id = node["id"]
path_strs = list(output_path.glob(f"{node_id}_*"))
path_strs = list(output_path.glob(f"{session_id}{node_id}_*"))
zip_paths.extend(
(p, p.name.replace(f"{session_id}{node_id}", k)) for p in path_strs
)
if len(path_strs) == 1:
value_map[k] = path_strs[0]
else:
value_map[k] = path_strs
return value_map
if not should_zip:
return value_map
else:
name, node = next(iter(outputs.items()))
if not node["class_type"].startswith("CPackOutput"):
raise ValueError(f"Node {name} is not a comfy-pack output node")
node_id = node["id"]
name, node = next(iter(outputs.items()))
if node["class_type"] not in CPACK_OUTPUT_NODES:
raise ValueError(f"Output node {name} is not of type {CPACK_OUTPUT_NODES}")
node_id = node["id"]
outs = list(output_path.glob(f"{session_id}{node_id}_*"))
if len(outs) == 1:
return outs[0]
return outs
outs = list(output_path.glob(f"{session_id}{node_id}_*"))
zip_paths.extend(
(p, p.name.replace(f"{session_id}{node_id}", name)) for p in outs
)
if not should_zip:
if len(outs) == 1:
return outs[0]
return outs
if len(zip_paths) == 1:
return zip_paths[0][0]
# Make a zipball from the collected files
output_zip = output_path / f"{session_id}_output.zip"
print(f"Creating zip file: {output_zip}")
with zipfile.ZipFile(output_zip, "w", zipfile.ZIP_DEFLATED) as zipf:
for path, name in zip_paths:
zipf.write(path, arcname=name)
return output_zip
def get_self_git_commit() -> str | None:
Generated
+1170 -1184
View File
File diff suppressed because it is too large Load Diff
+38 -38
View File
@@ -247,7 +247,7 @@ class FileTreeList {
try {
const { workflow, output: workflow_api } = await app.graphToPrompt();
const files = await this.getInputFiles(workflow, workflow_api);
// 预先将默认选中的文件添加到选中列表
files.forEach(file => {
const path = file.path || file;
@@ -255,14 +255,14 @@ class FileTreeList {
this.state.toggle(path, true);
}
});
this.renderTree(this.buildTree(files));
// 更新所有父目录的状态
this.container.querySelectorAll("[data-action='check-dir']").forEach(checkbox => {
this.updateFolderState(checkbox);
});
// 更新总数
this.updateCount();
} catch(e) {
@@ -282,12 +282,12 @@ class FileTreeList {
buildTree(files) {
const root = { name: 'root', children: {}, files: [] };
for (const file of files) {
const filePath = file.path || file;
const parts = filePath.split(/[\/\\]/);
let current = root;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (i === parts.length - 1) {
@@ -313,24 +313,24 @@ class FileTreeList {
}
}
}
// 对目录按名称排序
for (const dir in root.children) {
root.children[dir].files.sort((a, b) => a.name.localeCompare(b.name));
}
return root;
}
renderTree(node, level = 0) {
const dirs = Object.values(node.children);
const hasChildren = dirs.length > 0 || node.files.length > 0;
this.container.innerHTML = this.renderNode(node, true);
this.setupEventListeners();
this.updateCount();
}
renderNode(node, isRoot = false) {
@@ -354,35 +354,35 @@ class FileTreeList {
if (hasChildren) {
html += `<div class="cpack-tree-children" style="display: ${isRoot ? '' : 'none'}">`;
// 渲染子目录
for (const dir of dirs) {
html += this.renderNode(dir);
}
// 渲染文件
for (const file of node.files) {
html += `
<div class="cpack-tree-item">
<label style="padding-left: 17px">
<input type="checkbox" name="files" value="${file.path}"
<input type="checkbox" name="files" value="${file.path}"
${this.state.isSelected(file.path) || file.checked ? 'checked' : ''} />
<span>${file.name}</span>
${file.badges ? file.badges.map(badge =>
`<span style="background: ${badge.color || '#00a67d33'};
color: ${badge.textColor || '#00a67d'};
padding: 2px 6px;
border-radius: 4px;
font-size: 0.8em;
${file.badges ? file.badges.map(badge =>
`<span style="background: ${badge.color || '#00a67d33'};
color: ${badge.textColor || '#00a67d'};
padding: 2px 6px;
border-radius: 4px;
font-size: 0.8em;
cursor: help;
white-space: nowrap;"
white-space: nowrap;"
title="${badge.tooltip || ''}">${badge.text}</span>`
).join('') : ''}
</label>
</div>
`;
}
html += '</div>';
}
@@ -422,7 +422,7 @@ class FileTreeList {
}
setupEventListeners() {
// 目录选择功能
this.container.querySelectorAll("[data-action='check-dir']").forEach(checkbox => {
@@ -433,7 +433,7 @@ class FileTreeList {
const childFiles = Array.from(children.querySelectorAll("input[name='files']"))
.map(input => input.value);
this.state.toggleMultiple(childFiles, e.target.checked);
// 更新UI
children.querySelectorAll("input[type='checkbox']").forEach(child => {
child.checked = e.target.checked;
@@ -442,7 +442,7 @@ class FileTreeList {
}
});
}
});
});
@@ -464,7 +464,7 @@ class FileTreeList {
this.container.querySelectorAll("input[name='files']").forEach(checkbox => {
checkbox.addEventListener('change', (e) => {
this.state.toggle(e.target.value, e.target.checked);
// 更新父文件夹状态
const parentFolder = checkbox.closest('.cpack-tree-children')
?.previousElementSibling
@@ -477,7 +477,7 @@ class FileTreeList {
}
updateCount() {
const countSpan = document.querySelector(`[data-files-count='${this.countId}']`);
if (countSpan) {
countSpan.textContent = this.state.getSelectedCount();
@@ -616,7 +616,7 @@ class ModelList {
// 只返回用户选中的模型
return Array.from(this.container.querySelectorAll("input[name='models']:checked"))
.map(input => input.value);
}
}
@@ -679,7 +679,7 @@ async function createPackModal() {
const packageOptionsContainer = form.querySelector("#package-options-container");
const packageOptions = new PackageOptions(form, "pack-models-list", "pack-files-list", true);
packageOptionsContainer.innerHTML = packageOptions.getHtml();
packageOptions.init().then(() => {
confirmButton.disabled = false;
});
@@ -775,7 +775,7 @@ async function unpackAction() {
content.innerHTML = `
<div style="margin-bottom: 20px;">
<p style="margin-bottom: 15px;">To unpack a workflow package, follow these steps:</p>
<div style="margin-bottom: 15px;">
<p style="margin-bottom: 10px;">1. Install comfy-pack CLI:</p>
<div class="cpack-copyable" style="margin: 10px 0;">
@@ -804,13 +804,13 @@ async function unpackAction() {
const closeButton = document.createElement("button");
closeButton.textContent = "Close";
closeButton.className = "cpack-btn";
buttonContainer.appendChild(closeButton);
modal.appendChild(title);
modal.appendChild(content);
modal.appendChild(buttonContainer);
const { close } = createModal(modal);
closeButton.onclick = close;
}
@@ -1001,13 +1001,13 @@ class PackageOptions {
async init() {
const modelsList = this.container.querySelector(`#${this.modelsListId}`);
const filesList = this.container.querySelector(`#${this.filesListId}`);
this.modelListComponent = new ModelList(modelsList, this.modelsListId);
this.fileListComponent = new FileTreeList(filesList, this.filesListId);
const addButton = this.container.querySelector("#add-button");
const systemPackagesArray = this.container.querySelector("#system-packages-array");
addButton.addEventListener("click", (e) => {
e.preventDefault();
const row = document.createElement("div");
@@ -1063,7 +1063,7 @@ const buildForm = `
<p style="font-size: 0.85em; color: #888; margin-top: 5px;">
Get your API Token at <a href="https://cloud.bentoml.com/signup?from=comfy-pack" target="_blank" style="color: #00a67d;">cloud.bentoml.com</a>
</p>
</div>
</div>
@@ -1112,7 +1112,7 @@ function createBuildModal() {
const packageOptionsContainer = form.querySelector("#package-options-container");
const packageOptions = new PackageOptions(form, "build-models-list", "build-files-list", false);
packageOptionsContainer.innerHTML = packageOptions.getHtml();
packageOptions.init().then(() => {
confirmButton.disabled = false;
});
@@ -1234,7 +1234,7 @@ async function createServeStatusModal(url) {
// Start status checking
const checkInterval = setInterval(async () => {
try {
const resp = await api.fetchApi("/bentoml/serve/heartbeat", { method: "POST" });
const resp = await api.fetchApi("/bentoml/serve/heartbeat");
const status = await resp.json();
if (status.error) {
title.textContent = "Server Error";