Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
777 changes: 777 additions & 0 deletions apps/api/src/hcs_api/comfyui_compatibility.py

Large diffs are not rendered by default.

35 changes: 17 additions & 18 deletions apps/api/src/hcs_api/comfyui_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,6 @@
import hashlib
import json
import os
import platform
import re
import secrets
import shutil
import stat
Expand All @@ -20,6 +18,10 @@
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator

from . import storage
from .comfyui_compatibility import (
LocalImageCompatibilityError,
require_machine_compatibility,
)
from .comfyui_runtime import (
ComfyUIRuntimeError,
RuntimeDirectoryIdentity,
Expand Down Expand Up @@ -441,6 +443,7 @@ def load_workflow_pack() -> ComfyUIWorkflowPack:
WORKFLOW_PACK_LOADER: Callable[[], ComfyUIWorkflowPack] = load_workflow_pack
RUNTIME_SNAPSHOT = runtime_snapshot
DISK_USAGE = shutil.disk_usage
COMPATIBILITY_GUARD = require_machine_compatibility


def _model_root() -> Path:
Expand Down Expand Up @@ -719,22 +722,14 @@ def _tree_identity(record: ModelInstallationRecord) -> str:
return hashlib.sha256(payload).hexdigest()


def _validate_platform(manifest: ComfyUIModelManifest) -> None:
adapter = manifest.platforms[0]
machine = platform.machine().lower()
if platform.system() != "Darwin" or machine not in {"arm64", "aarch64"}:
raise ComfyUIModelError("unsupported_platform", "The fixed model package supports only macOS Apple Silicon")
version = tuple(int(part) for part in re.findall(r"\d+", platform.mac_ver()[0])[:2])
if version < (14, 0):
raise ComfyUIModelError("unsupported_platform", "The fixed model package requires macOS 14 or newer")
def _validate_platform(
_manifest: ComfyUIModelManifest,
action: Literal["install_model", "repair_model", "generate"] = "install_model",
) -> None:
try:
pages = os.sysconf("SC_PHYS_PAGES")
page_size = os.sysconf("SC_PAGE_SIZE")
memory_mb = pages * page_size // (1024**2)
except (OSError, ValueError):
memory_mb = adapter.minimum_memory_mb
if memory_mb < adapter.minimum_memory_mb:
raise ComfyUIModelError("insufficient_memory", "The fixed model package requires at least 16 GB memory")
COMPATIBILITY_GUARD(action)
except LocalImageCompatibilityError as exc:
raise ComfyUIModelError(exc.code, exc.message) from exc


def _assert_runtime_stopped(manifest: ComfyUIModelManifest, *, require_installed: bool) -> None:
Expand Down Expand Up @@ -1197,7 +1192,10 @@ def run_model_install(
WORKFLOW_PACK_LOADER()
with _MODEL_MUTATION_LOCK:
recover_model_installations()
_validate_platform(manifest)
_validate_platform(
manifest,
"repair_model" if operation == "repair" else "install_model",
)
_assert_runtime_stopped(manifest, require_installed=True)
current = _read_installation()
if operation == "install" and current is not None:
Expand Down Expand Up @@ -1270,6 +1268,7 @@ def download_progress(current_bytes: int, total_bytes: int) -> None:
cancel()
progress("installing_workflow", 82, "正在校验固定官方节点工作流", total, total)
WORKFLOW_PACK_LOADER()
_validate_platform(manifest, "generate")
journal.phase = "publish_prepared"
_write_journal(journal)
if current:
Expand Down
33 changes: 33 additions & 0 deletions apps/api/src/hcs_api/comfyui_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@
secure_dirfd_extraction_supported,
sha256_file,
)
from .comfyui_compatibility import (
LocalImageCompatibilityError,
require_machine_compatibility,
)


RuntimeStatus = Literal[
Expand Down Expand Up @@ -90,6 +94,7 @@
"runtime_identity_mismatch", "runtime_crashed", "runtime_stop_failed",
"runtime_health_failed", "runtime_modified", "repair_failed", "uninstall_failed",
"confirmation_invalid", "confirmation_expired", "confirmation_stale",
"machine_compatibility_unknown", "machine_incompatible",
"task_conflict", "cancelled", "internal_error",
})

