Files
Faych 5fa366d4d6 feat: Migrating the project into ComfyUI native style (#42)
* docs: design ComfyUI-native BAGEL migration

* docs: plan ComfyUI-native BAGEL migration

* docs: align BAGEL migration with custom node layout

* docs: define BAGEL test environment constraints

* docs: split BAGEL nodes by responsibility

* docs: retain flat custom node entrypoints

* feat: add converted BAGEL runtime loader

* feat: register native BAGEL ComfyUI nodes

* docs: document native BAGEL workflows

* fix: add custom node root to import path

* fix: log skipped BAGEL model discovery entries

* fix: prefer BAGEL model folder for native checkpoints

* feat: add BAGEL sidecar metadata helper

* fix: list BAGEL safetensors without metadata precheck

* fix: preserve BAGEL extra model paths

* fix: set Qwen token ids for BAGEL config fallback

* fix: decouple default Qwen RoPE from transformers registry

* feat: add streaming BAGEL metadata embedder

* feat: add free Colab metadata release workflow

* feat: describe BAGEL variant release metadata

* feat: prepare remaining BAGEL metadata releases

* fix: declare Echo-4o release as fp16

* fix: validate BAGEL latent image geometry

* fix: align BAGEL edit workflow preprocessing

* docs: add native BAGEL workflow figures

* chore: prepare 1.1.0 release contents

* docs: finalize release README and metadata validation

* fix: restore native BAGEL parity and lifecycle

* fix: align native controls with BAGEL app

* fix: migrate native thinking workflow controls

* docs: update native workflow examples

* fix: mark legacy nodes as deprecated
2026-07-31 01:56:08 +01:00

245 lines
9.5 KiB
Python

"""Embed ``comfyui_bagel`` metadata into an existing safetensors file.
This is a bounded-memory release repair tool for converted checkpoints whose
tensor payload is already correct but whose safetensors header predates the
native BAGEL metadata schema. It rewrites only the header and streams the
existing tensor data unchanged; tensors are never loaded or cast.
Usage:
python scripts/embed_bagel_metadata.py \
--source bagel-7b-mot.safetensors \
--metadata bagel-7b-mot.metadata.json \
--output bagel-7b-mot.with-metadata.safetensors
For storage-constrained release servers, ``--in-place`` shifts the tensor
payload inside the server-side working copy and verifies its SHA-256 before
and after the shift. The remote Hub file is not touched by this script.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import shutil
import struct
import sys
from pathlib import Path
_ALIGN = 8
_COPY_BUFFER_BYTES = 8 << 20
def _read_header(source) -> tuple[dict, int, int]:
prefix = source.read(8)
if len(prefix) != 8:
raise ValueError("source is too small to be a safetensors file")
header_len = struct.unpack("<Q", prefix)[0]
header_bytes = source.read(header_len)
if len(header_bytes) != header_len:
raise ValueError("truncated safetensors header")
try:
header = json.loads(header_bytes)
except Exception as exc:
raise ValueError(f"invalid safetensors JSON header: {exc}") from exc
if not isinstance(header, dict):
raise ValueError("safetensors header must be a JSON object")
tensor_entries = [value for key, value in header.items() if key != "__metadata__"]
if not tensor_entries:
raise ValueError("safetensors file contains no tensors")
try:
payload_len = max(int(entry["data_offsets"][1]) for entry in tensor_entries)
except Exception as exc:
raise ValueError("invalid tensor data_offsets in safetensors header") from exc
return header, 8 + header_len, payload_len
def _load_metadata(path: Path) -> str:
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except Exception as exc:
raise ValueError(f"invalid metadata JSON {path}: {exc}") from exc
# Accept either a direct ConvertedBagelMetadata object or the release
# sidecar schema previously published for bagel-7b-mot.
if raw.get("format") == "comfyui_bagel_sidecar":
raw = raw.get("metadata")
if not isinstance(raw, dict) or raw.get("format") != "comfyui_bagel":
raise ValueError(
"metadata must be a comfyui_bagel object or a comfyui_bagel_sidecar"
)
if int(raw.get("format_version", 0)) != 1:
raise ValueError("only comfyui_bagel format_version 1 is supported")
return json.dumps(raw, sort_keys=True, separators=(",", ":"))
def _encode_header(header: dict) -> bytes:
encoded = json.dumps(header, separators=(",", ":")).encode("utf-8")
padding = (_ALIGN - len(encoded) % _ALIGN) % _ALIGN
return encoded + b" " * padding
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as file:
for chunk in iter(lambda: file.read(_COPY_BUFFER_BYTES), b""):
digest.update(chunk)
return digest.hexdigest()
def _sha256_payload(path: Path, data_start: int) -> str:
digest = hashlib.sha256()
with path.open("rb") as file:
file.seek(data_start)
for chunk in iter(lambda: file.read(_COPY_BUFFER_BYTES), b""):
digest.update(chunk)
return digest.hexdigest()
def _merge_metadata(header: dict, metadata_json: str) -> bytes:
existing_metadata = header.get("__metadata__") or {}
if not isinstance(existing_metadata, dict):
raise ValueError("safetensors __metadata__ must be a JSON object")
header["__metadata__"] = {
**existing_metadata,
"comfyui_bagel": metadata_json,
}
return _encode_header(header)
def _rewrite_in_place(
path: Path,
old_data_start: int,
payload_len: int,
encoded_header: bytes,
) -> str:
"""Move payload safely within a disposable server copy, then write header."""
new_data_start = 8 + len(encoded_header)
delta = new_data_start - old_data_start
payload_sha256 = _sha256_payload(path, old_data_start)
with path.open("r+b") as file:
if delta > 0:
# Expanding the header requires copying backwards so unread source
# bytes are never overwritten by their shifted destination.
file.truncate(new_data_start + payload_len)
remaining = payload_len
while remaining:
chunk_size = min(_COPY_BUFFER_BYTES, remaining)
source_offset = old_data_start + remaining - chunk_size
destination_offset = new_data_start + remaining - chunk_size
file.seek(source_offset)
chunk = file.read(chunk_size)
if len(chunk) != chunk_size:
raise IOError("short read while shifting safetensors payload")
file.seek(destination_offset)
file.write(chunk)
remaining -= chunk_size
elif delta < 0:
# Shrinking can copy forwards for the symmetric overlap reason.
moved = 0
while moved < payload_len:
chunk_size = min(_COPY_BUFFER_BYTES, payload_len - moved)
file.seek(old_data_start + moved)
chunk = file.read(chunk_size)
if len(chunk) != chunk_size:
raise IOError("short read while shifting safetensors payload")
file.seek(new_data_start + moved)
file.write(chunk)
moved += chunk_size
file.truncate(new_data_start + payload_len)
file.seek(0)
file.write(struct.pack("<Q", len(encoded_header)))
file.write(encoded_header)
file.flush()
os.fsync(file.fileno())
rewritten_sha256 = _sha256_payload(path, new_data_start)
if rewritten_sha256 != payload_sha256:
raise IOError(
"payload verification failed after in-place rewrite; do not upload "
f"this working copy (before={payload_sha256}, after={rewritten_sha256})"
)
return payload_sha256
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--source", required=True, type=Path)
parser.add_argument("--metadata", required=True, type=Path)
destination = parser.add_mutually_exclusive_group(required=True)
destination.add_argument("--output", type=Path)
destination.add_argument(
"--in-place",
action="store_true",
help="rewrite the disposable server working copy without a second 29GB file",
)
parser.add_argument("--force", action="store_true")
args = parser.parse_args()
source_path = args.source.resolve()
output_path = source_path if args.in_place else args.output.resolve()
if not args.in_place and source_path == output_path:
print("ERROR: use --in-place when --output equals --source", file=sys.stderr)
return 2
if not args.in_place and output_path.exists() and not args.force:
print(f"ERROR: output exists (use --force): {output_path}", file=sys.stderr)
return 2
metadata_json = _load_metadata(args.metadata)
output_path.parent.mkdir(parents=True, exist_ok=True)
temporary = None if args.in_place else output_path.with_name(output_path.name + ".tmp")
payload_sha256 = None
try:
with source_path.open("rb") as source:
header, data_start, payload_len = _read_header(source)
source_size = os.fstat(source.fileno()).st_size
if source_size - data_start != payload_len:
raise ValueError(
"tensor payload size does not match header offsets: "
f"header={payload_len}, file={source_size - data_start}"
)
encoded_header = _merge_metadata(header, metadata_json)
if not args.in_place:
source.seek(data_start)
with temporary.open("wb") as destination_file:
destination_file.write(struct.pack("<Q", len(encoded_header)))
destination_file.write(encoded_header)
shutil.copyfileobj(
source, destination_file, length=_COPY_BUFFER_BYTES
)
if args.in_place:
payload_sha256 = _rewrite_in_place(
source_path, data_start, payload_len, encoded_header
)
else:
os.replace(temporary, output_path)
except Exception as exc:
if temporary is not None and temporary.exists():
temporary.unlink()
print(f"ERROR: {exc}", file=sys.stderr)
return 1
print(
json.dumps(
{
"source": str(source_path),
"output": str(output_path),
"output_size": output_path.stat().st_size,
# In-place mode already reads the 29GB payload twice for its
# before/after invariant. Avoid a third full-file pass on a
# free runtime; the Hub upload computes its own file identity.
"output_sha256": None if args.in_place else _sha256(output_path),
"payload_sha256": payload_sha256,
"in_place": args.in_place,
"metadata_key": "comfyui_bagel",
},
indent=2,
)
)
return 0
if __name__ == "__main__":
raise SystemExit(main())