192 lines
6.6 KiB
Python
192 lines
6.6 KiB
Python
# SimpleSyrup - workflow-focused ComfyUI extensions for image generation
|
|
# Copyright (C) 2026 Artificial Sweetener and contributors
|
|
# SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
"""Persist one validated P9.7 image or rejection artifact."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import hashlib
|
|
import json
|
|
from io import BytesIO
|
|
from pathlib import Path
|
|
from typing import cast
|
|
|
|
from PIL import Image
|
|
|
|
from tools.comfy_api import ImageReference, JsonObject
|
|
|
|
from .history import (
|
|
RegionalPatchInteropError,
|
|
RegionalPatchInteropHistory,
|
|
RegionalPatchInteropSuccess,
|
|
)
|
|
from .matrix import RegionalPatchInteropCase
|
|
|
|
|
|
class RegionalPatchInteropCaseArtifactWriter:
|
|
"""Own accepted-image and rejected-sidecar publication for one root."""
|
|
|
|
def __init__(self, root: Path) -> None:
|
|
"""Retain one existing resolved artifact directory."""
|
|
|
|
self._root = root.resolve()
|
|
if not self._root.is_dir():
|
|
raise ValueError("P9.7 case artifact root must already exist.")
|
|
|
|
def write(
|
|
self,
|
|
case: RegionalPatchInteropCase,
|
|
observed: RegionalPatchInteropHistory,
|
|
observation: JsonObject,
|
|
*,
|
|
image_bytes: bytes | None,
|
|
source_image_bytes: bytes | None,
|
|
) -> Path:
|
|
"""Publish one outcome and add its exact values to the observation."""
|
|
|
|
if isinstance(observed, RegionalPatchInteropSuccess):
|
|
return self._write_success(
|
|
case,
|
|
observed,
|
|
observation,
|
|
image_bytes=image_bytes,
|
|
source_image_bytes=source_image_bytes,
|
|
)
|
|
if isinstance(observed, RegionalPatchInteropError):
|
|
return self._write_rejection(
|
|
case,
|
|
observed,
|
|
observation,
|
|
image_bytes=image_bytes,
|
|
source_image_bytes=source_image_bytes,
|
|
)
|
|
raise TypeError("P9.7 history result has an invalid type.")
|
|
|
|
def _write_success(
|
|
self,
|
|
case: RegionalPatchInteropCase,
|
|
observed: RegionalPatchInteropSuccess,
|
|
observation: JsonObject,
|
|
*,
|
|
image_bytes: bytes | None,
|
|
source_image_bytes: bytes | None,
|
|
) -> Path:
|
|
"""Persist one accepted final image and optional 1024 source image."""
|
|
|
|
if image_bytes is None:
|
|
raise ValueError("P9.7 accepted case is missing final image bytes.")
|
|
_validate_image(image_bytes, expected_size=case.image_size)
|
|
image_path = self._root / f"{case.case_id}.png"
|
|
image_path.write_bytes(image_bytes)
|
|
observation.update(
|
|
{
|
|
"metrics": observed.metrics,
|
|
"diagnostics": observed.diagnostics,
|
|
"image_file": image_path.name,
|
|
"image_sha256": _sha256(image_bytes),
|
|
"image_size_bytes": len(image_bytes),
|
|
"image_reference": _image_reference(observed.image_reference),
|
|
"exception_type": None,
|
|
"exception_message": None,
|
|
}
|
|
)
|
|
if observed.source_image_reference is None:
|
|
if source_image_bytes is not None:
|
|
raise ValueError("P9.7 full case supplied an unexpected source image.")
|
|
observation["source_image"] = None
|
|
else:
|
|
if source_image_bytes is None:
|
|
raise ValueError("P9.7 spatial case is missing its source image.")
|
|
_validate_image(source_image_bytes, expected_size=(1024, 1024))
|
|
source_path = self._root / f"{case.case_id}-source.png"
|
|
source_path.write_bytes(source_image_bytes)
|
|
observation["source_image"] = {
|
|
"file": source_path.name,
|
|
"sha256": _sha256(source_image_bytes),
|
|
"size_bytes": len(source_image_bytes),
|
|
"reference": _image_reference(observed.source_image_reference),
|
|
}
|
|
return image_path
|
|
|
|
def _write_rejection(
|
|
self,
|
|
case: RegionalPatchInteropCase,
|
|
observed: RegionalPatchInteropError,
|
|
observation: JsonObject,
|
|
*,
|
|
image_bytes: bytes | None,
|
|
source_image_bytes: bytes | None,
|
|
) -> Path:
|
|
"""Persist one named conflict with explicit zero-output evidence."""
|
|
|
|
if image_bytes is not None or source_image_bytes is not None:
|
|
raise ValueError("P9.7 rejected cases cannot publish image bytes.")
|
|
rejection_path = self._root / f"{case.case_id}.rejection.json"
|
|
payload = {
|
|
"case_id": case.case_id,
|
|
"label": case.label,
|
|
"status": "rejected_before_sampling",
|
|
"model_call_count": 0,
|
|
"diagnostic_record_count": 0,
|
|
"image": None,
|
|
"exception_type": observed.exception_type,
|
|
"exception_message": observed.exception_message,
|
|
"executed_node_ids": list(observed.executed_node_ids),
|
|
}
|
|
_write_json(rejection_path, payload)
|
|
observation.update(
|
|
{
|
|
"metrics": None,
|
|
"diagnostics": None,
|
|
"image_file": None,
|
|
"source_image": None,
|
|
"exception_type": observed.exception_type,
|
|
"exception_message": observed.exception_message,
|
|
"rejection_file": rejection_path.name,
|
|
"rejection_sha256": _sha256(rejection_path.read_bytes()),
|
|
}
|
|
)
|
|
return rejection_path
|
|
|
|
|
|
def _validate_image(value: bytes, *, expected_size: tuple[int, int]) -> None:
|
|
"""Require one non-flat PNG at the exact declared dimensions."""
|
|
|
|
if not value:
|
|
raise ValueError("P9.7 image bytes must not be empty.")
|
|
with Image.open(BytesIO(value)) as image:
|
|
if image.format != "PNG" or image.size != expected_size:
|
|
raise ValueError(f"P9.7 image must be a {expected_size} PNG.")
|
|
extrema = cast(
|
|
tuple[tuple[int, int], ...],
|
|
image.convert("RGB").getextrema(),
|
|
)
|
|
if not any(high > low for low, high in extrema):
|
|
raise ValueError("P9.7 image must contain non-flat visual output.")
|
|
|
|
|
|
def _image_reference(reference: ImageReference) -> JsonObject:
|
|
"""Return one serializable Comfy image reference."""
|
|
|
|
return {
|
|
"filename": reference.filename,
|
|
"subfolder": reference.subfolder,
|
|
"type": reference.output_type,
|
|
}
|
|
|
|
|
|
def _write_json(path: Path, payload: object) -> None:
|
|
"""Write one stable UTF-8 JSON sidecar."""
|
|
|
|
path.write_text(
|
|
json.dumps(payload, indent=2, sort_keys=True) + "\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
def _sha256(value: bytes) -> str:
|
|
"""Hash one complete persisted artifact value."""
|
|
|
|
return hashlib.sha256(value).hexdigest()
|