Expand Down Expand Up @@ -436,6 +441,7 @@ class RuntimeSnapshot(_StrictModel):

ProgressCallback = Callable[[str, int, str, int | None, int | None], None]
CancellationCheck = Callable[[], None]
COMPATIBILITY_GUARD = require_machine_compatibility


def _iso() -> str:
Expand All @@ -460,6 +466,15 @@ def _runtime_manifest() -> ComfyUIRuntimeManifest:
raise ComfyUIRuntimeError("runtime_manifest_invalid", "ComfyUI Runtime manifest or dependency lock is invalid") from exc


def _require_machine_compatibility(
action: Literal["install_runtime", "repair_runtime", "start_runtime"],
) -> None:
try:
COMPATIBILITY_GUARD(action)
except LocalImageCompatibilityError as exc:
raise ComfyUIRuntimeError(exc.code, exc.message) from exc


def _public_archive_error_code(code: str) -> str:
if code == "checksum_mismatch":
return code
Expand Down Expand Up @@ -2296,6 +2311,10 @@ def run_runtime_install(
"confirmation_invalid", "Repair requires a valid backend confirmation"
)
assert_runtime_operation_identity(confirmation)
_require_machine_compatibility(
"repair_runtime" if operation == "repair" else "install_runtime"
)
if operation == "repair":
try:
stop_runtime(force=False)
except ComfyUIRuntimeError as exc:
Expand Down Expand Up @@ -2428,6 +2447,7 @@ def run_runtime_install(
RUNTIME_VALIDATOR(payload, manifest)
_update_journal(journal, "runtime_validated")
cancel()
_require_machine_compatibility("start_runtime")
_update_journal(journal, "publish_prepared")
progress("publishing_runtime", 92, "正在发布受控运行环境", None, None)
backup_identity = _optional_managed_directory_identity(
Expand Down Expand Up @@ -3302,6 +3322,17 @@ def _health_probe(process: ComfyUIRuntimeProcess, manifest: ComfyUIRuntimeManife
)[2:]
if not isinstance(api_argv, list) or [str(value) for value in api_argv] != expected_without_python:
raise ComfyUIRuntimeError("runtime_identity_mismatch", "ComfyUI API arguments do not match the managed process")
devices = system_stats.get("devices")
if (
not isinstance(devices, list)
or not devices
or not isinstance(devices[0], dict)
or devices[0].get("type") != "mps"
):
raise ComfyUIRuntimeError(
"runtime_identity_mismatch",
"The fixed Runtime did not report MPS as its primary inference device",
)
object_info = _read_http_json(
process.ownership.port,
"/object_info",
Expand Down Expand Up @@ -3457,6 +3488,7 @@ def check_runtime_health() -> RuntimeHealthSnapshot:
def start_runtime() -> RuntimeHealthSnapshot:
manifest = _runtime_manifest()
with _PROCESS_LOCK:
_require_machine_compatibility("start_runtime")
state = _read_state()
if not state.installed:
raise ComfyUIRuntimeError("runtime_not_installed", "Install ComfyUI Runtime before starting it")
Expand Down Expand Up @@ -3501,6 +3533,7 @@ def start_runtime() -> RuntimeHealthSnapshot:
supervisor_script_sha256 = sha256_file(supervisor_script)
except OSError as exc:
raise ComfyUIRuntimeError("runtime_validation_failed", "Managed process supervisor cannot be verified") from exc
_require_machine_compatibility("start_runtime")
request_path = _supervisor_request_path(nonce)
_atomic_json(request_path, {
"argv": runtime_argv,
Expand Down
52 changes: 46 additions & 6 deletions apps/api/src/hcs_api/comfyui_teaching_image.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@

from pydantic import BaseModel, ConfigDict, Field, field_validator

from .comfyui_compatibility import (
LocalImageCompatibilityError,
machine_compatibility_snapshot,
require_machine_compatibility,
)
from .comfyui_model import (
MODEL_MANIFEST_SHA256,
WORKFLOW_PACK_SHA256,
Expand Down Expand Up @@ -65,6 +70,8 @@
_ASSET_MANIFEST_LIMIT = 16 * 1024 * 1024
_CAPABILITY_CACHE_LOCK = threading.RLock()
_CAPABILITY_CACHE: dict[str, GenerationCapabilitySnapshot] = {}
MACHINE_COMPATIBILITY_SNAPSHOT = machine_compatibility_snapshot
COMPATIBILITY_GUARD = require_machine_compatibility


class TeachingImageError(RuntimeError):
Expand Down Expand Up @@ -156,15 +163,17 @@ class CompiledTeachingImagePlan(_StrictModel):


class GenerationCapabilitySnapshot(_StrictModel):
schema_: Literal["hanclassstudio.local_image_generation_capability.v1"] = Field(
default="hanclassstudio.local_image_generation_capability.v1", alias="schema"
schema_: Literal["hanclassstudio.local_image_generation_capability.v2"] = Field(
default="hanclassstudio.local_image_generation_capability.v2", alias="schema"
)
runtime_installed: bool
runtime_ready: bool
model_compatible: bool
model_installed: bool
model_ready: bool
workflow_ready: bool
generation_ready: bool
compatibility_contract_sha256: str = Field(pattern=r"^[0-9a-f]{64}$")
checked_at: str
technical_error: dict[str, str] | None = None

Expand Down Expand Up @@ -274,7 +283,7 @@ def _checkpoint_is_available(object_info: Any) -> bool:
)


def _capability_fingerprint(runtime: Any, model: Any) -> str:
def _capability_fingerprint(runtime: Any, model: Any, compatibility: Any) -> str:
return _sha256_payload(
{
"runtime_version": runtime.version,
Expand All @@ -290,23 +299,46 @@ def _capability_fingerprint(runtime: Any, model: Any) -> str:
"workflow_pack_id": model.workflow_pack_id,
"workflow_version": model.workflow_version,
"workflow_sha256": WORKFLOW_PACK_SHA256,
"model_compatible": compatibility.model_compatible,
"compatibility_status": compatibility.status,
"compatibility_contract_sha256": compatibility.contract_sha256,
}
)


def generation_capability_snapshot(*, deep: bool = False) -> GenerationCapabilitySnapshot:
compatibility = MACHINE_COMPATIBILITY_SNAPSHOT(force_refresh=deep)
runtime = runtime_snapshot()
model = model_snapshot(deep=deep)
workflow_ready = model.workflow_ready
error = model.technical_error
runtime_ready = runtime.runtime_ready
if not compatibility.model_compatible:
blocking = next(
(reason for reason in compatibility.reasons if reason.blocking),
None,
)
error = {
"code": (
"machine_compatibility_unknown"
if compatibility.status == "unknown"
else "machine_incompatible"
),
"message": (
blocking.message
if blocking is not None
else "The machine is not compatible with local image generation"
),
}
fingerprint = (
_capability_fingerprint(runtime, model)
if deep or (runtime_ready and model.model_ready and workflow_ready)
_capability_fingerprint(runtime, model, compatibility)
if compatibility.model_compatible
and (deep or (runtime_ready and model.model_ready and workflow_ready))
else ""
)
if (
not deep
and compatibility.model_compatible
and runtime_ready
and model.model_ready
and workflow_ready
Expand All @@ -316,7 +348,7 @@ def generation_capability_snapshot(*, deep: bool = False) -> GenerationCapabilit
cached = _CAPABILITY_CACHE.get(fingerprint)
if cached is not None:
return cached
if deep:
if deep and compatibility.model_compatible:
try:
health = check_runtime_health()
runtime_ready = health.healthy and health.identity_verified and health.custom_nodes_pristine
Expand Down Expand Up @@ -352,6 +384,7 @@ def generation_capability_snapshot(*, deep: bool = False) -> GenerationCapabilit
error = {"code": exc.code, "message": exc.message}
ready = (
deep
and compatibility.model_compatible
and runtime_ready
and model.model_ready
and workflow_ready
Expand All @@ -360,10 +393,12 @@ def generation_capability_snapshot(*, deep: bool = False) -> GenerationCapabilit
snapshot = GenerationCapabilitySnapshot(
runtime_installed=runtime.installed,
runtime_ready=runtime_ready,
model_compatible=compatibility.model_compatible,
model_installed=model.installed,
model_ready=model.model_ready,
workflow_ready=workflow_ready,
generation_ready=ready,
compatibility_contract_sha256=compatibility.contract_sha256,
checked_at=_iso(),
technical_error=error,
)
Expand Down Expand Up @@ -865,11 +900,14 @@ def _assert_asset_id_available(manifest: AssetManifest, asset_id: str) -> None:
def _revalidate_generation_context(plan: CompiledTeachingImagePlan) -> None:
"""Reject output if any approved execution identity changed while it ran."""
try:
COMPATIBILITY_GUARD("generate")
runtime = runtime_snapshot(recover=False)
current_runtime_identity = runtime_installation_identity()
model_record = validate_model_installation(deep=False)
current_model_identity = model_installation_identity(model_record)
workflow = load_workflow_pack()
except LocalImageCompatibilityError as exc:
raise TeachingImageError(exc.code, exc.message) from exc
except (ComfyUIRuntimeError, ComfyUIModelError) as exc:
raise TeachingImageError(
"generation_identity_mismatch",
Expand Down Expand Up @@ -1027,12 +1065,14 @@ def generate_teaching_image(
try:
if before_publication is not None:
before_publication()
_revalidate_generation_context(plan)
_write_new_file(image_path, payload)
wrote.append(image_path)
_write_new_file(provenance_path, provenance_bytes)
wrote.append(provenance_path)
if before_publication is not None:
before_publication()
_revalidate_generation_context(plan)
manifest_path = _real_directory(project_root, ("assets", "data")) / "asset_manifest.json"
_write_manifest(manifest_path, manifest)
except Exception:
Expand Down
17 changes: 17 additions & 0 deletions apps/api/src/hcs_api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@
hub_catalog,
prepare_comfyui_mutation,
prepare_comfyui_model_mutation,
recheck_comfyui_machine_compatibility,
save_online_config,
set_online_disabled,
start_comfyui_mutation,
Expand Down Expand Up @@ -1114,6 +1115,9 @@ def _provider_hub_http_error(error: ProviderHubError) -> HTTPException:
"confirmation_invalid": 409,
"confirmation_expired": 409,
"confirmation_stale": 409,
"machine_compatibility_unknown": 409,
"machine_incompatible": 409,
"insufficient_disk": 409,
"unsupported_platform": 400,
"runtime_start_failed": 503,
"runtime_start_timeout": 504,
Expand Down Expand Up @@ -1369,6 +1373,19 @@ def check_provider_generation_health(package_id: str) -> dict[str, Any]:
raise _provider_hub_http_error(error) from error


@app.post(
"/api/providers/hub/packages/{package_id}/compatibility/check",
response_model=ProviderHubItem,
)
def recheck_provider_machine_compatibility(package_id: str) -> dict[str, Any]:
try:
return recheck_comfyui_machine_compatibility(package_id).model_dump(
mode="json"
)
except ProviderHubError as error:
raise _provider_hub_http_error(error) from error


@app.post("/api/providers/hub/packages/{package_id}/start", response_model=ProviderHubItem)
def start_provider_runtime(package_id: str) -> dict[str, Any]:
try:
Expand Down
Loading
Loading