Two owner-reported bugs, both verified as gaps that never closed (the old code on master had them too). Cancel: the node never polled ComfyUI's interrupt flag, so the Cancel button had no effect. A new src/keep_interrupt.py wraps comfy.model_management.throw_exception_if_processing_interrupted() (lazy import, a no-op outside ComfyUI); it is called at every long-loop boundary - detection, crop, restore chunks, paste frames, and the paste module's group/chunk loops. InterruptProcessingException is a BaseException, so it escapes the pipeline untouched. Multi-face: the pipeline collapsed to one face per frame - the detector call passed only_keep_largest=True (the vendored selector reduces to the largest face) and the processor then took all_landmarks_5[0]. With only_center_face=False the new branches keep every detected face: detection returns all faces, the crops/restore/paste flatten to the ragged per-frame face count, the device canvas composites several ROIs per row, and the vendored fallback hands the helper all of a frame's faces. The near-black tail follows the real vendored reference (one convert per image after a forward face loop). only_center_face=True and the aligned path keep their bodies byte-for-byte. 44 new pins: cancel (the exception escapes the sequence and the node, no work after it, unchanged output without a cancel), multi-face image and video (all faces restored and pasted, faces appearing/disappearing, None-affine and black-face drops, per-row near-black tail against the vendored composite), and the paste core (face-to-frame map, row compositing, chunk boundaries that never split a frame's faces). Verified on the CPU device only - no model loads, no GPU runs. Gate: 505 passed, 6 skipped, 7 deselected, 0 failed (461 baseline + 44 new pins).
367 lines
13 KiB
Python
367 lines
13 KiB
Python
"""The console routing of the pack - every console write reaches the logger.
|
|
|
|
The vendored facelib writes to the console with ``print()``. Those writes
|
|
bypass the console of ComfyUI: they are untagged and they never reach the
|
|
log file of the host. ``src/keep_console.py`` routes them to the logger of
|
|
the pack:
|
|
|
|
* the writes of the **owning thread** become log records - one record per
|
|
complete line - and the tag filter adds the ``[KEEP]`` tag;
|
|
* the writes of **another thread** are forwarded to the original stream
|
|
untouched;
|
|
* the streams are restored when the block ends - also after an exception;
|
|
* the output of ComfyUI is not swallowed: its root handler holds the stream
|
|
object it was built with and writes straight through the swap.
|
|
|
|
The file also carries the guards of the **tqdm removal**: the processor must
|
|
not import ``tqdm`` any more - the progress of the node is reported through
|
|
the ComfyUI execution API (``src/keep_progress.py``).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import ast
|
|
import contextlib
|
|
import importlib
|
|
import io
|
|
import logging
|
|
import sys
|
|
import threading
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
SRC_DIR = REPO_ROOT / "src"
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def console_module():
|
|
return importlib.import_module("ComfyUI_KEEP.src.keep_console")
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def logger_module():
|
|
return importlib.import_module("ComfyUI_KEEP.src.keep_logger")
|
|
|
|
|
|
class _Capture(logging.Handler):
|
|
"""A handler that keeps the records it receives."""
|
|
|
|
def __init__(self) -> None:
|
|
super().__init__()
|
|
self.records: list[logging.LogRecord] = []
|
|
|
|
def emit(self, record: logging.LogRecord) -> None:
|
|
self.records.append(record)
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def _records(target: logging.Logger):
|
|
"""The records the logger receives while the block runs."""
|
|
handler = _Capture()
|
|
target.addHandler(handler)
|
|
try:
|
|
yield handler.records
|
|
finally:
|
|
target.removeHandler(handler)
|
|
|
|
|
|
def _pack_logger(logger_module, name: str) -> logging.Logger:
|
|
"""A **dedicated** pack logger - never the global one.
|
|
|
|
The name carries no dot on purpose: a dotted name would make the logger
|
|
a child of ``ComfyUI-KEEP`` and the records would travel through the
|
|
global pack logger.
|
|
"""
|
|
return logger_module.configure_logger(logging.getLogger(name), comfyui_present=True)
|
|
|
|
|
|
def _messages(records) -> list[str]:
|
|
"""The messages of the records."""
|
|
return [record.getMessage() for record in records]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# the capture
|
|
# ---------------------------------------------------------------------------
|
|
@pytest.mark.unit
|
|
def test_a_print_inside_the_block_becomes_a_tagged_record(
|
|
console_module, logger_module
|
|
):
|
|
"""The console write of the owning thread reaches the logger."""
|
|
logger = _pack_logger(logger_module, "KEEP-test-console-print")
|
|
|
|
with _records(logger) as records:
|
|
with console_module.capture_console_to_logger(logger):
|
|
print("hello from the vendored tree")
|
|
|
|
assert _messages(records) == ["[KEEP] hello from the vendored tree"]
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_the_default_logger_is_the_logger_of_the_pack(console_module, logger_module):
|
|
"""Without an argument the pack routes to the logger of the pack."""
|
|
assert console_module.logger is logger_module.logger
|
|
|
|
with _records(logger_module.logger) as records:
|
|
with console_module.capture_console_to_logger():
|
|
print("hello")
|
|
|
|
assert _messages(records) == ["[KEEP] hello"]
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_a_partial_write_is_buffered_until_the_line_is_complete(
|
|
console_module, logger_module
|
|
):
|
|
"""One record per **complete** line - a partial line waits for its newline."""
|
|
logger = _pack_logger(logger_module, "KEEP-test-console-partial")
|
|
|
|
with _records(logger) as records:
|
|
with console_module.capture_console_to_logger(logger):
|
|
sys.stdout.write("first ")
|
|
assert _messages(records) == [], "an incomplete line must not be logged"
|
|
sys.stdout.write("line\n")
|
|
sys.stdout.write("second line\n")
|
|
|
|
assert _messages(records) == ["[KEEP] first line", "[KEEP] second line"]
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_a_line_without_a_newline_is_flushed_at_the_end_of_the_block(
|
|
console_module, logger_module
|
|
):
|
|
"""The output of a ``print(..., end="")`` is not lost."""
|
|
logger = _pack_logger(logger_module, "KEEP-test-console-flush")
|
|
|
|
with _records(logger) as records:
|
|
with console_module.capture_console_to_logger(logger):
|
|
sys.stdout.write("no newline")
|
|
|
|
assert _messages(records) == ["[KEEP] no newline"]
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_the_stderr_of_the_owning_thread_is_captured_too(
|
|
console_module, logger_module
|
|
):
|
|
"""``sys.stderr`` is captured as well - as a warning."""
|
|
logger = _pack_logger(logger_module, "KEEP-test-console-stderr")
|
|
|
|
with _records(logger) as records:
|
|
with console_module.capture_console_to_logger(logger):
|
|
print("on stderr", file=sys.stderr)
|
|
|
|
assert len(records) == 1
|
|
assert records[0].getMessage() == "[KEEP] on stderr"
|
|
assert records[0].levelno == logging.WARNING
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# the thread scope
|
|
# ---------------------------------------------------------------------------
|
|
@pytest.mark.unit
|
|
def test_the_write_of_another_thread_is_not_captured(
|
|
console_module, logger_module, monkeypatch
|
|
):
|
|
"""Another thread keeps the console - its write is forwarded untouched."""
|
|
logger = _pack_logger(logger_module, "KEEP-test-console-thread")
|
|
original = io.StringIO()
|
|
monkeypatch.setattr(sys, "stdout", original)
|
|
|
|
inside = threading.Event()
|
|
done = threading.Event()
|
|
|
|
def _worker():
|
|
inside.wait(5)
|
|
print("from the worker")
|
|
done.set()
|
|
|
|
with _records(logger) as records:
|
|
with console_module.capture_console_to_logger(logger):
|
|
worker = threading.Thread(target=_worker)
|
|
worker.start()
|
|
inside.set()
|
|
assert done.wait(5), "the worker did not finish"
|
|
worker.join(5)
|
|
|
|
assert _messages(records) == [], "the write of the worker must not be captured"
|
|
assert original.getvalue() == "from the worker\n"
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_two_threads_capture_into_their_own_logger(
|
|
console_module, logger_module
|
|
):
|
|
"""Two capture blocks may run at the same time - each one owns its logger."""
|
|
logger_a = _pack_logger(logger_module, "KEEP-test-console-parallel-a")
|
|
logger_b = _pack_logger(logger_module, "KEEP-test-console-parallel-b")
|
|
|
|
inside_a = threading.Event()
|
|
inside_b = threading.Event()
|
|
errors: list[BaseException] = []
|
|
|
|
def _run(name, target, mine, other):
|
|
try:
|
|
with console_module.capture_console_to_logger(target):
|
|
mine.set()
|
|
other.wait(5)
|
|
print(f"from {name}")
|
|
except BaseException as exc: # noqa: BLE001 - the failure is asserted below
|
|
errors.append(exc)
|
|
|
|
with _records(logger_a) as records_a, _records(logger_b) as records_b:
|
|
thread_a = threading.Thread(target=_run, args=("a", logger_a, inside_a, inside_b))
|
|
thread_a.start()
|
|
assert inside_a.wait(5), "the first thread did not enter the block"
|
|
thread_b = threading.Thread(target=_run, args=("b", logger_b, inside_b, inside_a))
|
|
thread_b.start()
|
|
thread_a.join(5)
|
|
thread_b.join(5)
|
|
|
|
assert errors == []
|
|
assert _messages(records_a) == ["[KEEP] from a"]
|
|
assert _messages(records_b) == ["[KEEP] from b"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# the restoration
|
|
# ---------------------------------------------------------------------------
|
|
@pytest.mark.unit
|
|
def test_the_streams_are_restored_after_the_block(console_module, logger_module):
|
|
"""The block leaves the streams of the process as it found them."""
|
|
logger = _pack_logger(logger_module, "KEEP-test-console-restore")
|
|
before = (sys.stdout, sys.stderr)
|
|
|
|
with console_module.capture_console_to_logger(logger):
|
|
assert (sys.stdout, sys.stderr) != before
|
|
|
|
assert (sys.stdout, sys.stderr) == before
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_the_streams_are_restored_after_an_exception(
|
|
console_module, logger_module
|
|
):
|
|
"""An exception inside the block does not leave the streams swapped."""
|
|
logger = _pack_logger(logger_module, "KEEP-test-console-restore-error")
|
|
before = (sys.stdout, sys.stderr)
|
|
|
|
with pytest.raises(RuntimeError):
|
|
with console_module.capture_console_to_logger(logger):
|
|
raise RuntimeError("boom")
|
|
|
|
assert (sys.stdout, sys.stderr) == before
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_a_nested_block_restores_the_outer_one(console_module, logger_module):
|
|
"""The blocks may be nested - the inner one does not break the outer."""
|
|
logger = _pack_logger(logger_module, "KEEP-test-console-nested")
|
|
outer = _pack_logger(logger_module, "KEEP-test-console-nested-outer")
|
|
before = (sys.stdout, sys.stderr)
|
|
|
|
with _records(logger) as records_inner, _records(outer) as records_outer:
|
|
with console_module.capture_console_to_logger(outer):
|
|
with console_module.capture_console_to_logger(logger):
|
|
print("inner")
|
|
print("outer")
|
|
|
|
assert (sys.stdout, sys.stderr) == before
|
|
assert _messages(records_outer) == ["[KEEP] outer"]
|
|
assert _messages(records_inner) == ["[KEEP] inner"]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# the output of the host
|
|
# ---------------------------------------------------------------------------
|
|
@pytest.mark.unit
|
|
def test_the_output_of_comfyui_is_not_swallowed(
|
|
console_module, logger_module, monkeypatch
|
|
):
|
|
"""The root handler of the host holds its own stream - it is not routed.
|
|
|
|
``app.logger.setup_logger()`` installs the ``LogInterceptor`` before it
|
|
builds the root handler, so the handler writes straight through the swap.
|
|
"""
|
|
logger = _pack_logger(logger_module, "KEEP-test-console-comfy")
|
|
original = io.StringIO()
|
|
monkeypatch.setattr(sys, "stdout", original)
|
|
monkeypatch.setattr(sys, "stderr", original)
|
|
|
|
handler = logging.StreamHandler(original)
|
|
host_logger = logging.getLogger("KEEP-test-comfy-host")
|
|
host_logger.setLevel(logging.WARNING)
|
|
host_logger.addHandler(handler)
|
|
try:
|
|
with console_module.capture_console_to_logger(logger):
|
|
host_logger.warning("comfy says hello")
|
|
finally:
|
|
host_logger.removeHandler(handler)
|
|
|
|
assert "comfy says hello" in original.getvalue()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# the tqdm removal
|
|
# ---------------------------------------------------------------------------
|
|
def _imported_modules(path: Path) -> set[str]:
|
|
"""The top level modules a source file imports."""
|
|
tree = ast.parse(path.read_text(encoding="utf-8"), filename=str(path))
|
|
imported: set[str] = set()
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, ast.Import):
|
|
imported.update(alias.name.split(".")[0] for alias in node.names)
|
|
elif isinstance(node, ast.ImportFrom) and node.module:
|
|
imported.add(node.module.split(".")[0])
|
|
return imported
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_the_processor_does_not_import_tqdm():
|
|
"""The progress bars of the processor are gone - the pack logs its steps."""
|
|
source = SRC_DIR / "keep_processor.py"
|
|
assert "tqdm" not in _imported_modules(source)
|
|
assert "tqdm(" not in source.read_text(encoding="utf-8")
|
|
|
|
|
|
@pytest.mark.unit
|
|
def test_the_processor_logs_the_four_steps():
|
|
"""Each sequence path logs its four steps - one message each, in order."""
|
|
source = (SRC_DIR / "keep_processor.py").read_text(encoding="utf-8")
|
|
tree = ast.parse(source, filename=str(SRC_DIR / "keep_processor.py"))
|
|
four_steps = [
|
|
"Detecting face landmarks",
|
|
"Cropping and aligning faces",
|
|
"Restoring faces with KEEP",
|
|
"Pasting faces and finalizing frames",
|
|
]
|
|
|
|
def _stage_messages(fn):
|
|
calls = [
|
|
node
|
|
for node in ast.walk(fn)
|
|
if isinstance(node, ast.Call)
|
|
and isinstance(node.func, ast.Attribute)
|
|
and node.func.attr == "info"
|
|
and isinstance(node.func.value, ast.Name)
|
|
and node.func.value.id == "logger"
|
|
and node.args
|
|
and isinstance(node.args[0], ast.Constant)
|
|
and isinstance(node.args[0].value, str)
|
|
]
|
|
messages = [node.args[0].value for node in sorted(calls, key=lambda call: call.lineno)]
|
|
return [m for m in messages if m in four_steps]
|
|
|
|
paths = [
|
|
node.name
|
|
for node in ast.walk(tree)
|
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
and _stage_messages(node)
|
|
]
|
|
assert paths, "no sequence path logs its four steps"
|
|
for node in ast.walk(tree):
|
|
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name in paths:
|
|
assert _stage_messages(node) == four_steps, node.name
|