diff --git a/app/catalogue/log.html b/app/catalogue/log.html new file mode 100644 index 00000000..bf4fb155 --- /dev/null +++ b/app/catalogue/log.html @@ -0,0 +1,6 @@ + + +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()