mirror of
https://github.com/Comfy-Org/ComfyUI.git
synced 2026-08-05 18:05:08 +08:00
feat(assets): add --disable-assets flag to turn the assets system off
With --enable-assets removed and the assets system always on, there is no supported way to run without it. Add an explicit opt-out for the rollout bake period: cloud multi-tenant sidecars need a guaranteed off-switch, and QA needs a clean-fallback configuration. When --disable-assets is passed: - asset API routes are registered but disabled, returning a structured 503 - database initialization is skipped entirely (no sqlite file is created; the database currently has no non-asset users) - the background seeder is disabled, covering startup, /object_info and post-execution enrich scans, and output registration - workflow output enrichment and /upload/image asset registration are skipped - the assets feature flag reports false, and supports_model_type_tags follows it since model_type tags are an assets-API capability The hidden --enable-assets no-op remains accepted for launcher compatibility; --disable-assets takes precedence since the former gates nothing.
This commit is contained in:
@@ -243,6 +243,7 @@ parser.add_argument("--database-url", type=str, default=f"sqlite:///{database_de
|
||||
# Deprecated no-op: the asset system is now always enabled. Kept (hidden) so that
|
||||
# existing launchers/containers still passing --enable-assets don't fail argparse.
|
||||
parser.add_argument("--enable-assets", action="store_true", help=argparse.SUPPRESS)
|
||||
parser.add_argument("--disable-assets", action="store_true", help="Disable the assets system: asset API routes return 503, the asset database is not initialized, no background scanning runs, and uploads and workflow outputs are not registered as assets.")
|
||||
parser.add_argument("--enable-asset-hashing", action="store_true", help="Compute blake3 content hashes when scanning assets. Hashing enables future asset-portability features (deduplication, cross-machine model resolution) but adds startup cost and per-output cost on large models directories. Off by default; enable to opt in.")
|
||||
parser.add_argument("--feature-flag", type=str, action='append', default=[], metavar="KEY[=VALUE]", help="Set a server feature flag. Use KEY=VALUE to set an explicit value, or bare KEY to set it to true. Can be specified multiple times. Boolean values (true/false) and numbers are auto-converted. Examples: --feature-flag show_signin_button=true or --feature-flag show_signin_button")
|
||||
parser.add_argument("--list-feature-flags", action="store_true", help="Print the registry of known CLI-settable feature flags as JSON and exit.")
|
||||
|
||||
@@ -172,4 +172,8 @@ def get_server_features() -> dict[str, Any]:
|
||||
features["assets"] = assets_enabled()
|
||||
except Exception:
|
||||
features["assets"] = False
|
||||
if not features["assets"]:
|
||||
# model_type tags are an assets-API capability; don't advertise them
|
||||
# when the assets backend is disabled or unavailable.
|
||||
features["supports_model_type_tags"] = False
|
||||
return features
|
||||
|
||||
@@ -6,11 +6,15 @@ import os
|
||||
def enrich_output_with_assets(output_ui: dict) -> dict:
|
||||
"""Register file-type output entries as assets and inject their ``id``.
|
||||
|
||||
Runs at output-processing time, once per produced output. Returns a new
|
||||
dict; entries without a resolvable on-disk file path are left unchanged.
|
||||
Errors are caught per-entry so a failure never blocks execution or the
|
||||
other entries.
|
||||
Runs at output-processing time, once per produced output, unless
|
||||
--disable-assets is set. Returns a new dict; entries without a resolvable
|
||||
on-disk file path are left unchanged. Errors are caught per-entry so a
|
||||
failure never blocks execution or the other entries.
|
||||
"""
|
||||
from comfy.cli_args import args
|
||||
if args.disable_assets:
|
||||
return output_ui
|
||||
|
||||
import folder_paths
|
||||
from app.assets.services.ingest import register_file_in_place, DependencyMissingError
|
||||
|
||||
|
||||
4
main.py
4
main.py
@@ -459,6 +459,10 @@ def cleanup_temp():
|
||||
|
||||
|
||||
def setup_database():
|
||||
if args.disable_assets:
|
||||
logging.info("Assets system disabled via --disable-assets; skipping database initialization and asset scanning.")
|
||||
asset_seeder.disable()
|
||||
return
|
||||
try:
|
||||
if dependencies_available():
|
||||
init_db()
|
||||
|
||||
38
server.py
38
server.py
@@ -253,7 +253,12 @@ class PromptServer():
|
||||
else args.front_end_root
|
||||
)
|
||||
logging.info(f"[Prompt Server] web root: {self.web_root}")
|
||||
register_assets_routes(self.app, self.user_manager)
|
||||
if args.disable_assets:
|
||||
# Register the routes without enabling them so /api/assets/* returns
|
||||
# a structured 503 rather than a 404.
|
||||
register_assets_routes(self.app)
|
||||
else:
|
||||
register_assets_routes(self.app, self.user_manager)
|
||||
routes = web.RouteTableDef()
|
||||
self.routes = routes
|
||||
self.last_node_id = None
|
||||
@@ -435,21 +440,22 @@ class PromptServer():
|
||||
|
||||
resp = {"name" : filename, "subfolder": subfolder, "type": image_upload_type}
|
||||
|
||||
try:
|
||||
tag = image_upload_type if image_upload_type in ("input", "output") else "input"
|
||||
tags = [tag]
|
||||
tags.extend(get_known_subfolder_tags(subfolder))
|
||||
result = register_file_in_place(abs_path=filepath, name=filename, tags=tags)
|
||||
resp["asset"] = {
|
||||
"id": result.ref.id,
|
||||
"name": result.ref.name,
|
||||
"asset_hash": result.asset.hash,
|
||||
"size": result.asset.size_bytes,
|
||||
"mime_type": result.asset.mime_type,
|
||||
"tags": result.tags,
|
||||
}
|
||||
except Exception:
|
||||
logging.warning("Failed to register uploaded image as asset", exc_info=True)
|
||||
if not args.disable_assets:
|
||||
try:
|
||||
tag = image_upload_type if image_upload_type in ("input", "output") else "input"
|
||||
tags = [tag]
|
||||
tags.extend(get_known_subfolder_tags(subfolder))
|
||||
result = register_file_in_place(abs_path=filepath, name=filename, tags=tags)
|
||||
resp["asset"] = {
|
||||
"id": result.ref.id,
|
||||
"name": result.ref.name,
|
||||
"asset_hash": result.asset.hash,
|
||||
"size": result.asset.size_bytes,
|
||||
"mime_type": result.asset.mime_type,
|
||||
"tags": result.tags,
|
||||
}
|
||||
except Exception:
|
||||
logging.warning("Failed to register uploaded image as asset", exc_info=True)
|
||||
|
||||
return web.json_response(resp)
|
||||
else:
|
||||
|
||||
118
tests-unit/assets_test/test_disabled_mode.py
Normal file
118
tests-unit/assets_test/test_disabled_mode.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""Boot ComfyUI with --disable-assets and verify the assets system is inert."""
|
||||
import contextlib
|
||||
import socket
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def autoclean_unit_test_assets():
|
||||
"""Override the package-level autouse cleaner: it boots the regular
|
||||
(assets-enabled) server, which this module does not need."""
|
||||
yield
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def _wait_assets_disabled(base: str, timeout: float = 90.0) -> None:
|
||||
start = time.time()
|
||||
last_err = None
|
||||
while time.time() - start < timeout:
|
||||
try:
|
||||
r = requests.get(base + "/api/assets", timeout=5)
|
||||
if r.status_code == 503:
|
||||
return
|
||||
last_err = RuntimeError(f"unexpected status {r.status_code}")
|
||||
except Exception as e:
|
||||
last_err = e
|
||||
time.sleep(0.25)
|
||||
raise RuntimeError(f"ComfyUI HTTP did not become ready: {last_err}")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def disabled_comfy(tmp_path_factory: pytest.TempPathFactory):
|
||||
"""Boot a ComfyUI subprocess with --disable-assets.
|
||||
|
||||
Yields (base_url, db_path) where db_path is where the sqlite database
|
||||
would be created if the assets system initialized it.
|
||||
"""
|
||||
base_dir = tmp_path_factory.mktemp("comfyui-assets-disabled")
|
||||
for sub in ("models", "custom_nodes", "input", "output", "temp", "user"):
|
||||
(base_dir / sub).mkdir(parents=True, exist_ok=True)
|
||||
db_path = base_dir / "assets-disabled.sqlite3"
|
||||
|
||||
logs_dir = base_dir / "logs"
|
||||
logs_dir.mkdir(exist_ok=True)
|
||||
out_log = open(logs_dir / "stdout.log", "w", buffering=1)
|
||||
err_log = open(logs_dir / "stderr.log", "w", buffering=1)
|
||||
|
||||
comfy_root = Path(__file__).resolve().parent.parent.parent
|
||||
port = _free_port()
|
||||
proc = subprocess.Popen(
|
||||
args=[
|
||||
sys.executable,
|
||||
"main.py",
|
||||
f"--base-directory={str(base_dir)}",
|
||||
f"--database-url=sqlite:///{db_path}",
|
||||
"--disable-assets",
|
||||
"--listen",
|
||||
"127.0.0.1",
|
||||
"--port",
|
||||
str(port),
|
||||
"--cpu",
|
||||
],
|
||||
stdout=out_log,
|
||||
stderr=err_log,
|
||||
cwd=str(comfy_root),
|
||||
)
|
||||
|
||||
base_url = f"http://127.0.0.1:{port}"
|
||||
try:
|
||||
_wait_assets_disabled(base_url)
|
||||
yield base_url, db_path
|
||||
finally:
|
||||
if proc.poll() is None:
|
||||
with contextlib.suppress(Exception):
|
||||
proc.terminate()
|
||||
proc.wait(timeout=15)
|
||||
out_log.close()
|
||||
err_log.close()
|
||||
|
||||
|
||||
def test_assets_routes_return_503(disabled_comfy):
|
||||
base_url, _ = disabled_comfy
|
||||
for path in ("/api/assets", "/api/tags", "/api/assets/seed/status"):
|
||||
r = requests.get(base_url + path, timeout=30)
|
||||
assert r.status_code == 503, (path, r.text)
|
||||
assert r.json()["error"]["code"] == "SERVICE_DISABLED"
|
||||
|
||||
|
||||
def test_database_not_created(disabled_comfy):
|
||||
_, db_path = disabled_comfy
|
||||
assert not db_path.exists()
|
||||
|
||||
|
||||
def test_seed_scan_rejected(disabled_comfy):
|
||||
base_url, _ = disabled_comfy
|
||||
r = requests.post(base_url + "/api/assets/seed", json={"roots": ["models"]}, timeout=30)
|
||||
assert r.status_code == 503
|
||||
|
||||
|
||||
def test_upload_image_skips_asset_registration(disabled_comfy):
|
||||
base_url, db_path = disabled_comfy
|
||||
files = {"image": ("disabled-mode-test.png", b"\x89PNG fake bytes", "image/png")}
|
||||
r = requests.post(base_url + "/upload/image", files=files, timeout=30)
|
||||
assert r.status_code == 200, r.text
|
||||
body = r.json()
|
||||
assert body["name"] == "disabled-mode-test.png"
|
||||
assert "asset" not in body
|
||||
assert not db_path.exists()
|
||||
@@ -1,9 +1,16 @@
|
||||
"""Tests for enrich_output_with_assets in comfy_execution/asset_enrichment.py."""
|
||||
import os
|
||||
import types
|
||||
import unittest
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
|
||||
def _make_args(disable_assets: bool):
|
||||
a = types.SimpleNamespace()
|
||||
a.disable_assets = disable_assets
|
||||
return a
|
||||
|
||||
|
||||
def _make_register_result(ref_id="ref-id-2"):
|
||||
result = MagicMock()
|
||||
result.ref.id = ref_id
|
||||
@@ -15,8 +22,9 @@ def _make_register_result(ref_id="ref-id-2"):
|
||||
_DEFAULT_BASE = os.path.join(__import__("tempfile").gettempdir(), "asset-enrichment-test-base")
|
||||
|
||||
|
||||
def _mocked_modules(*, register_file_in_place=None, directory=_DEFAULT_BASE):
|
||||
def _mocked_modules(*, disable_assets=False, register_file_in_place=None, directory=_DEFAULT_BASE):
|
||||
return {
|
||||
"comfy.cli_args": MagicMock(args=_make_args(disable_assets)),
|
||||
"folder_paths": MagicMock(get_directory_by_type=MagicMock(return_value=directory)),
|
||||
"app.assets.services.ingest": MagicMock(
|
||||
register_file_in_place=register_file_in_place or MagicMock(return_value=_make_register_result()),
|
||||
@@ -25,9 +33,10 @@ def _mocked_modules(*, register_file_in_place=None, directory=_DEFAULT_BASE):
|
||||
}
|
||||
|
||||
|
||||
def _call(output_ui, *, file_exists=True, register_result=None, directory=_DEFAULT_BASE):
|
||||
def _call(output_ui, *, disable_assets=False, file_exists=True, register_result=None, directory=_DEFAULT_BASE):
|
||||
register_mock = MagicMock(return_value=register_result or _make_register_result())
|
||||
mocked = _mocked_modules(
|
||||
disable_assets=disable_assets,
|
||||
register_file_in_place=register_mock,
|
||||
directory=directory,
|
||||
)
|
||||
@@ -44,6 +53,11 @@ def _call(output_ui, *, file_exists=True, register_result=None, directory=_DEFAU
|
||||
|
||||
class TestEnrichOutputWithAssets(unittest.TestCase):
|
||||
|
||||
def test_disabled_returns_unchanged(self):
|
||||
output = {"images": [{"filename": "a.png", "subfolder": "", "type": "output"}]}
|
||||
result = _call(output, disable_assets=True)
|
||||
self.assertNotIn("id", result["images"][0])
|
||||
|
||||
def test_non_list_value_passed_through(self):
|
||||
output = {"text": "hello"}
|
||||
result = _call(output)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Tests for feature flags functionality."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from comfy_api.feature_flags import (
|
||||
@@ -26,7 +28,8 @@ class TestFeatureFlags:
|
||||
|
||||
def test_get_server_features_contains_expected_flags(self):
|
||||
"""Test that server features contain expected flags."""
|
||||
features = get_server_features()
|
||||
with patch("app.assets.api.routes.assets_enabled", return_value=True):
|
||||
features = get_server_features()
|
||||
assert "supports_preview_metadata" in features
|
||||
assert features["supports_preview_metadata"] is True
|
||||
assert "supports_model_type_tags" in features
|
||||
@@ -34,6 +37,20 @@ class TestFeatureFlags:
|
||||
assert "max_upload_size" in features
|
||||
assert isinstance(features["max_upload_size"], (int, float))
|
||||
|
||||
def test_assets_flag_reflects_live_availability(self):
|
||||
"""The assets capability mirrors assets_enabled() rather than a static default."""
|
||||
with patch("app.assets.api.routes.assets_enabled", return_value=True):
|
||||
assert get_server_features()["assets"] is True
|
||||
with patch("app.assets.api.routes.assets_enabled", return_value=False):
|
||||
assert get_server_features()["assets"] is False
|
||||
|
||||
def test_model_type_tags_follow_assets_availability(self):
|
||||
"""supports_model_type_tags is an assets capability: off when assets are off."""
|
||||
with patch("app.assets.api.routes.assets_enabled", return_value=False):
|
||||
features = get_server_features()
|
||||
assert features["assets"] is False
|
||||
assert features["supports_model_type_tags"] is False
|
||||
|
||||
def test_get_connection_feature_with_missing_sid(self):
|
||||
"""Test getting feature for non-existent session ID."""
|
||||
sockets_metadata = {}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"""Simplified tests for WebSocket feature flags functionality."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from comfy_api import feature_flags
|
||||
|
||||
|
||||
@@ -7,7 +9,8 @@ class TestWebSocketFeatureFlags:
|
||||
|
||||
def test_server_feature_flags_response(self):
|
||||
"""Test server feature flags are properly formatted."""
|
||||
features = feature_flags.get_server_features()
|
||||
with patch("app.assets.api.routes.assets_enabled", return_value=True):
|
||||
features = feature_flags.get_server_features()
|
||||
|
||||
# Check expected server features
|
||||
assert "supports_preview_metadata" in features
|
||||
@@ -67,7 +70,8 @@ class TestWebSocketFeatureFlags:
|
||||
assert "supports_preview_metadata" in client_message["data"]
|
||||
|
||||
# Server response format (what would be sent)
|
||||
server_features = feature_flags.get_server_features()
|
||||
with patch("app.assets.api.routes.assets_enabled", return_value=True):
|
||||
server_features = feature_flags.get_server_features()
|
||||
server_message = {
|
||||
"type": "feature_flags",
|
||||
"data": server_features
|
||||
|
||||
Reference in New Issue
Block a user