FunPack log
+
+
+
diff --git a/app/catalogue/log.js b/app/catalogue/log.js
new file mode 100644
index 00000000..63696f3d
--- /dev/null
+++ b/app/catalogue/log.js
@@ -0,0 +1,70 @@
+// The log, where a person can read it.
+//
+// The terminal is where this ends up by default, buried among ComfyUI's own
+// output. The whole reason for a severity and a source is that a line can be
+// found later, so there has to be somewhere to look.
+
+import { composer } from "../composer/composer.js";
+
+const root = document.querySelector("#app");
+
+// Severity -> the chip tone that already exists in the kit. Info is deliberately
+// quiet: most lines are Info, and a wall of coloured chips says nothing.
+const TONE = { Info: "neutral", Alert: "info", Warning: "warn", Critical: "danger" };
+
+let filter = null;
+
+async function load() {
+ const url = "/funpack/api/log" + (filter ? `?level=${encodeURIComponent(filter)}` : "");
+ const response = await fetch(url);
+ if (!response.ok) throw new Error(`${url} answered ${response.status}`);
+ return response.json();
+}
+
+function render(data) {
+ const counts = {};
+ for (const level of data.levels) counts[level] = 0;
+ for (const record of data.records) counts[record.level] = (counts[record.level] || 0) + 1;
+
+ const filters = composer.segmented.md({
+ label: "Show",
+ value: filter || "all",
+ options: [{ value: "all", label: "Everything" },
+ ...data.levels.map((l) => ({ value: l, label: `${l} (${counts[l] || 0})` }))],
+ onChange: (value) => { filter = value === "all" ? null : value; start(); },
+ });
+
+ // Newest last, like a terminal: the eye expects the end to be the present.
+ const items = data.records.map((record, i) => ({
+ id: `r${i}`,
+ label: record.message,
+ hint: `${record.level} · ${record.source || "FunPack"}`,
+ }));
+
+ root.replaceChildren(composer.panel.default({
+ title: "Log",
+ body: composer.group.default({
+ rows: [
+ filters,
+ items.length
+ ? composer.list.rows({ items, reorder: false })
+ : composer.emptyState.default({
+ icon: "○", title: "Nothing logged yet",
+ hint: filter ? `No ${filter} messages.` : "Run a generation and come back.",
+ }),
+ ],
+ }),
+ }).node);
+}
+
+async function start() {
+ try {
+ render(await load());
+ } catch (err) {
+ root.replaceChildren(composer.emptyState.default({
+ icon: "▲", title: "Could not read the log", hint: err.message,
+ }).node);
+ }
+}
+
+start();
diff --git a/app/catalogue/modules.js b/app/catalogue/modules.js
index 3548ab74..dbaef13a 100644
--- a/app/catalogue/modules.js
+++ b/app/catalogue/modules.js
@@ -75,10 +75,10 @@ async function start() {
function composal(title, hint, items) {
return composer.panel.default({
title,
- body: items.length
- ? composer.group.default({ children: [
- composer.hint.default({ text: hint }), rows(items)] })
- : composer.hint.default({ text: "None." }),
+ body: composer.group.default({
+ hint,
+ rows: items.length ? [rows(items)] : [composer.hint.default({ text: "None." })],
+ }),
});
}
diff --git a/core/log.py b/core/log.py
index 5a005788..aed5f406 100644
--- a/core/log.py
+++ b/core/log.py
@@ -1,17 +1,110 @@
-"""One-line logging with a fixed prefix.
+"""Saying what happened, at a severity, from somewhere.
-`failed()` exists so a module that silently no-ops is impossible: every swallowed
-exception says what stopped working, once.
+ [Log] Info: FunPack Diffusion Model Loader | flux.safetensors loaded, attention sage
+ [Log] Alert: FunPack Sampler | no image is connected, starting from noise
+ [Log] Warning: FunPack LoRA Loader | style.safetensors is empty and will not load
+ [Log] Critical: torch | cannot allocate 14 GB (3 GB free); the run will not start
+
+Four levels, and the difference between the middle two is the one that matters:
+
+* **Info** -- it worked, and here is what it actually did. Not decoration: "loaded
+ on GPU using sage attention" is how you find out the attention setting did
+ nothing, months before the pictures look wrong enough to investigate.
+* **Alert** -- something is unset or unconnected and the run continues without
+ it. A choice you may not have made on purpose.
+* **Warning** -- something will NOT do what its name says. This is the level v4
+ needed and did not have: 560 handlers swallowed a failure and carried on, so a
+ feature could be switched on, do nothing, and never say so.
+* **Critical** -- the run cannot proceed.
+
+Records are kept in a small ring as well as printed, so the app can show them.
+The buffer is bounded because a long rental session must not turn a log into a
+memory leak.
"""
import sys
+import time
+from collections import deque
+from typing import Optional
-PREFIX = "[FunPack]"
+LABEL = "[Log]"
+
+INFO = "Info"
+ALERT = "Alert"
+WARNING = "Warning"
+CRITICAL = "Critical"
+
+LEVELS = (INFO, ALERT, WARNING, CRITICAL)
+
+# Enough to cover a long generation and its aftermath, small enough to be free.
+HISTORY = 500
+_records: deque = deque(maxlen=HISTORY)
+
+# Keys already said once. Cleared per run rather than per process: v4's dedup
+# lasted the life of the interpreter, so a session reported the first generation
+# that went inert and stayed quiet for every one after it.
+_said: set = set()
-def note(message: str) -> None:
- print(f"{PREFIX} {message}", file=sys.stderr, flush=True)
+def line(level: str, source: str, message: str) -> str:
+ """The one place the format is decided."""
+ where = f"{source} | " if source else ""
+ return f"{LABEL} {level}: {where}{message}"
-def failed(what: str, exc: BaseException) -> None:
- note(f"{what} failed to load: {type(exc).__name__}: {exc}")
+def record(level: str, source: str, message: str) -> dict:
+ entry = {"at": time.time(), "level": level, "source": source, "message": message}
+ _records.append(entry)
+ print(line(level, source, message), file=sys.stderr, flush=True)
+ return entry
+
+
+def info(source: str, message: str) -> dict:
+ return record(INFO, source, message)
+
+
+def alert(source: str, message: str) -> dict:
+ return record(ALERT, source, message)
+
+
+def warning(source: str, message: str) -> dict:
+ return record(WARNING, source, message)
+
+
+def critical(source: str, message: str) -> dict:
+ return record(CRITICAL, source, message)
+
+
+def once(key: str, level: str, source: str, message: str) -> Optional[dict]:
+ """Say it the first time only, until the next run.
+
+ For anything that would otherwise repeat every sampling step. Thirty copies
+ of one line say nothing the first did not, and burying the console is its own
+ way of hiding a failure.
+ """
+ if key in _said:
+ return None
+ _said.add(key)
+ return record(level, source, message)
+
+
+def failed(what: str, exc: BaseException) -> dict:
+ """Something did not load. Always a Warning: it means a feature is absent."""
+ return warning(what, f"did not load -- {type(exc).__name__}: {exc}")
+
+
+def new_run() -> None:
+ """A generation is starting: whatever was said once may be said again."""
+ _said.clear()
+
+
+def history(level: Optional[str] = None, limit: int = HISTORY) -> list:
+ """Recent records, newest last. For the app, and for the modules page."""
+ items = [r for r in _records if level is None or r["level"] == level]
+ return items[-limit:]
+
+
+def _reset() -> None:
+ """Tests only."""
+ _records.clear()
+ _said.clear()
diff --git a/core/nodes.py b/core/nodes.py
index 4ffef78b..c90d26c9 100644
--- a/core/nodes.py
+++ b/core/nodes.py
@@ -56,14 +56,14 @@ def collect(registry=None) -> Tuple[List[type], List[Tuple[str, str]]]:
if schema is None:
rejected.append((where, "is not a comfy_api io.ComfyNode (no GET_SCHEMA)"))
- log.note(f"{where} is not a ComfyUI V3 node; not registered.")
+ log.warning(where, "is not a ComfyUI V3 node and was not registered")
continue
node_id = getattr(schema, "node_id", None)
if not isinstance(node_id, str) or not node_id.startswith(PREFIX):
rejected.append((where, f"node_id {node_id!r} does not start with {PREFIX!r}"))
- log.note(f"{where} declares node_id {node_id!r}, which is not "
- f"prefixed {PREFIX!r}; not registered.")
+ log.warning(where, f"declares node_id {node_id!r}, which is not prefixed "
+ f"{PREFIX!r}, and was not registered")
continue
if node_id in claimed:
@@ -71,8 +71,8 @@ def collect(registry=None) -> Tuple[List[type], List[Tuple[str, str]]]:
# and which one wins would depend on import order.
rejected.append((where, f"duplicate node_id {node_id!r}, already "
f"declared by {claimed[node_id]}"))
- log.note(f"{where} reuses node_id {node_id!r}, already declared by "
- f"{claimed[node_id]}; not registered.")
+ log.warning(where, f"reuses node_id {node_id!r}, already declared by "
+ f"{claimed[node_id]}, and was not registered")
continue
claimed[node_id] = where
@@ -96,7 +96,7 @@ def extension():
return list(nodes)
if rejected:
- log.note(f"{len(rejected)} node(s) were not registered; "
- + "; ".join(f"{where}: {why}" for where, why in rejected))
- log.note(f"registered {len(nodes)} node(s)")
+ log.warning("nodes", f"{len(rejected)} node(s) were not registered; "
+ + "; ".join(f"{where}: {why}" for where, why in rejected))
+ log.info("nodes", f"registered {len(nodes)} node(s)")
return FunPackExtension()
diff --git a/core/patching.py b/core/patching.py
index 8e6268fb..6388be90 100644
--- a/core/patching.py
+++ b/core/patching.py
@@ -136,9 +136,10 @@ def guard(fn, key: str, neutral, dropped: Dropped):
except Exception as exc: # noqa: BLE001 -- the whole point
if dropped.record(key, exc):
from . import log
- log.note(
- f"{key} failed during sampling and is now OFF for the rest of "
- f"this run. The run continues without it.\n"
+ log.warning(
+ key,
+ "failed during sampling and is now OFF for the rest of this run; "
+ "the run continues without it\n"
+ "".join(traceback.format_exception(exc)).rstrip())
return neutral(*args, **kwargs)
diff --git a/core/registry.py b/core/registry.py
index 7cfbe984..8ef39e56 100644
--- a/core/registry.py
+++ b/core/registry.py
@@ -122,7 +122,7 @@ def scan(root: Optional[Path] = None, package: Optional[str] = None) -> Registry
try:
registry.add(validate(raw, source=dotted))
except SchemaError as exc:
- log.note(f"{dotted} declares an invalid module: {exc}")
+ log.warning(dotted, f"is not a valid module and was not loaded -- {exc}")
registry.failed.append((dotted, str(exc)))
return registry
diff --git a/core/relations.py b/core/relations.py
index 5d5ce1ff..990aa2a0 100644
--- a/core/relations.py
+++ b/core/relations.py
@@ -39,7 +39,8 @@ def order(specs: Iterable[ModuleSpec]) -> Tuple[List[ModuleSpec], List[Tuple[Mod
# that would put a whole stage out of sequence to satisfy one module.
# The relation is dropped and said out loud rather than silently obeyed.
if stage_of[later] < stage_of[earlier]:
- log.note(
+ log.alert(
+ "module ordering",
f"ignoring {later!r} after {earlier!r}: {later!r} is in stage "
f"{STAGES[stage_of[later]]!r}, which runs before {STAGES[stage_of[earlier]]!r}"
)
diff --git a/core/routes.py b/core/routes.py
index 8a7e76b5..0855e33b 100644
--- a/core/routes.py
+++ b/core/routes.py
@@ -92,6 +92,16 @@ def register(routes, prefix=None):
traits = [t for t in raw.split(",") if t] if raw is not None else None
return web.json_response(manifest(traits))
+ @routes.get(P + "/api/log")
+ async def _log(req):
+ level = req.query.get("level") or None
+ try:
+ limit = int(req.query.get("limit", log.HISTORY))
+ except ValueError:
+ limit = log.HISTORY
+ return web.json_response({"levels": list(log.LEVELS),
+ "records": log.history(level, limit)})
+
@routes.get(P + "/app/{tail:.*}")
async def _app_asset(req):
return _serve_under(req, config.APP_DIR, config.APP_EXTS)
@@ -121,4 +131,4 @@ if web is not None and PromptServer is not None:
except Exception as exc: # noqa: BLE001
log.failed("route registration", exc)
else:
- log.note(f"serving the app at {config.UI_PREFIX}/")
+ log.info("routes", f"serving the app at {config.UI_PREFIX}/")
diff --git a/core/tests/test_log.py b/core/tests/test_log.py
new file mode 100644
index 00000000..6fe22831
--- /dev/null
+++ b/core/tests/test_log.py
@@ -0,0 +1,86 @@
+"""The log: a severity, a source, and a message.
+
+The format is the contract -- the app will parse it and a person will scan it --
+so it is pinned rather than left to drift line by line.
+"""
+
+import pytest
+
+from core import log
+
+
+@pytest.fixture(autouse=True)
+def clean():
+ log._reset()
+ yield
+ log._reset()
+
+
+def test_the_shape_is_label_severity_source_message():
+ assert log.line("Warning", "FunPack LoRA Loader", "style.safetensors is empty") == (
+ "[Log] Warning: FunPack LoRA Loader | style.safetensors is empty")
+
+
+def test_a_message_with_no_source_does_not_print_an_empty_one():
+ assert log.line("Info", "", "ready") == "[Log] Info: ready"
+
+
+@pytest.mark.parametrize("fn,level", [
+ (log.info, "Info"), (log.alert, "Alert"),
+ (log.warning, "Warning"), (log.critical, "Critical"),
+])
+def test_each_level_records_itself(fn, level):
+ fn("somewhere", "something")
+ assert log.history()[-1] == {
+ "at": log.history()[-1]["at"], "level": level,
+ "source": "somewhere", "message": "something"}
+
+
+def test_it_prints_as_well_as_records(capsys):
+ log.warning("loader", "a file went missing")
+ assert "[Log] Warning: loader | a file went missing" in capsys.readouterr().err
+
+
+def test_history_can_be_filtered_by_level():
+ log.info("a", "one")
+ log.warning("b", "two")
+ log.info("c", "three")
+ assert [r["source"] for r in log.history(log.INFO)] == ["a", "c"]
+
+
+def test_the_history_is_bounded():
+ """A long rental session must not turn the log into a memory leak."""
+ for i in range(log.HISTORY + 50):
+ log.info("loop", str(i))
+ assert len(log.history()) == log.HISTORY
+ assert log.history()[-1]["message"] == str(log.HISTORY + 49)
+
+
+def test_failed_is_a_warning_because_it_means_a_feature_is_absent():
+ log.failed("some.module", ValueError("bad"))
+ entry = log.history()[-1]
+ assert entry["level"] == log.WARNING
+ assert "ValueError" in entry["message"] and "bad" in entry["message"]
+
+
+# --- once, and what "once" means -------------------------------------------
+
+def test_once_says_it_a_single_time():
+ for _ in range(10):
+ log.once("k", log.ALERT, "src", "inert")
+ assert len(log.history()) == 1
+
+
+def test_once_is_per_run_not_per_process():
+ """v4 deduped for the life of the interpreter, so a session reported the
+ first generation that went inert and stayed quiet for every one after."""
+ log.once("k", log.ALERT, "src", "inert")
+ log.new_run()
+ log.once("k", log.ALERT, "src", "inert")
+ assert len(log.history()) == 2
+
+
+def test_different_keys_are_independent():
+ log.once("a", log.ALERT, "src", "one")
+ log.once("b", log.ALERT, "src", "two")
+ assert len(log.history()) == 2
diff --git a/modules/loaders/clip/nodes.py b/modules/loaders/clip/nodes.py
index c5d30e84..4e1efc91 100644
--- a/modules/loaders/clip/nodes.py
+++ b/modules/loaders/clip/nodes.py
@@ -28,6 +28,8 @@ import folder_paths
import torch
from comfy_api.latest import io
+from ..._core import log
+
MAX_ENCODERS = 4
@@ -118,5 +120,8 @@ class FunPackCLIPLoader(io.ComfyNode):
raise RuntimeError(
f"Could not load a text encoder from {', '.join(names)} as type {type!r}.")
+ log.info("FunPack CLIP Loader",
+ f"{len(names)} encoder file(s) loaded as type {type}, device {device}: "
+ + ", ".join(names))
listing = "\n".join(f" {i + 1}. {name}" for i, name in enumerate(names))
return io.NodeOutput(clip, f"FunPack CLIP Loader | type={type} device={device}\n{listing}")
diff --git a/modules/loaders/common.py b/modules/loaders/common.py
index 8d0922a9..46462432 100644
--- a/modules/loaders/common.py
+++ b/modules/loaders/common.py
@@ -10,10 +10,10 @@ which the loader cannot be handed by anyone else. Feature knobs -- strengths,
sigma windows, anything a modifier owns -- are settings, and never appear here.
"""
-import logging
-
import torch
+from .._core import log
+
# Named exactly as core's UNETLoader names them where they overlap, so a value
# copied from a stock workflow means the same thing here.
WEIGHT_DTYPES = ["default", "fp8_e4m3fn", "fp8_e4m3fn_fast", "fp8_e5m2", "fp16", "bf16", "fp32"]
@@ -80,11 +80,13 @@ def attention_override(name):
try:
from comfy.ldm.modules.attention import get_attention_function
except ImportError:
- logging.warning("[FunPack] this ComfyUI has no attention registry; leaving attention alone")
+ log.warning("attention", "this ComfyUI has no attention registry, so the "
+ "chosen backend cannot be applied")
return None
chosen = get_attention_function(name, None)
if chosen is None:
- logging.warning("[FunPack] attention backend %r is not available; leaving attention alone", name)
+ log.warning("attention", f"backend {name!r} is not available on this machine, "
+ f"so it was not applied")
return None
inner = getattr(chosen, "__wrapped__", chosen)
@@ -124,7 +126,7 @@ def set_fp16_accumulation(enabled):
before = bool(getattr(matmul, "allow_fp16_accumulation", False))
matmul.allow_fp16_accumulation = wanted
if before != wanted:
- logging.warning(
- "[FunPack] fp16 accumulation is now %s for EVERY model in this process, "
- "not just this one -- torch has a single process-wide flag.", wanted)
+ log.alert("fp16 accumulation",
+ f"now {wanted} for EVERY model in this process, not just this one -- "
+ f"torch has a single process-wide flag")
return wanted
diff --git a/modules/loaders/diffusion_model/nodes.py b/modules/loaders/diffusion_model/nodes.py
index 277309e9..1a0ff298 100644
--- a/modules/loaders/diffusion_model/nodes.py
+++ b/modules/loaders/diffusion_model/nodes.py
@@ -10,6 +10,7 @@ import comfy.utils
import folder_paths
from comfy_api.latest import io
+from ..._core import log
from ..common import (COMPUTE_DTYPES, WEIGHT_DTYPES, attention_choices,
attention_override, dtype_of, set_fp16_accumulation,
weight_model_options)
@@ -90,4 +91,11 @@ class FunPackDiffusionModelLoader(io.ComfyNode):
else:
notes.append("attention: default (as launched)")
+ # Never reach into the model's shape to describe it: a log line that
+ # assumes structure can fail the load it was only meant to narrate.
+ kind = type(getattr(model, "model", model)).__name__
+ log.info("FunPack Diffusion Model Loader",
+ f"{model_name} loaded as {kind}, weights "
+ f"{weight_dtype}, compute {compute_dtype}, attention "
+ f"{attention if override is not None else 'default (as launched)'}")
return io.NodeOutput(model, "\n".join(notes))
diff --git a/modules/loaders/lora/nodes.py b/modules/loaders/lora/nodes.py
index 814f4665..03d1bca0 100644
--- a/modules/loaders/lora/nodes.py
+++ b/modules/loaders/lora/nodes.py
@@ -12,6 +12,8 @@ import comfy.utils
import folder_paths
from comfy_api.latest import io
+from ..._core import log
+
class FunPackLoraLoader(io.ComfyNode):
@classmethod
@@ -45,6 +47,10 @@ class FunPackLoraLoader(io.ComfyNode):
# rather than clones: a clone here would silently drop patches a later
# loader in the chain applied to the original.
if strength_model == 0 and (clip is None or strength_clip == 0):
+ # Loud, because "applied a LoRA at strength 0" is indistinguishable
+ # from "forgot to set the strength" by looking at the picture.
+ log.alert("FunPack LoRA Loader",
+ f"{lora_name} has both strengths at 0 and was not applied")
return io.NodeOutput(model, clip, f"{lora_name}: both strengths are 0, not applied")
path = folder_paths.get_full_path_or_raise("loras", lora_name)
@@ -54,5 +60,6 @@ class FunPackLoraLoader(io.ComfyNode):
applied = [f"model={strength_model}"]
applied.append(f"clip={strength_clip}" if clip is not None else "clip=not wired")
+ log.info("FunPack LoRA Loader", f"{lora_name} applied ({', '.join(applied)})")
return io.NodeOutput(patched_model, patched_clip,
f"FunPack LoRA Loader | {lora_name} ({', '.join(applied)})")
diff --git a/modules/loaders/tests/test_loader_behaviour.py b/modules/loaders/tests/test_loader_behaviour.py
index 77662466..35756b3c 100644
--- a/modules/loaders/tests/test_loader_behaviour.py
+++ b/modules/loaders/tests/test_loader_behaviour.py
@@ -164,12 +164,12 @@ def test_an_unknown_encoder_family_is_refused_not_silently_downgraded(monkeypatc
nodes.FunPackCLIPLoader.execute(clip_name1="e.safetensors", type="not_a_family")
-def test_changing_the_process_wide_fp16_flag_is_never_silent(monkeypatch, caplog):
+def test_changing_the_process_wide_fp16_flag_is_never_silent(monkeypatch):
"""torch has ONE flag for the interpreter, so this cannot mean "for this
model". A second loader setting it differently changes the first model's
maths, and ComfyUI's loader caching means the last value outlives its run."""
- import logging
import torch
+ from core import log
from modules.loaders import common
class FakeMatmul:
@@ -178,23 +178,21 @@ def test_changing_the_process_wide_fp16_flag_is_never_silent(monkeypatch, caplog
fake = FakeMatmul()
monkeypatch.setattr(torch.backends, "cuda", type("C", (), {"matmul": fake}))
- with caplog.at_level(logging.WARNING):
- assert common.set_fp16_accumulation(True) is True
+ log._reset()
+ assert common.set_fp16_accumulation(True) is True
assert fake.allow_fp16_accumulation is True
- assert any("EVERY model in this process" in r.getMessage() for r in caplog.records)
+ assert any("EVERY model in this process" in r["message"] for r in log.history())
# No transition, nothing said.
- caplog.clear()
- with caplog.at_level(logging.WARNING):
- common.set_fp16_accumulation(True)
- assert not caplog.records
+ log._reset()
+ common.set_fp16_accumulation(True)
+ assert log.history() == []
# And turning it back off is a transition too: it is another model's setting
# being undone.
- caplog.clear()
- with caplog.at_level(logging.WARNING):
- common.set_fp16_accumulation(False)
- assert caplog.records
+ log._reset()
+ common.set_fp16_accumulation(False)
+ assert log.history()
def test_a_torch_build_without_the_flag_reports_that_rather_than_pretending():
diff --git a/modules/loaders/vae/nodes.py b/modules/loaders/vae/nodes.py
index 14405f0c..596f8267 100644
--- a/modules/loaders/vae/nodes.py
+++ b/modules/loaders/vae/nodes.py
@@ -12,6 +12,8 @@ import folder_paths
import torch
from comfy_api.latest import io
+from ..._core import log
+
# Chosen, not inferred. bf16 is the default because fp16 is a known failure on
# the AV VAEs, and fp32 decodes slowly for no visible gain.
DTYPES = {
@@ -53,4 +55,5 @@ class FunPackVAELoader(io.ComfyNode):
state_dict = comfy.utils.load_torch_file(path)
vae = comfy.sd.VAE(sd=state_dict, dtype=DTYPES[dtype])
vae.throw_exception_if_invalid()
+ log.info("FunPack VAE Loader", f"{vae_name} loaded as {dtype}")
return io.NodeOutput(vae, f"{vae_name} loaded as {dtype}")
diff --git a/modules/sampling/alg/__init__.py b/modules/sampling/alg/__init__.py
index 77bc84b9..a188b590 100644
--- a/modules/sampling/alg/__init__.py
+++ b/modules/sampling/alg/__init__.py
@@ -61,14 +61,11 @@ SETTINGS = {
},
}
-_said = set()
-
-
def _once(message: str) -> None:
- """Say an inert reason once per process, not once per step."""
- if message not in _said:
- _said.add(message)
- log.note(message)
+ """Say an inert reason once per RUN, not once per step and not once per
+ process -- a session that deduped for the life of the interpreter reported
+ the first generation that went inert and stayed quiet for every one after."""
+ log.once(f"alg:{message}", log.ALERT, "FunPack ALG", message)
def install(patcher, values, key):
@@ -87,14 +84,14 @@ def install(patcher, values, key):
# No anchor pinned means nothing to de-static; a sampler that is not
# KSAMPLER-shaped has no loop to stand between.
if inner_fn is None or latent_image is None or denoise_mask is None:
- _once("[FunPack] ALG is off this run: no pinned anchor to loosen.")
+ _once("off this run: there is no pinned anchor to loosen")
return executor(model_wrap, sigmas, extra_args, callback, noise,
latent_image, denoise_mask, disable_pbar)
blurred = blur_frames(latent_image, strength, frame_indices=(0,))
if blurred is None:
- _once("[FunPack] ALG is off this run: this model's anchor is not a "
- "plain video latent, and reshaping it would be guesswork.")
+ _once("off this run: this model's anchor is not a plain video latent, "
+ "and reshaping it would be guesswork")
return executor(model_wrap, sigmas, extra_args, callback, noise,
latent_image, denoise_mask, disable_pbar)
diff --git a/modules/sampling/modifiers/nodes.py b/modules/sampling/modifiers/nodes.py
index 3db65735..9cecfa28 100644
--- a/modules/sampling/modifiers/nodes.py
+++ b/modules/sampling/modifiers/nodes.py
@@ -149,8 +149,14 @@ class FunPackLoadModifiers(io.ComfyNode):
stripped = patching.strip(patched, KEY_PREFIX)
applied, notes = [], []
- # One per run, so a long session reports every generation that went
- # inert rather than only the first one after a restart.
+ # A run starts here: whatever was said once may be said again, and the
+ # dropped-modifier record starts empty. Both are per-run state and they
+ # begin together so they cannot disagree about when a run began.
+ #
+ # The limit, stated: a graph without this node never marks a run, so
+ # once-per-run reverts to once-per-process there. That is the honest
+ # cost of ComfyUI having no notion of "a run started" a node can hook.
+ log.new_run()
dropped = patching.Dropped()
patched.funpack_dropped = dropped
diff --git a/modules/sampling/sharpen/__init__.py b/modules/sampling/sharpen/__init__.py
index 7fd2a7ad..4d807ccf 100644
--- a/modules/sampling/sharpen/__init__.py
+++ b/modules/sampling/sharpen/__init__.py
@@ -71,7 +71,7 @@ def install(patcher, values, key):
if amount == 0.0:
# Zero is a request for nothing, and saying so beats reporting that a
# modifier ran when it could not have changed a single pixel.
- log.note("[FunPack] detail is enabled at amount 0, which changes nothing")
+ log.alert("FunPack Detail", "enabled at amount 0, which changes nothing")
return None
radius = int(values.get("radius", 5))
diff --git a/tools/devserver.py b/tools/devserver.py
index 2d3b022c..9e486149 100644
--- a/tools/devserver.py
+++ b/tools/devserver.py
@@ -78,7 +78,13 @@ class Handler(BaseHTTPRequestHandler):
# The same manifest the aiohttp route serves, so what you see in the
# browser here is what ComfyUI would send.
- if path == P + "/api/modules":
+ if path == P + "/api/log":
+ query = parse_qs(parsed.query)
+ level = (query.get("level") or [None])[0]
+ body = json.dumps({"levels": list(routes.log.LEVELS),
+ "records": routes.log.history(level)}).encode()
+ served = static.Served(200, body, "application/json")
+ elif path == P + "/api/modules":
raw = parse_qs(parsed.query).get("traits")
traits = [t for t in raw[0].split(",") if t] if raw else None
body = json.dumps(routes.manifest(traits)).encode()