Compare commits

...
39 Commits
Author SHA1 Message Date
Sebastian ElsnerandFrost Ming 705e82af4a fix: Model hashing fixes (#62)
* make sure hashing is done only when the models are selected

* fix cache file not being written, if it did not exist

---------

Co-authored-by: Frost Ming <me@frostming.com>
2025-11-10 09:42:29 +08:00
Michael 521aca76ee feat: COMFYUI_PATH setting for externally-managed ComfyUI (#79)
* configurable COMFYUI_PATH

* Revert "configurable COMFYUI_PATH"

This reverts commit 43628ed3ceee9dd39f249bcde283b2b65994be49.

* move `COMFYUI_PATH` override to bento constructor
2025-10-14 09:12:04 +08:00
Michael d3fe79b996 use absolute venv path in dependency installation (#78) 2025-10-11 14:48:31 +08:00
halfaipg 5f9c5f5dad Fix path parsing issue in _get_models function (#77)
- Clean escaped characters and quotes from git ls-files output
- Add safety check to skip non-existent files
- Fixes FileNotFoundError when model paths contain escaped characters

Resolves issue where comfy-pack API would fail with:
FileNotFoundError: [Errno 2] No such file or directory: '.../models/loras/wan/wan\\WAN2.1_...'
2025-09-25 09:34:35 +08:00
Frost Ming b44f81e4d1 feat: Implement audio and video output nodes with save functionality (#76)
Signed-off-by: Frost Ming <me@frostming.com>
2025-08-29 09:35:43 +08:00
Frost Ming 22114ebfa6 feat: Support COMFYUI_OPTIONS env var for extra CLI args (#72) 2025-08-13 15:08:47 +08:00
Frost Ming 062ddc0155 fix: Remove debug print statement from retrieve_workflow_outputs (#71) 2025-08-13 08:53:55 +08:00
Frost Ming 9e5a29a3f1 fix: incorrect script path of cm-cli.py (#70)
* fix: Bug: comfy-pack Constructs Incorrect Paths for cm-cli.py
Fixes #69

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

* fix: Improve unpack command output for restored workspace

Signed-off-by: Frost Ming <me@frostming.com>
2025-07-23 12:10:15 +08:00
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
18 changed files with 2072 additions and 1561 deletions
+2
View File
@@ -161,3 +161,5 @@ cython_debug/
# option (not recommended) you can uncomment the following to ignore the entire idea folder. # option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/ #.idea/
/temp/ /temp/
src/.DS_Store
.DS_Store
+7
View File
@@ -73,6 +73,13 @@ To unpack:
comfy-pack unpack workflow.cpack.zip 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/). For example cpack files, check our [examples folder](examples/).
### Deploy a workflow as an API ### Deploy a workflow as an API
+2 -3
View File
@@ -1,8 +1,7 @@
import sys
import pathlib import pathlib
import sys
SRC_DIR = pathlib.Path(__file__).parent.parent / "src" SRC_DIR = pathlib.Path(__file__).parent.parent / "src"
if str(SRC_DIR) not in sys.path: if str(SRC_DIR) not in sys.path:
sys.path.append(str(SRC_DIR)) sys.path.insert(0, str(SRC_DIR))
+83 -96
View File
@@ -11,68 +11,78 @@ import tempfile
import time import time
import uuid import uuid
import zipfile import zipfile
from importlib.metadata import Distribution, distributions from fnmatch import fnmatch
from pathlib import Path from pathlib import Path
from typing import Any, Union from typing import Any, Union
import folder_paths
from aiohttp import web from aiohttp import web
from server import PromptServer
from comfy_pack.hash import async_batch_get_sha256 from comfy_pack.hash import async_batch_get_sha256
from comfy_pack.model_helper import alookup_model_source from comfy_pack.model_helper import alookup_model_source
from comfy_pack.package import build_bento from comfy_pack.package import build_bento
import folder_paths
from server import PromptServer
ZPath = Union[Path, zipfile.Path] ZPath = Union[Path, zipfile.Path]
TEMP_FOLDER = Path(__file__).parent.parent / "temp" TEMP_FOLDER = Path(__file__).parent.parent / "temp"
COMFY_PACK_DIR = Path(__file__).parent.parent / "src" / "comfy_pack" 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: def normalize_name(name: str) -> str:
direct_url_text = dist.read_text("direct_url.json") import re
pinned_str = f'{dist.metadata["Name"]}=={dist.version}'
if not direct_url_text: return re.sub(r"[-_.]+", "-", name).lower()
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
async def _write_requirements(path: ZPath, extras: list[str] | None = None) -> None: def get_snapshot_path() -> Path | None:
print("Package => Writing requirements.txt") manager_file_path = Path(
with path.joinpath("requirements.txt").open("w") as f: folder_paths.get_user_directory(), "default", "ComfyUI-Manager"
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
) )
stdout, _ = await proc.communicate() return manager_file_path / "snapshots"
if models is None:
print("Package => Writing models")
models = await _get_models() 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: with path.joinpath("snapshot.json").open("w") as f:
data = { snapshot.update(
"python": f"{sys.version_info.major}.{sys.version_info.minor}", {
"comfyui": stdout.decode().strip(), "python": f"{sys.version_info.major}.{sys.version_info.minor}",
"models": models, "models": models,
"custom_nodes": await _get_custom_nodes(), }
} )
f.write(json.dumps(data, indent=2)) f.write(json.dumps(snapshot, indent=2))
def _is_port_in_use(port: int | str, host="localhost"): def _is_port_in_use(port: int | str, host="localhost"):
@@ -122,16 +132,31 @@ async def _get_models(
models = [] models = []
model_filenames = [ model_filenames = [
os.path.abspath(line) os.path.abspath(line.strip().strip('"').replace('\\\\', '/'))
for line in stdout.decode().splitlines() for line in stdout.decode().splitlines()
if not os.path.basename(line).startswith(".") if not os.path.basename(line.strip().strip('"').replace('\\\\', '/')).startswith(".")
] ]
# Only compute hashes for referenced models
to_include = []
if model_filter:
for m1 in model_filter:
for m2 in model_filenames:
if m1 in m2:
to_include.append(m2)
else:
to_include = model_filenames
model_hashes = await async_batch_get_sha256( model_hashes = await async_batch_get_sha256(
model_filenames, to_include,
cache_only=not (ensure_sha or store_models), cache_only=not (ensure_sha or store_models),
) )
for filename in model_filenames: for filename in to_include:
# Skip if file doesn't exist
if not os.path.exists(filename):
continue
relpath = os.path.relpath(filename, folder_paths.base_path) relpath = os.path.relpath(filename, folder_paths.base_path)
model_data = { model_data = {
@@ -158,7 +183,7 @@ async def _get_models(
if should_store: if should_store:
import bentoml import bentoml
model_tag = f'cpack-model:{model_data["sha256"][:16]}' model_tag = f"cpack-model:{model_data['sha256'][:16]}"
try: try:
model = bentoml.models.get(model_tag) model = bentoml.models.get(model_tag)
except bentoml.exceptions.NotFound: except bentoml.exceptions.NotFound:
@@ -174,47 +199,6 @@ async def _get_models(
return 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: async def _write_workflow(path: ZPath, data: dict) -> None:
print("Package => Writing workflow") print("Package => Writing workflow")
with path.joinpath("workflow_api.json").open("w") as f: with path.joinpath("workflow_api.json").open("w") as f:
@@ -277,6 +261,8 @@ class DevServer:
@classmethod @classmethod
def start(cls, workflow_api: dict, port: int = 3000): def start(cls, workflow_api: dict, port: int = 3000):
from comfy_pack import __file__ as comfy_pack_file
cls.stop() cls.stop()
cls.port = port cls.port = port
@@ -285,7 +271,7 @@ class DevServer:
with cls.run_dir.joinpath("workflow_api.json").open("w") as f: with cls.run_dir.joinpath("workflow_api.json").open("w") as f:
f.write(json.dumps(workflow_api, indent=2)) f.write(json.dumps(workflow_api, indent=2))
shutil.copy( shutil.copy(
Path(__file__).with_name("service.py"), Path(comfy_pack_file).with_name("service.py"),
cls.run_dir / "service.py", cls.run_dir / "service.py",
) )
shutil.copytree(COMFY_PACK_DIR, cls.run_dir / COMFY_PACK_DIR.name) shutil.copytree(COMFY_PACK_DIR, cls.run_dir / COMFY_PACK_DIR.name)
@@ -436,7 +422,7 @@ async def serve(request):
) )
@PromptServer.instance.routes.post("/bentoml/serve/heartbeat") @PromptServer.instance.routes.get("/bentoml/serve/heartbeat")
async def heartbeat(_): async def heartbeat(_):
running = DevServer.feed_watch_dog() running = DevServer.feed_watch_dog()
@@ -462,14 +448,15 @@ async def _prepare_pack(
working_dir: ZPath, working_dir: ZPath,
data: dict, data: dict,
store_models: bool = False, store_models: bool = False,
ensure_source: bool = True,
) -> None: ) -> None:
model_filter = set(data.get("models", [])) model_filter = set(data.get("models", []))
models = await _get_models( models = await _get_models(
store_models=store_models, store_models=store_models,
model_filter=model_filter, 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_snapshot(working_dir, data, models)
await _write_workflow(working_dir, data) await _write_workflow(working_dir, data)
await _write_inputs(working_dir, data) await _write_inputs(working_dir, data)
@@ -535,14 +522,14 @@ async def build_bento_api(request):
with tempfile.TemporaryDirectory(suffix="-bento", prefix="comfy-pack-") as temp_dir: with tempfile.TemporaryDirectory(suffix="-bento", prefix="comfy-pack-") as temp_dir:
temp_dir_path = Path(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 # create a bento
try: try:
bento = build_bento( bento = build_bento(
data["bento_name"], data["bento_name"],
temp_dir_path, temp_dir_path,
data.get("system_packages"), system_packages=data.get("system_packages"),
) )
except bentoml.exceptions.BentoMLException as e: except bentoml.exceptions.BentoMLException as e:
return web.json_response( return web.json_response(
+390 -2
View File
@@ -1,14 +1,18 @@
import glob
import hashlib import hashlib
import io
import json import json
import os import os
import sys
import shutil import shutil
import sys
import zipfile
from io import BytesIO
import folder_paths import folder_paths
import node_helpers import node_helpers
import numpy as np import numpy as np
import torch import torch
from PIL import Image, ImageOps, ImageSequence from PIL import Image, ImageOps, ImageSequence, PngImagePlugin
from PIL.PngImagePlugin import PngInfo from PIL.PngImagePlugin import PngInfo
from .monkeypatch import set_bentoml_output from .monkeypatch import set_bentoml_output
@@ -163,6 +167,100 @@ class OutputImage:
return {"ui": {"images": results}} 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: class ImageInput:
COLOR = (142, 36, 170) COLOR = (142, 36, 170)
@@ -353,14 +451,299 @@ class AnyInput:
return True 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:
CPACK_NODE = True
CATEGORY = "ComfyPack/output"
RETURN_TYPES = ()
FUNCTION = "save_audio"
OUTPUT_NODE = True
def __init__(self):
self.output_dir = folder_paths.get_output_directory()
self.type = "output"
self.prefix_append = ""
def save_audio(
self,
audio,
filename_prefix,
format="flac",
prompt=None,
extra_pnginfo=None,
quality="128k",
):
import av
import torchaudio
filename_prefix += self.prefix_append
full_output_folder, filename, counter, subfolder, filename_prefix = (
get_save_image_path(filename_prefix, self.output_dir)
)
results = []
# Prepare metadata dictionary
metadata = {}
if prompt is not None:
metadata["prompt"] = json.dumps(prompt)
if extra_pnginfo is not None:
for x in extra_pnginfo:
metadata[x] = json.dumps(extra_pnginfo[x])
# Opus supported sample rates
OPUS_RATES = [8000, 12000, 16000, 24000, 48000]
for batch_number, waveform in enumerate(audio["waveform"].cpu()):
filename_with_batch_num = filename.replace("%batch_num%", str(batch_number))
file = f"{filename_with_batch_num}_{counter:05}.{format}"
output_path = os.path.join(full_output_folder, file)
# Use original sample rate initially
sample_rate = audio["sample_rate"]
# Handle Opus sample rate requirements
if format == "opus":
if sample_rate > 48000:
sample_rate = 48000
elif sample_rate not in OPUS_RATES:
# Find the next highest supported rate
for rate in sorted(OPUS_RATES):
if rate > sample_rate:
sample_rate = rate
break
if sample_rate not in OPUS_RATES: # Fallback if still not supported
sample_rate = 48000
# Resample if necessary
if sample_rate != audio["sample_rate"]:
waveform = torchaudio.functional.resample(
waveform, audio["sample_rate"], sample_rate
)
# Create in-memory WAV buffer
wav_buffer = io.BytesIO()
torchaudio.save(wav_buffer, waveform, sample_rate, format="WAV")
wav_buffer.seek(0) # Rewind for reading
# Use PyAV to convert and add metadata
input_container = av.open(wav_buffer)
# Create output with specified format
output_buffer = io.BytesIO()
output_container = av.open(output_buffer, mode="w", format=format)
# Set metadata on the container
for key, value in metadata.items():
output_container.metadata[key] = value
# Set up the output stream with appropriate properties
input_container.streams.audio[0]
if format == "opus":
out_stream = output_container.add_stream("libopus", rate=sample_rate)
if quality == "64k":
out_stream.bit_rate = 64000
elif quality == "96k":
out_stream.bit_rate = 96000
elif quality == "128k":
out_stream.bit_rate = 128000
elif quality == "192k":
out_stream.bit_rate = 192000
elif quality == "320k":
out_stream.bit_rate = 320000
elif format == "mp3":
out_stream = output_container.add_stream("libmp3lame", rate=sample_rate)
if quality == "V0":
# TODO i would really love to support V3 and V5 but there doesn't seem to be a way to set the qscale level, the property below is a bool
out_stream.codec_context.qscale = 1
elif quality == "128k":
out_stream.bit_rate = 128000
elif quality == "320k":
out_stream.bit_rate = 320000
else: # format == "flac":
out_stream = output_container.add_stream("flac", rate=sample_rate)
# Copy frames from input to output
for frame in input_container.decode(audio=0):
frame.pts = None # Let PyAV handle timestamps
output_container.mux(out_stream.encode(frame))
# Flush encoder
output_container.mux(out_stream.encode(None))
# Close containers
output_container.close()
input_container.close()
# Write the output to file
output_buffer.seek(0)
with open(output_path, "wb") as f:
f.write(output_buffer.getbuffer())
results.append(
{"filename": file, "subfolder": subfolder, "type": self.type}
)
counter += 1
return {"ui": {"audio": results}}
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"audio": ("AUDIO",),
"filename_prefix": ("STRING", {"default": "audio/comfypack"}),
},
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
}
class OutputVideo:
CPACK_NODE = True
CATEGORY = "ComfyPack/output"
RETURN_TYPES = ()
FUNCTION = "save_video"
DESCRIPTION = "Saves the input video to your ComfyUI output directory."
OUTPUT_NODE = True
def __init__(self):
self.output_dir = folder_paths.get_output_directory()
self.type = "output"
self.prefix_append = ""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"video": ("VIDEO", {"tooltip": "The video to save."}),
"filename_prefix": (
"STRING",
{
"default": "video/comfypack",
"tooltip": "The prefix for the file to save. This may include formatting information such as %date:yyyy-MM-dd% or %Empty Latent Image.width% to include values from nodes.",
},
),
"format": (
["auto", "mp4"],
{"default": "auto", "tooltip": "The format to save the video as."},
),
"codec": (
["auto", "h264"],
{"default": "auto", "tooltip": "The codec to use for the video."},
),
},
"hidden": {"prompt": "PROMPT", "extra_pnginfo": "EXTRA_PNGINFO"},
}
def save_video(
self,
video,
filename_prefix,
format,
codec,
prompt=None,
extra_pnginfo=None,
):
filename_prefix += self.prefix_append
width, height = video.get_dimensions()
full_output_folder, filename, counter, subfolder, filename_prefix = (
get_save_image_path(filename_prefix, self.output_dir, width, height)
)
results = list()
saved_metadata = None
metadata = {}
if extra_pnginfo is not None:
metadata.update(extra_pnginfo)
if prompt is not None:
metadata["prompt"] = prompt
if len(metadata) > 0:
saved_metadata = metadata
file = f"{filename}_{counter:05}.mp4"
video.save_to(
os.path.join(full_output_folder, file),
format=format,
codec=codec,
metadata=saved_metadata,
)
results.append({"filename": file, "subfolder": subfolder, "type": self.type})
counter += 1
return {"ui": {"images": results, "animated": (True,)}}
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 = { NODE_CLASS_MAPPINGS = {
"CPackOutputFile": OutputFile, "CPackOutputFile": OutputFile,
"CPackOutputImage": OutputImage, "CPackOutputImage": OutputImage,
"CPackOutputAudio": OutputAudio,
"CPackOutputVideo": OutputVideo,
"CPackOutputZip": OutputImageWithStringTxt,
"CPackOutputZipSwitch": OutputZip,
"CPackInputImage": ImageInput, "CPackInputImage": ImageInput,
"CPackInputString": StringInput, "CPackInputString": StringInput,
"CPackInputInt": IntInput, "CPackInputInt": IntInput,
"CPackInputFile": FileInput, "CPackInputFile": FileInput,
"CPackInputAny": AnyInput, "CPackInputAny": AnyInput,
"CPackOutputTextFile": OutputTextFile,
} }
NODE_DISPLAY_NAME_MAPPINGS = { NODE_DISPLAY_NAME_MAPPINGS = {
@@ -370,5 +753,10 @@ NODE_DISPLAY_NAME_MAPPINGS = {
"CPackInputFile": "File Input", "CPackInputFile": "File Input",
"CPackInputAny": "Any Input", "CPackInputAny": "Any Input",
"CPackOutputImage": "Image Output", "CPackOutputImage": "Image Output",
"CPackOutputAudio": "Audio Output",
"CPackOutputVideo": "Video Output",
"CPackOutputFile": "File 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" readme = "README.md"
requires-python = ">=3.9" requires-python = ">=3.9"
dependencies = [ dependencies = [
"bentoml>=1.3.13", "bentoml>=1.3.21",
"click>=8.1.7", "click>=8.1.7",
"comfy-cli>=1.2.8", "comfy-cli>=1.2.8",
] ]
+1 -1
View File
@@ -1,4 +1,4 @@
bentoml bentoml>=1.3.21
fastapi fastapi
comfy-cli comfy-cli
duckduckgo-search duckduckgo-search
+157 -61
View File
@@ -5,6 +5,7 @@ import shutil
import subprocess import subprocess
import sys import sys
import tempfile import tempfile
import zipfile
from pathlib import Path from pathlib import Path
import click import click
@@ -30,6 +31,7 @@ def _ensure_uv() -> None:
@click.group() @click.group()
@click.version_option()
def main(): def main():
"""comfy-pack CLI""" """comfy-pack CLI"""
pass pass
@@ -55,27 +57,25 @@ def main():
def init(dir: str, verbose: int): def init(dir: str, verbose: int):
import os import os
from rich.console import Console import rich
console = Console()
# Check if directory path is valid # Check if directory path is valid
try: try:
install_dir = Path(dir).absolute() install_dir = Path(dir).absolute()
if install_dir.exists() and not install_dir.is_dir(): 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 return 1
# Check if directory is empty or contains ComfyUI # Check if directory is empty or contains ComfyUI
if install_dir.exists(): if install_dir.exists():
contents = list(install_dir.iterdir()) contents = list(install_dir.iterdir())
if contents and not (install_dir / ".git").exists(): 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]" f"[red]Error: Directory {dir} is not empty and doesn't appear to be a ComfyUI installation[/red]"
) )
return 1 return 1
except Exception as e: 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 return 1
# Check git installation # Check git installation
@@ -86,7 +86,7 @@ def init(dir: str, verbose: int):
capture_output=True, capture_output=True,
) )
except (subprocess.SubprocessError, FileNotFoundError): 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 return 1
# Check if we have write permissions # Check if we have write permissions
@@ -97,36 +97,36 @@ def init(dir: str, verbose: int):
test_file.touch() test_file.touch()
test_file.unlink() test_file.unlink()
except (OSError, PermissionError) as e: 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 return 1
# Check if Python version is compatible # Check if Python version is compatible
if sys.version_info < (3, 8): 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 return 1
# Check if uv is installed # Check if uv is installed
try: try:
_ensure_uv() _ensure_uv()
except RuntimeError as e: except RuntimeError as e:
console.print(f"[red]Error: {str(e)}[/red]") rich.print(f"[red]Error: {str(e)}[/red]")
return 1 return 1
# Check if enough disk space is available (rough estimate: 2GB) # Check if enough disk space is available (rough estimate: 2GB)
try: try:
free_space = shutil.disk_usage(install_dir).free free_space = shutil.disk_usage(install_dir).free
if free_space < 2 * 1024 * 1024 * 1024: # 2GB in bytes if free_space < 2 * 1024 * 1024 * 1024: # 2GB in bytes
console.print( rich.print(
"[yellow]Warning: Less than 2GB free disk space available[/yellow]" "[yellow]Warning: Less than 2GB free disk space available[/yellow]"
) )
except Exception as e: except Exception as e:
console.print( rich.print(
f"[yellow]Warning: Could not check free disk space - {str(e)}[/yellow]" f"[yellow]Warning: Could not check free disk space - {str(e)}[/yellow]"
) )
# Clone ComfyUI if not exists # Clone ComfyUI if not exists
if not (install_dir / ".git").exists(): if not (install_dir / ".git").exists():
console.print("[green]Cloning ComfyUI...[/green]") rich.print("[green]Cloning ComfyUI...[/green]")
subprocess.run( subprocess.run(
[ [
"git", "git",
@@ -138,7 +138,7 @@ def init(dir: str, verbose: int):
) )
# Update ComfyUI # Update ComfyUI
console.print("[green]Updating ComfyUI...[/green]") rich.print("[green]Updating ComfyUI...[/green]")
subprocess.run( subprocess.run(
["git", "pull"], ["git", "pull"],
cwd=install_dir, cwd=install_dir,
@@ -147,7 +147,7 @@ def init(dir: str, verbose: int):
# Create and activate venv # Create and activate venv
venv_dir = install_dir / ".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(): if venv_dir.exists():
shutil.rmtree(venv_dir) shutil.rmtree(venv_dir)
subprocess.run( subprocess.run(
@@ -163,7 +163,7 @@ def init(dir: str, verbose: int):
python = str(venv_dir / "bin" / "python") python = str(venv_dir / "bin" / "python")
# Install requirements with uv # Install requirements with uv
console.print("[green]Installing ComfyUI requirements with uv...[/green]") rich.print("[green]Installing ComfyUI requirements with uv...[/green]")
subprocess.run( subprocess.run(
["uv", "pip", "install", "pip", "--upgrade"], ["uv", "pip", "install", "pip", "--upgrade"],
env={ env={
@@ -182,7 +182,7 @@ def init(dir: str, verbose: int):
) )
# Install comfy-pack as custom node # 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 = install_dir / "custom_nodes"
custom_nodes_dir.mkdir(exist_ok=True) custom_nodes_dir.mkdir(exist_ok=True)
@@ -224,12 +224,12 @@ def init(dir: str, verbose: int):
) )
version = get_self_git_commit() or "unknown" version = get_self_git_commit() or "unknown"
console.print( rich.print(
f"\n[green]✓ Installation completed! (comfy-pack version: {version})[/green]" 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" "\n[green]Next steps:[/green]\n"
f"1. cd {dir}\n" f"1. cd {dir}\n"
"2. source .venv/bin/activate # On Windows: .venv\\Scripts\\activate\n" "2. source .venv/bin/activate # On Windows: .venv\\Scripts\\activate\n"
@@ -241,7 +241,7 @@ def init(dir: str, verbose: int):
name="unpack", name="unpack",
help="Restore the ComfyUI workspace to specified directory", 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( @click.option(
"--dir", "--dir",
"-d", "-d",
@@ -255,33 +255,69 @@ def init(dir: str, verbose: int):
type=click.BOOL, type=click.BOOL,
is_flag=True, 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( @click.option(
"--verbose", "--verbose",
"-v", "-v",
count=True, count=True,
help="Increase verbosity level (use multiple times for more verbosity)", help="Increase verbosity level (use multiple times for more verbosity)",
) )
def unpack_cmd(cpack: str, dir: str, include_disabled_models: bool, verbose: int): @click.option(
from rich.console import Console "--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 from .package import install
console = Console() install(
cpack,
install(cpack, dir, verbose=verbose, all_models=include_disabled_models) dir,
console.print("\n[green]✓ ComfyUI Workspace is restored![/green]") verbose=verbose,
console.print(f"{dir}") all_models=include_disabled_models,
prepare_models=not no_models,
console.print( no_venv=no_venv,
"\n[green] Next steps: [/green]\n" preheat=preheat,
"1. Change directory to the restored workspace\n"
"2. Source the virtual environment by running `source .venv/bin/activate`\n"
"3. Run the ComfyUI project by running `python main.py`"
) )
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): def _print_schema(schema, verbose: int = 0):
from rich.console import Console import rich
from rich.table import Table from rich.table import Table
table = Table(title="") table = Table(title="")
@@ -312,7 +348,7 @@ def _print_schema(schema, verbose: int = 0):
range_str, range_str,
) )
Console().print(table) rich.print(table)
@functools.lru_cache @functools.lru_cache
@@ -340,8 +376,8 @@ def _get_cache_workspace(cpack: str):
) )
@click.pass_context @click.pass_context
def run(ctx, cpack: str, output_dir: str, help: bool, verbose: int): def run(ctx, cpack: str, output_dir: str, help: bool, verbose: int):
import rich
from pydantic import ValidationError from pydantic import ValidationError
from rich.console import Console
from .utils import generate_input_model from .utils import generate_input_model
@@ -349,35 +385,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]) zip([k.lstrip("-").replace("-", "_") for k in ctx.args[::2]], ctx.args[1::2])
) )
console = Console() with zipfile.ZipFile(cpack) as z:
workflow = json.loads(z.read("workflow_api.json"))
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())
input_model = generate_input_model(workflow) input_model = generate_input_model(workflow)
# If help is requested, show command help and input schema # If help is requested, show command help and input schema
if help: if help:
console.print( rich.print(
'Usage: comfy-pack run [OPTIONS] CPACK --input1 "value1" --input2 "value2" ...' '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) _print_schema(input_model.model_json_schema(), verbose)
return 0 return 0
try: try:
validated_data = input_model(**inputs) 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(): for field, value in validated_data.model_dump().items():
console.print(f"{field}: {value}") rich.print(f"{field}: {value}")
except ValidationError as e: except ValidationError as e:
console.print("[red]✗ Validation failed![/red]") rich.print("[red]✗ Validation failed![/red]")
for error in e.errors(): 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) _print_schema(input_model.model_json_schema(), verbose)
return 1 return 1
@@ -385,44 +417,45 @@ def run(ctx, cpack: str, output_dir: str, help: bool, verbose: int):
workspace = _get_cache_workspace(cpack) workspace = _get_cache_workspace(cpack)
if not (workspace / "DONE").exists(): if not (workspace / "DONE").exists():
console.print("\n[green]✓ Restoring ComfyUI Workspace...[/green]") rich.print("\n[green]✓ Restoring ComfyUI Workspace...[/green]")
if workspace.exists(): if workspace.exists():
shutil.rmtree(workspace) shutil.rmtree(workspace)
install(cpack, workspace, verbose=verbose) install(cpack, workspace, verbose=verbose)
with open(workspace / "DONE", "w") as f: with open(workspace / "DONE", "w") as f:
f.write("DONE") f.write("DONE")
console.print("\n[green]✓ ComfyUI Workspace is restored![/green]") rich.print("\n[green]✓ ComfyUI Workspace is restored![/green]")
console.print(f"{workspace}") rich.print(f"{workspace}")
from .run import ComfyUIServer, run_workflow from .run import ComfyUIServer, run_workflow
with ComfyUIServer(str(workspace.absolute()), verbose=verbose) as server: 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( results = run_workflow(
server.host, server.host,
server.port, server.port,
workflow, workflow,
Path(output_dir).absolute(), Path(output_dir).absolute(),
verbose=verbose, verbose=verbose,
workspace=server.workspace,
**validated_data.model_dump(), **validated_data.model_dump(),
) )
console.print("\n[green]✓ Workflow is executed successfully![/green]") rich.print("\n[green]✓ Workflow is executed successfully![/green]")
if results: if results:
console.print("\n[green]✓ Retrieved outputs:[/green]") rich.print("\n[green]✓ Retrieved outputs:[/green]")
if isinstance(results, dict): if isinstance(results, dict):
for field, value in results.items(): for field, value in results.items():
console.print(f"{field}: {value}") rich.print(f"{field}: {value}")
elif isinstance(results, list): elif isinstance(results, list):
for i, value in enumerate(results): for i, value in enumerate(results):
console.print(f"{i}: {value}") rich.print(f"{i}: {value}")
else: else:
console.print(results) rich.print(results)
@main.command(name="build-bento") @main.command(name="build-bento")
@click.argument("source")
@click.option("--name", help="Name of the bento service") @click.option("--name", help="Name of the bento service")
@click.option("--version", help="Version 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): 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.""" """Build a bento from the source, which can be either a .cpack.zip file or a bento tag."""
import bentoml import bentoml
@@ -439,10 +472,17 @@ def bento_cmd(source: str, name: str | None, version: str | None):
else: else:
existing_bento = bentoml.get(source) existing_bento = bentoml.get(source)
name = name or existing_bento.tag.name 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( build_config = BentoBuildConfig.from_bento_dir(
existing_bento.path_of("src") 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 system_packages = build_config.docker.system_packages
include_default_system_packages = False include_default_system_packages = False
@@ -453,3 +493,59 @@ def bento_cmd(source: str, name: str | None, version: str | None):
system_packages=system_packages, system_packages=system_packages,
include_default_system_packages=include_default_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 -1
View File
@@ -113,7 +113,12 @@ async def async_batch_get_sha256(
try: try:
with SHA_CACHE_FILE.open("r") as f: with SHA_CACHE_FILE.open("r") as f:
cache = json.load(f) cache = json.load(f)
cache.update(new_cache) except (IOError, OSError):
cache = {}
cache.update(new_cache)
try:
with SHA_CACHE_FILE.open("w") as f: with SHA_CACHE_FILE.open("w") as f:
json.dump(cache, f, indent=2) json.dump(cache, f, indent=2)
except (IOError, OSError): except (IOError, OSError):
+6 -8
View File
@@ -1,8 +1,9 @@
from .const import MODEL_SOURCE_CACHE_FILE
import asyncio import asyncio
import json import json
import re import re
from .const import MODEL_SOURCE_CACHE_FILE
# MODEL_NAME = r"[a-zA-Z0-9-._]+" # MODEL_NAME = r"[a-zA-Z0-9-._]+"
# COMMIT = r"[a-f0-9]+" # COMMIT = r"[a-f0-9]+"
@@ -14,9 +15,8 @@ PATH_PATTERN = re.compile(
async def _lookup_huggingface_model(model_sha: str) -> dict: async def _lookup_huggingface_model(model_sha: str) -> dict:
from duckduckgo_search import DDGS
import aiohttp import aiohttp
from duckduckgo_search import DDGS
query = f"site:huggingface.co blob {model_sha}" query = f"site:huggingface.co blob {model_sha}"
try: try:
@@ -25,7 +25,7 @@ async def _lookup_huggingface_model(model_sha: str) -> dict:
async with aiohttp.ClientSession(trust_env=True) as session: async with aiohttp.ClientSession(trust_env=True) as session:
for result in search_results: for result in search_results:
url = result['link'] url = result['href']
if "blob" not in url: if "blob" not in url:
continue continue
@@ -38,11 +38,9 @@ async def _lookup_huggingface_model(model_sha: str) -> dict:
repo, commit = commit_match.groups() repo, commit = commit_match.groups()
if path_match := PATH_PATTERN.search(text): if path_match := PATH_PATTERN.search(text):
path = path_match.group(1) 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 = { info = {
"download_url": download_url, "download_url": path,
"url": url, "url": path,
"repo": repo, "repo": repo,
"commit": commit, "commit": commit,
"path": path, "path": path,
+136 -68
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
import contextlib
import json import json
import os import os
import shutil import shutil
@@ -25,28 +26,33 @@ COMFY_PACK_DIR = Path(__file__).parent
def _clone_commit(url: str, commit: str, dir: Path, verbose: int = 0): def _clone_commit(url: str, commit: str, dir: Path, verbose: int = 0):
stdout = None if verbose > 0 else subprocess.DEVNULL stdout = None if verbose > 0 else subprocess.DEVNULL
stderr = None if verbose > 1 else subprocess.DEVNULL stderr = None if verbose > 1 else subprocess.DEVNULL
env = {**os.environ, "GIT_TERMINAL_PROMPT": "0"}
subprocess.check_call( subprocess.check_call(
["git", "clone", "--recurse-submodules", "--filter=blob:none", url, dir], ["git", "clone", "--recurse-submodules", "--filter=blob:none", url, dir],
stdout=stdout, stdout=stdout,
stderr=stderr, stderr=stderr,
env=env,
) )
subprocess.check_call( subprocess.check_call(
["git", "fetch", "-q", url, commit], ["git", "fetch", "-q", url, commit],
cwd=dir, cwd=dir,
stdout=stdout, stdout=stdout,
stderr=stderr, stderr=stderr,
env=env,
) )
subprocess.check_call( subprocess.check_call(
["git", "checkout", "FETCH_HEAD"], ["git", "checkout", "FETCH_HEAD"],
cwd=dir, cwd=dir,
stdout=stdout, stdout=stdout,
stderr=stderr, stderr=stderr,
env=env,
) )
subprocess.check_call( subprocess.check_call(
["git", "submodule", "update", "--init", "--recursive"], ["git", "submodule", "update", "--init", "--recursive"],
cwd=dir, cwd=dir,
stdout=stdout, stdout=stdout,
stderr=stderr, stderr=stderr,
env=env,
) )
@@ -61,6 +67,18 @@ def install_comfyui(snapshot, workspace: Path, verbose: int = 0):
return return
shutil.rmtree(workspace) shutil.rmtree(workspace)
_clone_commit(COMFYUI_REPO, comfyui_commit, workspace, verbose=verbose) _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: with open(workspace / ".DONE", "w") as f:
f.write(comfyui_commit) f.write(comfyui_commit)
@@ -83,6 +101,7 @@ def install_custom_modules(snapshot, workspace: Path, verbose: int = 0):
continue continue
shutil.rmtree(module_dir) shutil.rmtree(module_dir)
print(f"Installing custom node {url}")
commit_hash = module["commit_hash"] commit_hash = module["commit_hash"]
_clone_commit(url, commit_hash, module_dir, verbose=verbose) _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( def install_dependencies(
snapshot: dict, python_version: str,
req_file: str, req_files: list[str],
workspace: Path, workspace: Path,
verbose: int = 0, verbose: int = 0,
): no_deps: bool = False,
no_venv: bool = False,
) -> Path:
print("Installing Python dependencies") print("Installing Python dependencies")
python_version = snapshot["python"]
stdout = None if verbose > 0 else subprocess.DEVNULL stdout = None if verbose > 0 else subprocess.DEVNULL
stderr = None if verbose > 1 else subprocess.DEVNULL stderr = None if verbose > 1 else subprocess.DEVNULL
if no_venv:
subprocess.check_call( print("Using the current Python environment")
["uv", "python", "install", python_version], venv_py = Path(sys.executable)
cwd=workspace, else:
stdout=stdout, venv = (workspace / ".venv").absolute()
stderr=stderr, venv_py = (
) venv / "Scripts" / "python.exe"
venv = workspace / ".venv" if os.name == "nt"
if (venv / "DONE").exists(): else venv / "bin" / "python"
return )
venv_py = ( if (venv / "DONE").exists():
venv / "Scripts" / "python.exe" if os.name == "nt" else venv / "bin" / "python" return venv_py
) subprocess.check_call(
subprocess.check_call( [
[ "uv",
"uv", "venv",
"venv", "--python",
"--python", python_version,
python_version, venv,
venv, ],
], stdout=stdout,
stdout=stdout, stderr=stderr,
stderr=stderr, )
)
subprocess.check_call( subprocess.check_call(
[ [
"uv", "uv",
@@ -163,28 +182,28 @@ def install_dependencies(
stderr=stderr, stderr=stderr,
) )
if verbose > 0: if verbose > 0:
print(f"Installing dependencies from {req_file}") print(f"Installing dependencies from {req_files}")
install_cmd = [ install_cmd = [
"uv", "uv",
"pip", "pip",
"install", "install",
"-p", "-p",
str(venv_py), str(venv_py),
"-r",
req_file,
"--no-deps",
] ]
if STRICT_MODE: for req_file in req_files:
pass install_cmd.extend(["-r", str(req_file)])
else: if not STRICT_MODE:
install_cmd.extend(["--index-strategy", "unsafe-best-match"]) install_cmd.extend(["--index-strategy", "unsafe-best-match"])
if no_deps:
install_cmd.append("--no-deps")
subprocess.check_call( subprocess.check_call(
install_cmd, install_cmd,
stdout=stdout, stdout=stdout,
stderr=stderr, stderr=stderr,
) )
with open(venv / "DONE", "w") as f: if not no_venv:
f.write("DONE") with open(venv / "DONE", "w") as f:
f.write("DONE")
return venv_py 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): def download_file(url: str, dest_path: Path, progress_callback=None):
"""Download file with progress tracking""" """Download file with progress tracking"""
if subprocess.call(["curl", "--version"], stdout=subprocess.DEVNULL) == 0:
subprocess.check_call( # prepare auth token from huggingface if possible
["curl", "-L", url, "-o", str(dest_path)], if (token := os.getenv("HF_TOKEN")) and ("huggingface" in url):
) bearer = f"Bearer {token}"
return True urllib_request = urllib.request.Request(url, headers={"Authorization": bearer})
curl_auth = ["-H", f"Authorization: {bearer}"]
else:
curl_auth = []
urllib_request = url
try: 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)) total_size = int(response.headers.get("content-length", 0))
block_size = 8192 block_size = 8192
downloaded = 0 downloaded = 0
@@ -382,49 +411,82 @@ def retrieve_models(
def install( def install(
cpack: str | Path, cpack: str | Path,
workspace: str | Path = "workspace", workspace: str | Path = "workspace",
preheat: bool = True, preheat: bool = False,
prepare_models: bool = True,
all_models: bool = False, all_models: bool = False,
no_venv: bool = False,
verbose: int = 0, verbose: int = 0,
): ):
workspace = Path(workspace) workspace = Path(workspace)
cpack = Path(cpack)
print(f"Installing package {cpack} to {workspace} (verbose={verbose})") print(f"Installing package {cpack} to {workspace} (verbose={verbose})")
with tempfile.TemporaryDirectory() as temp_dir: with contextlib.ExitStack() as stack:
pack_dir = Path(temp_dir) / ".cpack" if cpack.is_file():
shutil.unpack_archive(cpack, pack_dir) 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()) 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_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 = Path("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("*"): for f in (pack_dir / "input").glob("*"):
if f.is_file(): if f.is_file():
shutil.copy(f, workspace / "input" / f.name) shutil.copy(f, workspace / "input" / f.name)
elif f.is_dir(): elif f.is_dir():
shutil.copytree(f, workspace / "input" / f.name, dirs_exist_ok=True) 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: if preheat:
from .run import ComfyUIServer from .run import ComfyUIServer
with ComfyUIServer( with ComfyUIServer(
str(workspace), str(workspace),
verbose=verbose, verbose=verbose,
venv=str(workspace / ".venv"), venv=str(workspace / ".venv") if not no_venv else None,
) as _: ) as _:
pass pass
if prepare_models:
retrieve_models(snapshot, workspace, verbose=verbose, all_models=all_models) retrieve_models(snapshot, workspace, verbose=verbose, all_models=all_models)
required_files = ["snapshot.json", "requirements.txt"] required_files = ["snapshot.json"]
def build_bento( def build_bento(
@@ -456,11 +518,14 @@ def build_bento(
system_packages = system_packages or [] system_packages = system_packages or []
shutil.copy2(Path(__file__).with_name("service.py"), source_dir / "service.py") shutil.copy2(Path(__file__).with_name("service.py"), source_dir / "service.py")
# Copy comfy-pack package snapshot_text = (source_dir / "snapshot.json").read_text()
shutil.copytree( setup_script = source_dir / "setup_workspace.sh"
COMFY_PACK_DIR, source_dir / COMFY_PACK_DIR.name, dirs_exist_ok=True with Path(__file__).with_name("setup_workspace.sh").open() as f:
) setup_script.write_text(f.read().replace("<SNAPSHOT>", snapshot_text))
snapshot = json.loads((source_dir / "snapshot.json").read_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( return bentoml.build(
"service:ComfyService", "service:ComfyService",
name=bento_name, name=bento_name,
@@ -475,7 +540,10 @@ def build_bento(
docker={ docker={
"python_version": f"{sys.version_info.major}.{sys.version_info.minor}", "python_version": f"{sys.version_info.major}.{sys.version_info.minor}",
"system_packages": system_packages, "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},
) )
+7 -3
View File
@@ -5,6 +5,7 @@ import json
import logging import logging
import os import os
import random import random
import shlex
import shutil import shutil
import socket import socket
import subprocess import subprocess
@@ -81,7 +82,7 @@ class ComfyUIServer:
self.port = 8188 self.port = 8188
else: else:
self.port = port self.port = port
self.venv = venv self.venv = os.path.abspath(venv) if venv else None
def start(self) -> None: def start(self) -> None:
""" """
@@ -115,8 +116,6 @@ class ComfyUIServer:
subprocess.run(command, check=True, stdout=stdout, env=env) subprocess.run(command, check=True, stdout=stdout, env=env)
logger.info("Successfully disabled Comfy CLI tracking") logger.info("Successfully disabled Comfy CLI tracking")
logger.info("Preparing directories required by ComfyUI...")
logger.info("Starting ComfyUI in the background...") logger.info("Starting ComfyUI in the background...")
command = [ command = [
"python", "python",
@@ -133,6 +132,8 @@ class ComfyUIServer:
if self.host != "localhost": if self.host != "localhost":
command.extend(["--listen", self.host]) command.extend(["--listen", self.host])
if options := env.pop("COMFYUI_OPTIONS", None):
command.extend(shlex.split(options))
def preexec_fn(): def preexec_fn():
os.setpgrp() os.setpgrp()
@@ -206,6 +207,7 @@ def run_workflow(
output_dir: Union[str, Path, None] = None, output_dir: Union[str, Path, None] = None,
timeout: int = 300, timeout: int = 300,
verbose: int = 0, verbose: int = 0,
workspace: str = ".",
**kwargs: Any, **kwargs: Any,
) -> Any: ) -> Any:
""" """
@@ -258,6 +260,8 @@ def run_workflow(
command = [ command = [
"comfy", "comfy",
"--skip-prompt", "--skip-prompt",
"--workspace",
workspace,
"run", "run",
"--workflow", "--workflow",
workflow_file_path.as_posix(), workflow_file_path.as_posix(),
+5 -10
View File
@@ -40,7 +40,7 @@ def _get_workspace() -> Path:
from bentoml._internal.configuration.containers import BentoMLContainer from bentoml._internal.configuration.containers import BentoMLContainer
snapshot = BASE_DIR / "snapshot.json" snapshot = BASE_DIR / "snapshot.json"
checksum = hashlib.md5(snapshot.read_bytes()).hexdigest() checksum = hashlib.md5(snapshot.read_bytes().strip()).hexdigest()
wp = ( wp = (
Path(BentoMLContainer.bentoml_home.get()) / "run" / "comfy_workspace" / checksum Path(BentoMLContainer.bentoml_home.get()) / "run" / "comfy_workspace" / checksum
) )
@@ -73,15 +73,16 @@ else:
snapshot = {} snapshot = {}
@bentoml.mount_asgi_app(app, path="/comfy") @bentoml.asgi_app(app, path="/comfy")
@bentoml.service(traffic={"timeout": REQUEST_TIMEOUT * 2}, resources={"gpu": 1}) @bentoml.service(traffic={"timeout": REQUEST_TIMEOUT * 2}, resources={"gpu": 1})
class ComfyService: class ComfyService:
def __init__(self): def __init__(self):
logger = logging.getLogger("comfy_pack") logger = logging.getLogger("comfy_pack")
logger.setLevel(logging.INFO) logger.setLevel(logging.INFO)
if not EXISTING_COMFYUI_SERVER: if not EXISTING_COMFYUI_SERVER:
workspace_override = os.environ.get("COMFYUI_PATH", None)
self.server = comfy_pack.run.ComfyUIServer( self.server = comfy_pack.run.ComfyUIServer(
str(_get_workspace()), workspace_override if workspace_override else str(_get_workspace()),
str(INPUT_DIR), str(INPUT_DIR),
verbose=int("BENTOML_DEBUG" in os.environ), verbose=int("BENTOML_DEBUG" in os.environ),
) )
@@ -122,19 +123,13 @@ class ComfyService:
output_dir=ctx.temp_dir, output_dir=ctx.temp_dir,
timeout=REQUEST_TIMEOUT, timeout=REQUEST_TIMEOUT,
verbose=verbose, verbose=verbose,
workspace=self.server.workspace if hasattr(self, "server") else ".",
**kwargs, **kwargs,
) )
if isinstance(ret, list): if isinstance(ret, list):
ret = ret[-1] ret = ret[-1]
return ret 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 @bentoml.on_deployment
@staticmethod @staticmethod
def prepare_models(): 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
+38 -20
View File
@@ -1,18 +1,15 @@
from __future__ import annotations from __future__ import annotations
import subprocess
from pathlib import Path
import re import re
import subprocess
import sys import sys
import zipfile
from pathlib import Path
from typing import TYPE_CHECKING, Any, Literal, Union from typing import TYPE_CHECKING, Any, Literal, Union
if TYPE_CHECKING: if TYPE_CHECKING:
from pydantic import BaseModel from pydantic import BaseModel
CPACK_OUTPUT_NODES = {
"CPackOutputFile",
"CPackOutputImage",
}
CPACK_PATH_INPUT_NODES = { CPACK_PATH_INPUT_NODES = {
"CPackInputFile", "CPackInputFile",
@@ -210,7 +207,7 @@ def populate_workflow(
for _, node in output_spec.items(): for _, node in output_spec.items():
node_id = node["id"] node_id = node["id"]
if node["class_type"] in CPACK_OUTPUT_NODES: if node["class_type"].startswith("CPackOutput"):
workflow[node_id]["inputs"]["filename_prefix"] = ( workflow[node_id]["inputs"]["filename_prefix"] = (
output_path / f"{session_id}{node_id}_" output_path / f"{session_id}{node_id}_"
).as_posix() ).as_posix()
@@ -221,7 +218,7 @@ def retrieve_workflow_outputs(
workflow: dict, workflow: dict,
output_path: Path, output_path: Path,
session_id: str = "", 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. 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. output_path (Path): The path where output files are saved.
Returns: 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 single Path if there's only one output file.
- A list of Paths if there are multiple files for a single output. - 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. - A dictionary mapping output names to Paths or lists of Paths for multiple outputs.
@@ -239,26 +236,47 @@ def retrieve_workflow_outputs(
ValueError: If the output node is not of the expected type. ValueError: If the output node is not of the expected type.
""" """
_, outputs = _parse_workflow(workflow) _, outputs = _parse_workflow(workflow)
should_zip = any(
node["class_type"] == "CPackOutputZipSwitch" for node in workflow.values()
)
zip_paths: list[tuple[Path, str]] = []
if len(outputs) != 1: if len(outputs) != 1:
value_map = {} value_map = {}
for k, node in outputs.items(): for k, node in outputs.items():
node_id = node["id"] 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: if len(path_strs) == 1:
value_map[k] = path_strs[0] value_map[k] = path_strs[0]
else: else:
value_map[k] = path_strs 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())) outs = list(output_path.glob(f"{session_id}{node_id}_*"))
if node["class_type"] not in CPACK_OUTPUT_NODES: zip_paths.extend(
raise ValueError(f"Output node {name} is not of type {CPACK_OUTPUT_NODES}") (p, p.name.replace(f"{session_id}{node_id}", name)) for p in outs
node_id = node["id"] )
if not should_zip:
outs = list(output_path.glob(f"{session_id}{node_id}_*")) if len(outs) == 1:
if len(outs) == 1: return outs[0]
return outs[0] return outs
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: 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 { try {
const { workflow, output: workflow_api } = await app.graphToPrompt(); const { workflow, output: workflow_api } = await app.graphToPrompt();
const files = await this.getInputFiles(workflow, workflow_api); const files = await this.getInputFiles(workflow, workflow_api);
// 预先将默认选中的文件添加到选中列表 // 预先将默认选中的文件添加到选中列表
files.forEach(file => { files.forEach(file => {
const path = file.path || file; const path = file.path || file;
@@ -255,14 +255,14 @@ class FileTreeList {
this.state.toggle(path, true); this.state.toggle(path, true);
} }
}); });
this.renderTree(this.buildTree(files)); this.renderTree(this.buildTree(files));
// 更新所有父目录的状态 // 更新所有父目录的状态
this.container.querySelectorAll("[data-action='check-dir']").forEach(checkbox => { this.container.querySelectorAll("[data-action='check-dir']").forEach(checkbox => {
this.updateFolderState(checkbox); this.updateFolderState(checkbox);
}); });
// 更新总数 // 更新总数
this.updateCount(); this.updateCount();
} catch(e) { } catch(e) {
@@ -282,12 +282,12 @@ class FileTreeList {
buildTree(files) { buildTree(files) {
const root = { name: 'root', children: {}, files: [] }; const root = { name: 'root', children: {}, files: [] };
for (const file of files) { for (const file of files) {
const filePath = file.path || file; const filePath = file.path || file;
const parts = filePath.split(/[\/\\]/); const parts = filePath.split(/[\/\\]/);
let current = root; let current = root;
for (let i = 0; i < parts.length; i++) { for (let i = 0; i < parts.length; i++) {
const part = parts[i]; const part = parts[i];
if (i === parts.length - 1) { if (i === parts.length - 1) {
@@ -313,24 +313,24 @@ class FileTreeList {
} }
} }
} }
// 对目录按名称排序 // 对目录按名称排序
for (const dir in root.children) { for (const dir in root.children) {
root.children[dir].files.sort((a, b) => a.name.localeCompare(b.name)); root.children[dir].files.sort((a, b) => a.name.localeCompare(b.name));
} }
return root; return root;
} }
renderTree(node, level = 0) { renderTree(node, level = 0) {
const dirs = Object.values(node.children); const dirs = Object.values(node.children);
const hasChildren = dirs.length > 0 || node.files.length > 0; const hasChildren = dirs.length > 0 || node.files.length > 0;
this.container.innerHTML = this.renderNode(node, true); this.container.innerHTML = this.renderNode(node, true);
this.setupEventListeners(); this.setupEventListeners();
this.updateCount(); this.updateCount();
} }
renderNode(node, isRoot = false) { renderNode(node, isRoot = false) {
@@ -354,35 +354,35 @@ class FileTreeList {
if (hasChildren) { if (hasChildren) {
html += `<div class="cpack-tree-children" style="display: ${isRoot ? '' : 'none'}">`; html += `<div class="cpack-tree-children" style="display: ${isRoot ? '' : 'none'}">`;
// 渲染子目录 // 渲染子目录
for (const dir of dirs) { for (const dir of dirs) {
html += this.renderNode(dir); html += this.renderNode(dir);
} }
// 渲染文件 // 渲染文件
for (const file of node.files) { for (const file of node.files) {
html += ` html += `
<div class="cpack-tree-item"> <div class="cpack-tree-item">
<label style="padding-left: 17px"> <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' : ''} /> ${this.state.isSelected(file.path) || file.checked ? 'checked' : ''} />
<span>${file.name}</span> <span>${file.name}</span>
${file.badges ? file.badges.map(badge => ${file.badges ? file.badges.map(badge =>
`<span style="background: ${badge.color || '#00a67d33'}; `<span style="background: ${badge.color || '#00a67d33'};
color: ${badge.textColor || '#00a67d'}; color: ${badge.textColor || '#00a67d'};
padding: 2px 6px; padding: 2px 6px;
border-radius: 4px; border-radius: 4px;
font-size: 0.8em; font-size: 0.8em;
cursor: help; cursor: help;
white-space: nowrap;" white-space: nowrap;"
title="${badge.tooltip || ''}">${badge.text}</span>` title="${badge.tooltip || ''}">${badge.text}</span>`
).join('') : ''} ).join('') : ''}
</label> </label>
</div> </div>
`; `;
} }
html += '</div>'; html += '</div>';
} }
@@ -422,7 +422,7 @@ class FileTreeList {
} }
setupEventListeners() { setupEventListeners() {
// 目录选择功能 // 目录选择功能
this.container.querySelectorAll("[data-action='check-dir']").forEach(checkbox => { this.container.querySelectorAll("[data-action='check-dir']").forEach(checkbox => {
@@ -433,7 +433,7 @@ class FileTreeList {
const childFiles = Array.from(children.querySelectorAll("input[name='files']")) const childFiles = Array.from(children.querySelectorAll("input[name='files']"))
.map(input => input.value); .map(input => input.value);
this.state.toggleMultiple(childFiles, e.target.checked); this.state.toggleMultiple(childFiles, e.target.checked);
// 更新UI // 更新UI
children.querySelectorAll("input[type='checkbox']").forEach(child => { children.querySelectorAll("input[type='checkbox']").forEach(child => {
child.checked = e.target.checked; 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 => { this.container.querySelectorAll("input[name='files']").forEach(checkbox => {
checkbox.addEventListener('change', (e) => { checkbox.addEventListener('change', (e) => {
this.state.toggle(e.target.value, e.target.checked); this.state.toggle(e.target.value, e.target.checked);
// 更新父文件夹状态 // 更新父文件夹状态
const parentFolder = checkbox.closest('.cpack-tree-children') const parentFolder = checkbox.closest('.cpack-tree-children')
?.previousElementSibling ?.previousElementSibling
@@ -477,7 +477,7 @@ class FileTreeList {
} }
updateCount() { updateCount() {
const countSpan = document.querySelector(`[data-files-count='${this.countId}']`); const countSpan = document.querySelector(`[data-files-count='${this.countId}']`);
if (countSpan) { if (countSpan) {
countSpan.textContent = this.state.getSelectedCount(); countSpan.textContent = this.state.getSelectedCount();
@@ -616,7 +616,7 @@ class ModelList {
// 只返回用户选中的模型 // 只返回用户选中的模型
return Array.from(this.container.querySelectorAll("input[name='models']:checked")) return Array.from(this.container.querySelectorAll("input[name='models']:checked"))
.map(input => input.value); .map(input => input.value);
} }
} }
@@ -679,7 +679,7 @@ async function createPackModal() {
const packageOptionsContainer = form.querySelector("#package-options-container"); const packageOptionsContainer = form.querySelector("#package-options-container");
const packageOptions = new PackageOptions(form, "pack-models-list", "pack-files-list", true); const packageOptions = new PackageOptions(form, "pack-models-list", "pack-files-list", true);
packageOptionsContainer.innerHTML = packageOptions.getHtml(); packageOptionsContainer.innerHTML = packageOptions.getHtml();
packageOptions.init().then(() => { packageOptions.init().then(() => {
confirmButton.disabled = false; confirmButton.disabled = false;
}); });
@@ -775,7 +775,7 @@ async function unpackAction() {
content.innerHTML = ` content.innerHTML = `
<div style="margin-bottom: 20px;"> <div style="margin-bottom: 20px;">
<p style="margin-bottom: 15px;">To unpack a workflow package, follow these steps:</p> <p style="margin-bottom: 15px;">To unpack a workflow package, follow these steps:</p>
<div style="margin-bottom: 15px;"> <div style="margin-bottom: 15px;">
<p style="margin-bottom: 10px;">1. Install comfy-pack CLI:</p> <p style="margin-bottom: 10px;">1. Install comfy-pack CLI:</p>
<div class="cpack-copyable" style="margin: 10px 0;"> <div class="cpack-copyable" style="margin: 10px 0;">
@@ -804,13 +804,13 @@ async function unpackAction() {
const closeButton = document.createElement("button"); const closeButton = document.createElement("button");
closeButton.textContent = "Close"; closeButton.textContent = "Close";
closeButton.className = "cpack-btn"; closeButton.className = "cpack-btn";
buttonContainer.appendChild(closeButton); buttonContainer.appendChild(closeButton);
modal.appendChild(title); modal.appendChild(title);
modal.appendChild(content); modal.appendChild(content);
modal.appendChild(buttonContainer); modal.appendChild(buttonContainer);
const { close } = createModal(modal); const { close } = createModal(modal);
closeButton.onclick = close; closeButton.onclick = close;
} }
@@ -1001,13 +1001,13 @@ class PackageOptions {
async init() { async init() {
const modelsList = this.container.querySelector(`#${this.modelsListId}`); const modelsList = this.container.querySelector(`#${this.modelsListId}`);
const filesList = this.container.querySelector(`#${this.filesListId}`); const filesList = this.container.querySelector(`#${this.filesListId}`);
this.modelListComponent = new ModelList(modelsList, this.modelsListId); this.modelListComponent = new ModelList(modelsList, this.modelsListId);
this.fileListComponent = new FileTreeList(filesList, this.filesListId); this.fileListComponent = new FileTreeList(filesList, this.filesListId);
const addButton = this.container.querySelector("#add-button"); const addButton = this.container.querySelector("#add-button");
const systemPackagesArray = this.container.querySelector("#system-packages-array"); const systemPackagesArray = this.container.querySelector("#system-packages-array");
addButton.addEventListener("click", (e) => { addButton.addEventListener("click", (e) => {
e.preventDefault(); e.preventDefault();
const row = document.createElement("div"); const row = document.createElement("div");
@@ -1063,7 +1063,7 @@ const buildForm = `
<p style="font-size: 0.85em; color: #888; margin-top: 5px;"> <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> 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> </p>
</div> </div>
</div> </div>
@@ -1112,7 +1112,7 @@ function createBuildModal() {
const packageOptionsContainer = form.querySelector("#package-options-container"); const packageOptionsContainer = form.querySelector("#package-options-container");
const packageOptions = new PackageOptions(form, "build-models-list", "build-files-list", false); const packageOptions = new PackageOptions(form, "build-models-list", "build-files-list", false);
packageOptionsContainer.innerHTML = packageOptions.getHtml(); packageOptionsContainer.innerHTML = packageOptions.getHtml();
packageOptions.init().then(() => { packageOptions.init().then(() => {
confirmButton.disabled = false; confirmButton.disabled = false;
}); });
@@ -1234,7 +1234,7 @@ async function createServeStatusModal(url) {
// Start status checking // Start status checking
const checkInterval = setInterval(async () => { const checkInterval = setInterval(async () => {
try { try {
const resp = await api.fetchApi("/bentoml/serve/heartbeat", { method: "POST" }); const resp = await api.fetchApi("/bentoml/serve/heartbeat");
const status = await resp.json(); const status = await resp.json();
if (status.error) { if (status.error) {
title.textContent = "Server Error"; title.textContent = "Server Error";