Files
comfyui_o1key/utils/o1key_image_save.py
T
Jony ba920f2b66 Publish current ComfyUI O1Key code baseline
Replace the prior release tree with the current plugin, frontend, tests, and documentation. Document retired node IDs and the public Gitea update source.
2026-09-24 19:56:48 +08:00

599 lines
20 KiB
Python

"""Format-aware saving for the unified o1key image workflow.
The generator keeps provider bytes in memory or in ComfyUI's temp directory.
Only the save node promotes those results into a permanent save directory.
"""
from __future__ import annotations
import json
import os
import re
import struct
import threading
import uuid
import zlib
from io import BytesIO
from typing import Any, Iterable
import numpy as np
from PIL import Image
from PIL.PngImagePlugin import PngInfo
SAVE_FORMAT_OPTIONS = ("原始", "png", "jpg", "webp")
SAVE_NAMING_RULE_OPTIONS = ("和主图一致", "自然数字", "自定义前缀")
DEFAULT_SAVE_NAMING_RULE = "自定义前缀"
_FORMAT_EXTENSIONS = {"PNG": "png", "JPEG": "jpg", "WEBP": "webp"}
_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
_SAVE_LOCK = threading.Lock()
_UNSAFE_FILENAME_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
_WINDOWS_RESERVED_STEMS = {
"CON", "PRN", "AUX", "NUL",
*(f"COM{index}" for index in range(1, 10)),
*(f"LPT{index}" for index in range(1, 10)),
}
def normalize_save_format(value: Any) -> str:
normalized = str(value or "原始").strip().lower()
if normalized == "jpeg":
normalized = "jpg"
if normalized == "原始":
return "原始"
if normalized not in {"png", "jpg", "webp"}:
raise ValueError("保存格式仅支持:原始、png、jpg、webp")
return normalized
def normalize_save_location(value: Any) -> str:
"""Return an output-relative directory or an explicit absolute directory."""
raw = str(value or "").strip().replace("\\", "/")
if not raw or raw.lower() == "output" or raw == ".":
return ""
drive, _ = os.path.splitdrive(raw)
if os.path.isabs(raw):
return os.path.normpath(raw)
if drive:
raise ValueError("盘符路径必须填写完整绝对路径,例如 D:/图片")
parts = [part for part in raw.split("/") if part not in {"", "."}]
if not parts or any(part == ".." for part in parts):
raise ValueError("相对保存位置必须是 output 下的子目录,不能包含 ..")
return os.path.join(*parts)
def _external_preview_descriptor(
encoded: bytes,
filename: str,
folder_paths_module,
) -> dict[str, Any]:
"""Keep external saves previewable without exposing local paths to workflows."""
temp_root = os.path.abspath(folder_paths_module.get_temp_directory())
preview_subfolder = os.path.join("o1key_external_preview", uuid.uuid4().hex)
preview_directory = os.path.join(temp_root, preview_subfolder)
os.makedirs(preview_directory, exist_ok=False)
_atomic_write(os.path.join(preview_directory, filename), encoded)
return {
"filename": filename,
"subfolder": preview_subfolder,
"type": "temp",
"external_saved": True,
}
def normalize_naming_rule(value: Any) -> str:
normalized = str(value or DEFAULT_SAVE_NAMING_RULE).strip()
if normalized not in SAVE_NAMING_RULE_OPTIONS:
raise ValueError("命名规则仅支持:和主图一致、自然数字、自定义前缀")
return normalized
def _safe_filename_stem(value: Any) -> str:
filename = os.path.basename(str(value or "").strip().replace("\\", "/"))
stem, _ = os.path.splitext(filename)
stem = _UNSAFE_FILENAME_CHARS.sub("_", stem).strip(" .")
stem = stem[:240] or "o1key"
if stem.upper() in _WINDOWS_RESERVED_STEMS:
stem = f"_{stem}"
return stem
def detect_image_format(data: bytes) -> str | None:
if data.startswith(_PNG_SIGNATURE):
return "PNG"
if data.startswith(b"\xff\xd8\xff"):
return "JPEG"
if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
return "WEBP"
return None
def _metadata_enabled() -> bool:
try:
from comfy.cli_args import args
return not bool(args.disable_metadata)
except Exception:
return True
def _metadata_items(
prompt: Any = None,
extra_pnginfo: dict[str, Any] | None = None,
) -> list[tuple[str, str]]:
if not _metadata_enabled():
return []
items: list[tuple[str, str]] = []
if prompt is not None:
items.append(("prompt", json.dumps(prompt)))
if isinstance(extra_pnginfo, dict):
for key, value in extra_pnginfo.items():
items.append((str(key), json.dumps(value)))
return items
def _has_workflow_metadata(extra_pnginfo: dict[str, Any] | None) -> bool:
"""Match ComfyUI's recoverable image contract.
The frontend metadata parser restores workflows from PNG and WebP, but not
from JPEG. Native SaveImage always writes PNG, so metadata-bearing JPEG
results must use that same container instead of an EXIF-only JPEG.
"""
return (
_metadata_enabled()
and isinstance(extra_pnginfo, dict)
and extra_pnginfo.get("workflow") is not None
)
def _png_text_chunk(key: str, value: str) -> bytes:
payload = key.encode("latin-1", "replace") + b"\0" + value.encode(
"latin-1", "replace"
)
chunk_type = b"tEXt"
return (
struct.pack(">I", len(payload))
+ chunk_type
+ payload
+ struct.pack(">I", zlib.crc32(chunk_type + payload) & 0xFFFFFFFF)
)
def inject_png_metadata(data: bytes, metadata: Iterable[tuple[str, str]]) -> bytes:
"""Append tEXt chunks without decoding or recompressing PNG pixels."""
chunks = [_png_text_chunk(key, value) for key, value in metadata]
if not chunks or not data.startswith(_PNG_SIGNATURE):
return data
offset = len(_PNG_SIGNATURE)
while offset + 12 <= len(data):
length = struct.unpack(">I", data[offset : offset + 4])[0]
end = offset + 12 + length
if end > len(data):
return data
if data[offset + 4 : offset + 8] == b"IEND":
return data[:offset] + b"".join(chunks) + data[offset:]
offset = end
return data
def _create_exif_bytes(metadata: Iterable[tuple[str, str]]) -> bytes:
items = list(metadata)
if not items:
return b""
exif = Image.Exif()
extra_index = 0
for key, value in items:
if key == "prompt":
exif[0x0110] = f"prompt:{value}"
else:
exif[0x010F - extra_index] = f"{key}:{value}"
extra_index += 1
try:
return exif.tobytes()
except (OSError, OverflowError, ValueError):
# JPEG APP1 and some Pillow EXIF writers have practical size limits.
# The image must still save; PNG remains the fully lossless metadata path.
return b""
def inject_jpeg_metadata(data: bytes, metadata: Iterable[tuple[str, str]]) -> bytes:
"""Insert an EXIF APP1 segment without recompressing JPEG pixels."""
if not data.startswith(b"\xff\xd8"):
return data
exif = _create_exif_bytes(metadata)
if not exif or len(exif) + 2 > 0xFFFF:
return data
segment = b"\xff\xe1" + struct.pack(">H", len(exif) + 2) + exif
return data[:2] + segment + data[2:]
def _webp_chunks(data: bytes) -> list[tuple[bytes, bytes]] | None:
if len(data) < 12 or data[:4] != b"RIFF" or data[8:12] != b"WEBP":
return None
chunks: list[tuple[bytes, bytes]] = []
offset = 12
while offset + 8 <= len(data):
kind = data[offset : offset + 4]
length = struct.unpack("<I", data[offset + 4 : offset + 8])[0]
start = offset + 8
end = start + length
if end > len(data):
return None
chunks.append((kind, data[start:end]))
offset = end + (length & 1)
return chunks
def _pack_webp_chunk(kind: bytes, payload: bytes) -> bytes:
padding = b"\0" if len(payload) & 1 else b""
return kind + struct.pack("<I", len(payload)) + payload + padding
def inject_webp_metadata(data: bytes, metadata: Iterable[tuple[str, str]]) -> bytes:
"""Add/replace a WebP EXIF chunk without recompressing image data."""
exif = _create_exif_bytes(metadata)
chunks = _webp_chunks(data)
if not exif or chunks is None:
return data
chunks = [(kind, payload) for kind, payload in chunks if kind != b"EXIF"]
vp8x_index = next((i for i, item in enumerate(chunks) if item[0] == b"VP8X"), None)
if vp8x_index is None:
try:
with Image.open(BytesIO(data)) as image:
width, height = image.size
has_alpha = "A" in image.getbands()
except Exception:
return data
flags = 0x08 | (0x10 if has_alpha else 0)
vp8x = bytes((flags, 0, 0, 0)) + (width - 1).to_bytes(3, "little") + (
height - 1
).to_bytes(3, "little")
chunks.insert(0, (b"VP8X", vp8x))
else:
kind, payload = chunks[vp8x_index]
if len(payload) != 10:
return data
chunks[vp8x_index] = (kind, bytes((payload[0] | 0x08,)) + payload[1:])
chunks.append((b"EXIF", exif))
body = b"WEBP" + b"".join(_pack_webp_chunk(kind, payload) for kind, payload in chunks)
return b"RIFF" + struct.pack("<I", len(body)) + body
def inject_workflow_metadata(
data: bytes,
image_format: str,
prompt: Any = None,
extra_pnginfo: dict[str, Any] | None = None,
) -> bytes:
metadata = _metadata_items(prompt, extra_pnginfo)
if not metadata:
return data
if image_format == "PNG":
return inject_png_metadata(data, metadata)
if image_format == "JPEG":
return inject_jpeg_metadata(data, metadata)
if image_format == "WEBP":
return inject_webp_metadata(data, metadata)
return data
def _atomic_write(path: str, data: bytes) -> None:
temporary = f"{path}.{uuid.uuid4().hex}.tmp"
reserved = False
try:
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL)
os.close(descriptor)
reserved = True
with open(temporary, "wb") as handle:
handle.write(data)
os.replace(temporary, path)
reserved = False
finally:
if os.path.exists(temporary):
try:
os.remove(temporary)
except OSError:
pass
if reserved and os.path.exists(path):
try:
os.remove(path)
except OSError:
pass
def _tensor_uint8(image_tensor) -> np.ndarray:
return np.clip(255.0 * image_tensor.detach().cpu().numpy(), 0, 255).astype(np.uint8)
def _raw_bytes_match_tensor(data: bytes, image_tensor) -> bool:
try:
array = _tensor_uint8(image_tensor)
mode = "RGBA" if array.shape[-1] == 4 else "RGB"
with Image.open(BytesIO(data)) as source:
source.load()
decoded = np.asarray(source.convert(mode), dtype=np.uint8)
return decoded.shape == array.shape and np.array_equal(decoded, array)
except Exception:
return False
def _source_for_tensor(images, index: int) -> tuple[bytes | None, str | None, str | None]:
metadata = getattr(images, "_o1key_source_metadata", None)
if not isinstance(metadata, list) or index >= len(metadata):
return None, None, None
item = metadata[index]
if not isinstance(item, dict):
return None, None, None
source_filename = item.get("filename")
if not isinstance(source_filename, str) or not source_filename.strip():
source_filename = None
if item.get("modified"):
return None, None, source_filename
data = item.get("bytes")
if not isinstance(data, bytes):
source_path = item.get("path")
if isinstance(source_path, str) and os.path.isfile(source_path):
try:
with open(source_path, "rb") as handle:
data = handle.read()
except OSError:
data = None
if not isinstance(data, bytes) or not _raw_bytes_match_tensor(data, images[index]):
data = None
image_format = detect_image_format(data) if data else None
if image_format is None:
hinted = str(item.get("format") or "").upper()
if hinted == "JPG":
hinted = "JPEG"
image_format = hinted if hinted in _FORMAT_EXTENSIONS else None
return data, image_format, source_filename
def _pil_from_tensor(image_tensor) -> Image.Image:
return Image.fromarray(_tensor_uint8(image_tensor))
def _encode_image(
image: Image.Image,
image_format: str,
prompt: Any = None,
extra_pnginfo: dict[str, Any] | None = None,
) -> bytes:
output = BytesIO()
metadata = _metadata_items(prompt, extra_pnginfo)
working = image
converted = None
try:
if image_format == "PNG":
pnginfo = None
if metadata:
pnginfo = PngInfo()
for key, value in metadata:
pnginfo.add_text(key, value)
working.save(output, format="PNG", pnginfo=pnginfo, compress_level=4)
elif image_format == "JPEG":
if working.mode != "RGB":
converted = working.convert("RGB")
working = converted
kwargs: dict[str, Any] = {
"format": "JPEG",
"quality": 100,
"subsampling": 0,
"optimize": True,
}
exif = _create_exif_bytes(metadata)
if exif:
kwargs["exif"] = exif
working.save(output, **kwargs)
elif image_format == "WEBP":
kwargs = {"format": "WEBP", "lossless": True, "quality": 100, "method": 4}
exif = _create_exif_bytes(metadata)
if exif:
kwargs["exif"] = exif
working.save(output, **kwargs)
else:
raise ValueError(f"不支持的图像格式:{image_format}")
return output.getvalue()
finally:
if converted is not None:
converted.close()
def _save_sources(
sources: list[tuple[bytes | None, str | None, Image.Image, str | None]],
filename_prefix: str,
save_format: str,
output_directory: str,
folder_paths_module,
prompt: Any = None,
extra_pnginfo: dict[str, Any] | None = None,
save_location: str = "",
naming_rule: str = DEFAULT_SAVE_NAMING_RULE,
main_filename: str | None = None,
) -> list[dict[str, Any]]:
if not sources:
return []
width, height = sources[0][2].size
normalized_location = normalize_save_location(save_location)
external_location = bool(normalized_location and os.path.isabs(normalized_location))
relative_location = "" if external_location else normalized_location
save_root = normalized_location if external_location else output_directory
selected_rule = normalize_naming_rule(naming_rule)
filename = ""
counter = 1
if selected_rule == "自定义前缀":
effective_prefix = filename_prefix or "o1key"
if relative_location:
effective_prefix = os.path.join(relative_location, effective_prefix)
full_output_folder, filename, counter, subfolder, _ = (
folder_paths_module.get_save_image_path(
effective_prefix,
save_root,
width,
height,
)
)
else:
full_output_folder = os.path.join(save_root, relative_location)
subfolder = relative_location
os.makedirs(full_output_folder, exist_ok=True)
selected = normalize_save_format(save_format)
main_stem = _safe_filename_stem(
main_filename or next((item[3] for item in sources if item[3]), None)
)
results: list[dict[str, Any]] = []
natural_number = 1
main_number = 0
for batch_number, (raw, source_format, image, _source_filename) in enumerate(sources):
target_format = source_format if selected == "原始" else {
"png": "PNG",
"jpg": "JPEG",
"webp": "WEBP",
}[selected]
if target_format not in _FORMAT_EXTENSIONS:
target_format = "PNG"
if target_format == "JPEG" and _has_workflow_metadata(extra_pnginfo):
target_format = "PNG"
extension = _FORMAT_EXTENSIONS[target_format]
if selected == "原始" and raw is not None and detect_image_format(raw) == target_format:
encoded = inject_workflow_metadata(
raw,
target_format,
prompt=prompt,
extra_pnginfo=extra_pnginfo,
)
else:
encoded = _encode_image(
image,
target_format,
prompt=prompt,
extra_pnginfo=extra_pnginfo,
)
with _SAVE_LOCK:
while True:
if selected_rule == "自然数字":
saved_name = f"{natural_number}.{extension}"
natural_number += 1
elif selected_rule == "和主图一致":
suffix = "" if main_number == 0 else str(main_number)
saved_name = f"{main_stem}{suffix}.{extension}"
main_number += 1
else:
filename_with_batch_num = filename.replace(
"%batch_num%", str(batch_number)
)
saved_name = f"{filename_with_batch_num}_{counter:05}_.{extension}"
counter += 1
output_path = os.path.join(full_output_folder, saved_name)
if os.path.exists(output_path):
continue
try:
_atomic_write(output_path, encoded)
break
except FileExistsError:
continue
if external_location:
results.append(
_external_preview_descriptor(encoded, saved_name, folder_paths_module)
)
else:
results.append({"filename": saved_name, "subfolder": subfolder, "type": "output"})
return results
def save_tensor_images(
images,
filename_prefix: str,
save_format: str,
output_directory: str,
folder_paths_module,
prompt: Any = None,
extra_pnginfo: dict[str, Any] | None = None,
save_location: str = "",
naming_rule: str = DEFAULT_SAVE_NAMING_RULE,
main_filename: str | None = None,
) -> list[dict[str, Any]]:
sources: list[tuple[bytes | None, str | None, Image.Image, str | None]] = []
try:
for index, image_tensor in enumerate(images):
raw, source_format, source_filename = _source_for_tensor(images, index)
sources.append((raw, source_format, _pil_from_tensor(image_tensor), source_filename))
return _save_sources(
sources,
filename_prefix,
save_format,
output_directory,
folder_paths_module,
prompt,
extra_pnginfo,
save_location,
naming_rule,
main_filename or getattr(images, "_o1key_main_filename", None),
)
finally:
for _, _, image, _ in sources:
image.close()
def save_temp_images(
source_paths: list[str],
filename_prefix: str,
save_format: str,
output_directory: str,
folder_paths_module,
prompt: Any = None,
extra_pnginfo: dict[str, Any] | None = None,
save_location: str = "",
naming_rule: str = DEFAULT_SAVE_NAMING_RULE,
main_filename: str | None = None,
) -> list[dict[str, Any]]:
sources: list[tuple[bytes | None, str | None, Image.Image, str | None]] = []
try:
for source_path in source_paths:
with open(source_path, "rb") as handle:
raw = handle.read()
image_format = detect_image_format(raw)
if image_format not in _FORMAT_EXTENSIONS:
raise ValueError(f"临时结果不是受支持的图像:{os.path.basename(source_path)}")
with Image.open(BytesIO(raw)) as opened:
opened.load()
image = opened.copy()
sources.append((raw, image_format, image, None))
return _save_sources(
sources,
filename_prefix,
save_format,
output_directory,
folder_paths_module,
prompt,
extra_pnginfo,
save_location,
naming_rule,
main_filename,
)
finally:
for _, _, image, _ in sources:
image.close()
__all__ = [
"SAVE_FORMAT_OPTIONS",
"SAVE_NAMING_RULE_OPTIONS",
"detect_image_format",
"inject_workflow_metadata",
"normalize_save_format",
"normalize_save_location",
"normalize_naming_rule",
"save_temp_images",
"save_tensor_images",
]