Files
Sebastian Monroy 527e8dc4ab 663 implement comfyui memory guardian to prevent oom errors (#12)
* feat(config): add Memory Hygiene config scaffolding and defaults for nilor-nodes

- introduce MemoryHygieneConfig and wire into NilorNodesConfig
- parse NILOR_MEMORY_HYGIENE_* from JSON5 and apply env overrides
- add validation for thresholds, policy, cooldowns, retries, and durations
- extend config.json5 with sane Memory Guardian defaults
- update .env.example

* feat(client): add ComfyUI capability detection and supports_hygiene cache

- add one-time probe for /system_stats and /free, cached per session
- expose supports_hygiene() that logs a single warning when unsupported
- use short timeouts and no retries; mark false only on 404/405
- leave transient failures retryable by keeping capability as unknown

* feat(memory): add MemoryHygiene module with typed skeleton and API

- introduce RemediationAction and RemediationResult dataclass
- add MemoryHygiene class with DI for client/config/logger
- implement check_and_remediate skeleton with enablement/capability checks
- add safe stats helper; defer policy/remediation to later commits
- export public symbols via all

* feat(memory): add metrics collector with usage pct and vram_total

- extend SystemStats with vram_total and parse from /system_stats
- add DerivedStats and collect_metrics() computing vram/ram used pct (0–100)
- integrate metrics collection in check_and_remediate skeleton
- safe math with clamping and None handling for incomplete stats

* feat(memory): implement policy engine thresholds, cooldown, and action selection

- add cooldown handling and respect it in check_and_remediate
- detect pressure via percent or absolute MB thresholds for vram/ram
- normalize policy and choose staged initial action (auto => free)
- return actionable RemediationResult with reason; no remediation yet
- helper functions for conversions and comparisons

* feat(memory): add remediation loop with retries, time caps, and cooldown

- implement remediate_cycle with free/unload flags and staged auto escalation
- respect MAX_RETRIES, SLEEP_BETWEEN_ATTEMPTS_SECONDS, MAX_CYCLE_DURATION_SECONDS
- set cooldown after cycle; return after-stats, attempts, action, and outcome reason
- integrate cycle into check_and_remediate; keep helpers in module

* feat(worker): wire MemoryHygiene into idle and post-completion paths

- initialize MemoryHygiene with comfy client and config in consume_jobs
- run hygiene before polling when idle and after prompt finalize with 1s debounce
- guard remediation by setting is_busy to block new intake; reset after
- keep websocket listener unaffected; schedule post-completion hygiene as background task

* feat(worker): throttle hygiene checks and add cadence tracking

- throttle idle hygiene by cfg.hygiene.idle_poll_seconds using monotonic clock
- add last_hygiene_check_ts to avoid overly frequent checks
- keep is_busy gating and post-completion debounce execution

* chore(memory): add structured logs for decisions and remediation

- log disabled/unsupported/cooldown/no-pressure branches
- log start/end of remediation cycles with before/after VRAM/RAM stats
- log each /free invocation flags; guard logging to avoid exceptions

* fix(memory): harden hygiene with session disable and single-warning on unsupported

- add _capability_disabled to short-circuit future runs after unsupported endpoints
- emit a single warning then quietly skip further cycles for the session
- preserve existing retry/backoff/cooldown and safe exception handling

* .env.example update

* improved get_system_stats and added more logging

* improve logging formatting for comfyui_client and memory_hygiene

* feat(nilor-nodes): add startup hygiene summary log; convert hygiene lambda to class method

- add WorkerConsumer._run_memory_hygiene() async method; remove late-bound lambda injection
- delegate to guarded helper to respect busy gate and optional debounce
- emit startup hygiene summary with effective thresholds from _CFG.hygiene
  (enabled, idle_poll_s, vram/ram pct caps, min_free_mb, policy, retries, cooldown,
  sleep_between, max_cycle)
- keep existing call sites in consume_loop() and _finalize_prompt() using the new method
- no functional changes to remediation logic; new log improves observability at boot

* got rid of redundant .env loading in worker_consumer

* chore(config): simplify global config caching

- keep process-wide _CONFIG singleton for shared configuration instance
- no functional behavior change to config loading paths

* fixed valueerror logging in media_stream
2025-10-20 13:56:38 +01:00

395 lines
14 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import torch
import numpy as np
from PIL import Image
import requests
import io
import logging
import imageio.v2 as imageio
import mimetypes
import boto3
import json
from .logger import logger
from .config.config import load_nilor_nodes_config
# Load shared configuration once
_CFG = load_nilor_nodes_config()
# --- Node Categories ---
category = "Nilor Nodes 👺"
subcategories = {
"streaming": "/Streaming",
}
# --- MediaStreamInput: Universal Media Downloader ---
class MediaStreamInput:
"""
A custom node to download an image/video from a pre-signed URL and provide it as a tensor.
"""
def __init__(self):
pass
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"input_name": (
"STRING",
{"default": "default_input", "multiline": False},
),
"format": (["image", "image_batch", "video"],),
"presigned_download_url": (
"STRING",
{"multiline": True, "default": "<auto-filled by system>"},
),
},
"hidden": {},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("image",)
FUNCTION = "download"
CATEGORY = category + subcategories["streaming"]
def download(
self,
presigned_download_url: str,
format: str,
input_name: str = "default_input",
):
logger.info(
f"ℹ️\u2009 Nilor-Nodes: MediaStreamInput: Downloading from {presigned_download_url} for input '{input_name}' with format '{format}'"
)
try:
# Two-phase download for batches: manifest first, then assets
if format == "image_batch":
manifest_response = requests.get(presigned_download_url, timeout=60)
manifest_response.raise_for_status()
manifest = manifest_response.json()
logger.info(
f"ℹ️\u2009 Nilor-Nodes: Processing manifest for '{manifest.get('input_name')}' with {len(manifest.get('files', []))} assets."
)
# Sort files by sequence number to ensure correct order
sorted_files = sorted(
manifest.get("files", []), key=lambda x: x.get("sequence", 0)
)
# Download all assets in parallel
asset_responses = []
for file_info in sorted_files:
try:
resp = requests.get(file_info["presigned_url"], timeout=180)
resp.raise_for_status()
asset_responses.append(resp.content)
except requests.RequestException as e:
logger.error(
f"🛑\u2009 Nilor-Nodes: Failed to download asset {file_info.get('filename')}: {e}"
)
raise # Re-raise to fail the entire process
return self._process_image_batch(asset_responses)
# --- Single-file download ---
response = requests.get(presigned_download_url, timeout=180)
response.raise_for_status()
media_bytes = response.content
if format == "video":
return self._process_video(media_bytes)
elif format == "image":
return self._process_image(media_bytes)
else:
# Should not happen if UI choices are respected
raise ValueError(
f"🛑\u2009 Nilor-Nodes (MediaStreamInput): Unsupported format '{format}' for single media download."
)
except requests.RequestException as e:
logger.error(
f"🛑\u2009 Nilor-Nodes (MediaStreamInput): Failed to download file: {e}"
)
return (None,)
except Exception as e:
logger.error(
f"🛑\u2009 Nilor-Nodes (MediaStreamInput): Failed to process media: {e}"
)
return (None,)
def _process_image_batch(self, image_bytes_list):
logger.info(
f"ℹ️\u2009 Nilor-Nodes (MediaStreamInput): Processing image batch with {len(image_bytes_list)} images..."
)
output_images = []
for image_bytes in image_bytes_list:
image_pil = Image.open(io.BytesIO(image_bytes))
rgb_image_pil = image_pil.convert("RGB")
image_tensor = torch.from_numpy(
np.array(rgb_image_pil).astype(np.float32) / 255.0
).unsqueeze(0)
output_images.append(image_tensor)
# Concatenate along the batch dimension (dim=0)
images_tensor = torch.cat(output_images, dim=0)
logger.info(
f"✅ Nilor-Nodes (MediaStreamInput): Image batch processing successful. Batch shape: {images_tensor.shape}"
)
return (images_tensor,)
def _process_image(self, image_bytes):
logger.info("ℹ️\u2009 Nilor-Nodes (MediaStreamInput): Processing as image...")
image_pil = Image.open(io.BytesIO(image_bytes))
# Ensure image is in RGB
rgb_image_pil = image_pil.convert("RGB")
image_tensor = torch.from_numpy(
np.array(rgb_image_pil).astype(np.float32) / 255.0
).unsqueeze(0)
logger.info("✅ Nilor-Nodes (MediaStreamInput): Image processing successful.")
return (image_tensor,)
def _process_video(self, video_bytes):
logger.info("ℹ️\u2009 Nilor-Nodes (MediaStreamInput): Processing as video...")
frames = []
with imageio.get_reader(io.BytesIO(video_bytes), format="mp4") as reader:
for frame in reader:
# Convert frame to RGB PIL Image and then to tensor
pil_image = Image.fromarray(frame).convert("RGB")
numpy_image = np.array(pil_image).astype(np.float32) / 255.0
tensor_frame = torch.from_numpy(numpy_image)
frames.append(tensor_frame)
if not frames:
raise ValueError(
"🛑\u2009 Nilor-Nodes (MediaStreamInput): No frames could be read from the video."
)
# Stack frames into a single tensor (batch of images)
video_tensor = torch.stack(frames)
logging.info(
f"✅ Nilor-Nodes (MediaStreamInput): Video processing successful. Image Shape: {video_tensor.shape}"
)
return (video_tensor,)
# --- MediaStreamOutput: Universal Media Uploader & SQS Notifier ---
class MediaStreamOutput:
@classmethod
def INPUT_TYPES(s):
return {
"required": {
"output_name": (
"STRING",
{"default": "default_output", "multiline": False},
),
"images": ("IMAGE",),
"format": (["png", "mp4"],),
"framerate": ("INT", {"default": 24, "min": 1, "max": 240, "step": 1}),
"content_id": (
"STRING",
{"default": "<auto-filled by system>", "multiline": False},
),
"venue": (
"STRING",
{"default": "<auto-filled by system>", "multiline": False},
),
"canvas": (
"STRING",
{"default": "<auto-filled by system>", "multiline": False},
),
"scene": (
"STRING",
{"default": "<auto-filled by system>", "multiline": False},
),
"presigned_upload_url": (
"STRING",
{"multiline": True, "default": "<auto-filled by system>"},
),
"job_completions_queue_url": (
"STRING",
{"multiline": True, "default": "<auto-filled by system>"},
),
"output_object_keys": (
"STRING",
{"multiline": False, "default": "<auto-filled by system>"},
),
"job_type": (
"STRING",
{"default": "<auto-filled by system>", "multiline": False},
),
},
"hidden": {
"prompt": "PROMPT",
"extra_pnginfo": "EXTRA_PNGINFO",
},
}
RETURN_TYPES = ("STRING",)
RETURN_NAMES = ("uploaded_url",)
FUNCTION = "upload_and_notify"
OUTPUT_NODE = True
CATEGORY = category + subcategories["streaming"]
def upload_and_notify(
self,
images,
format,
content_id,
venue,
canvas,
scene,
presigned_upload_url,
job_completions_queue_url,
output_object_keys,
framerate,
output_name: str = "default_output",
prompt=None,
extra_pnginfo=None,
job_type: str | None = None,
):
if not content_id:
raise ValueError(
"🛑\u2009 Nilor-Nodes (MediaStreamOutput): content_id is a required input for MediaStreamOutput."
)
# The `output_object_keys` is received as a string representation of a dictionary.
# We must parse it back into a dictionary.
final_outputs_dict = {}
try:
# The string may use single quotes, so we replace them for valid JSON.
final_outputs_dict = json.loads(output_object_keys.replace("'", '"'))
except Exception as e:
logger.error(
f"🛑\u2009 Nilor-Nodes (MediaStreamOutput): FATAL -- Could not parse output_object_keys from string: {output_object_keys}. Error: {e}"
)
final_outputs_dict = {} # Send empty dict on failure.
# The presigned_upload_url provided to this node is specific to its output_name.
# We don't need to re-select it. We just need to perform the upload.
if format == "png":
self._upload_image(images[0], presigned_upload_url)
elif format == "mp4":
self._upload_video(images, presigned_upload_url, framerate)
# This node is responsible for a single output. We find its corresponding object key.
output_key_for_this_node = final_outputs_dict.get(output_name)
if not output_key_for_this_node:
logging.error(
f"🛑\u2009 Nilor-Nodes (MediaStreamOutput): FATAL -- Could not find object key for output name '{output_name}' in output_object_keys."
)
# Send an empty dictionary to signal failure.
final_outputs_for_sqs = {}
else:
final_outputs_for_sqs = {output_name: output_key_for_this_node}
# After upload, send the filtered dictionary of outputs to the SQS queue.
completion_message = {
"content_id": content_id,
"status": "completed",
"venue": venue,
"canvas": canvas,
"scene": scene,
"outputs": final_outputs_for_sqs,
}
if job_type:
completion_message["job_type"] = job_type
try:
# Re-initialize the client inside the execution to ensure it picks up env vars correctly.
sqs_client = boto3.client(
"sqs",
endpoint_url=_CFG.worker.sqs_endpoint_url,
aws_access_key_id=_CFG.worker.aws_access_key_id,
aws_secret_access_key=_CFG.worker.aws_secret_access_key,
region_name=_CFG.worker.aws_region,
)
logger.debug(
f"ℹ️\u2009 Nilor-Nodes (MediaStreamOutput): Sending completion message for content {content_id} to queue: {job_completions_queue_url}"
)
sqs_client.send_message(
QueueUrl=job_completions_queue_url,
MessageBody=json.dumps(completion_message),
)
logger.info(
f"✅ Nilor-Nodes (MediaStreamOutput): Completion message sent successfully for content {content_id} to queue: {job_completions_queue_url}"
)
except Exception as e:
logger.error(
f"🛑\u2009 Nilor-Nodes (MediaStreamOutput): Failed to send completion message to SQS: {e}"
)
raise # Re-raise to fail the ComfyUI job
return {"ui": {"images": []}, "result": (presigned_upload_url,)}
def _upload_image(self, image_tensor, url):
logger.debug(
"ℹ️\u2009 Nilor-Nodes (MediaStreamOutput): Uploading as PNG image..."
)
i = 255.0 * image_tensor.cpu().numpy()
img_pil = Image.fromarray(np.clip(i, 0, 255).astype(np.uint8))
buffer = io.BytesIO()
img_pil.save(buffer, format="PNG", compress_level=4)
buffer.seek(0)
self._perform_upload(buffer, url, "image/png")
def _upload_video(self, image_batch_tensor, url, framerate):
logger.info(
f"ℹ️\u2009 Nilor-Nodes (MediaStreamOutput): Uploading as MP4 video. Frame count: {len(image_batch_tensor)}"
)
frames = []
for image_tensor in image_batch_tensor:
i = 255.0 * image_tensor.cpu().numpy()
frame = np.clip(i, 0, 255).astype(np.uint8)
frames.append(frame)
buffer = io.BytesIO()
imageio.mimwrite(buffer, frames, format="mp4", fps=framerate, quality=8)
buffer.seek(0)
self._perform_upload(buffer, url, "video/mp4")
def _perform_upload(self, buffer, url, content_type):
try:
logger.info(
f"ℹ️\u2009 Nilor-Nodes (MediaStreamOutput): Uploading to {url} with Content-Type: {content_type}"
)
headers = {"Content-Type": content_type}
response = requests.put(
url, data=buffer.read(), headers=headers, timeout=300
)
response.raise_for_status()
logger.info("✅ Nilor-Nodes (MediaStreamOutput): Upload successful.")
except requests.RequestException as e:
logger.error(
f"🛑\u2009 Nilor-Nodes (MediaStreamOutput): Failed to upload media: {e}"
)
raise
except Exception as e:
logger.error(
f"🛑\u2009 Nilor-Nodes (MediaStreamOutput): Failed to process and upload media: {e}"
)
raise
# --- Node Mappings ---
NODE_CLASS_MAPPINGS = {
"MediaStreamInput": MediaStreamInput,
"MediaStreamOutput": MediaStreamOutput,
}
NODE_DISPLAY_NAME_MAPPINGS = {
"MediaStreamInput": "👺 Media Stream Input (URL)",
"MediaStreamOutput": "👺 Media Stream Output (URL)",
}