Files
malkuthro-ComfyUI-Koolook/scripts/sync_to_dev.py
T
040b30733d feat(matte): first-party mask-guided one-step video matting nodes (#269)
* feat(matte): first-party mask-guided one-step video matting nodes

Adds matte/ — an independent implementation of the published VideoMaMa
one-step SVD method (matte/pipeline.py) plus Koolook's temporal windowing,
subject-focused crop/stitch, resolution control (/64, shortest-side) and
auto OOM resolution-backoff. Nodes: KoolookMatteLoader, KoolookMatteSampler,
KoolookMatteFocusCrop, KoolookMatteFocusStitch.

Registered via the existing guarded _merge_node_group("Koolook Matte",
".matte"), so a missing diffusers disables only these nodes. diffusers is
already pulled in by the radiance/whatdreamscost forks, so no new dependency.

Loads (never bundles) the VideoMaMa UNet (CC-BY-NC) + SVD VAE at runtime;
matte/NOTICE records the model licenses and user responsibility.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(dev-sync): include matte/ in RUNTIME_PATHS

So `sync_to_dev` copies the new matte node package into the live ComfyUI
install alongside the rest of the koolook pack.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(matte): raw cond latents; fp16 default; preserve checkpoints on dev-sync

pipeline.py: _encode_latents must NOT divide by vae scaling_factor. The
VideoMaMa UNet is trained on raw VAE latents (matching okdalto's
VideoInferencePipeline, which multiplies by scaling_factor then divides it
back out -> net x1). Dividing over-scaled the conditioning ~5.5x and produced
a systematically blurry matte on every frame. Verified 5/5 vs the desktop /
vanilla reference on identical single-frame inputs.

nodes.py: harden the loader precision fallback so fp16 is the true default -
only an explicit "bf16" opts out; a stale "auto" widget now resolves to fp16
instead of bf16.

sync_to_dev.py: the rmtree+copytree mirror deleted matte/checkpoints/ (the
gitignored ~10GB SVD+VideoMaMa weights) on every matte sync, forcing a
re-download. Add PRESERVE_IN_DEST + selective _clean_dest so runtime-
downloaded model data survives a sync.

docs: add docs/maintainers/matte-pipeline.md (latent-scaling invariant,
black-output causes, fidelity levers, single-frame + vanilla-oracle debug
loop, checkpoint preservation).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(matte): address PR #269 review

- license: align matte to the repo's GPL-3.0 (was self-contradictory: claimed
  "MIT licensed - see LICENSE" while LICENSE is GPL-3.0). pipeline.py header,
  NOTICE, and the maintainer doc updated. NOTICE still credits VideoMaMa
  (CC BY-NC) + SVD (Stability Community License).
- nodes.py _ensure_2d_mask: squeeze first, then handle [H,W,C]/[C,H,W]
  explicitly and RAISE on ambiguous shapes -- previously [H,W,3] silently
  became [W,3] (wrong geometry).
- nodes.py OOM backoff: catch RuntimeError (OutOfMemoryError subclasses it, but
  cuBLAS/cuDNN/older paths raise plain "RuntimeError: CUDA out of memory"); the
  existing message guard still filters non-OOM errors.
- nodes.py: warn when the guide mask is empty instead of silently falling back
  to full-frame.
- sync_to_dev.py: PRESERVE_IN_DEST is now an exact relative path
  ("matte/checkpoints"), not a bare name matched at any depth; handle symlinks
  before is_dir() in _clean_dest so a symlinked dir isn't recursed/rmtree'd.
- CHANGELOG: add the Koolook Matte nodes under [Unreleased] with the
  non-commercial model-license caveat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(matte): address PR #269 re-review

- sync_to_dev.py [BLOCKING]: a top-level symlinked dest (e.g. target/matte -> X)
  was passed straight into _clean_dest()/copytree(), which follow the link and
  can mutate files outside the target. Now unlink a symlinked dest and own the
  path as a real dir (checked before exists(), which follows symlinks). Verified
  with a repro: the symlink target's file survives.
- nodes.py: force float32 on a CPU fallback (CUDA unavailable). Half precision
  is unreliable on CPU; fp16 kernels would error. Warn that CPU is very slow.
- docs: correct the preserved-path guidance to the target-relative
  "matte/checkpoints" (was a bare "checkpoints" name); reword "near-verbatim
  reimplementation" -> "independent implementation targeting behavioural parity"
  to match the NOTICE/pipeline framing.
- nodes.py: also warn on a degenerate (<8px) guide-mask region before the
  full-frame fallback (was silent).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* fix(matte): address PR #269 re-review (CI red + FocusCrop frame check)

- nodes.py: KoolookMatteFocusCrop now validates images vs coarse_mask frame
  counts (raises like KoolookMatteSampler.run_inference). A mismatch previously
  made cropped_images/cropped_mask diverge and silently drop/misalign frames at
  FocusStitch.
- pipeline.py: split "vae.to('cpu'); _clear_cache(...)" onto two lines (Ruff
  E702 was failing Lint & Smoke).
- constraints-test.txt: bump pillow 12.2.0 -> 12.3.0 (pip-audit CVE, fixed in
  12.3.0). Pillow is a leaf dep so the closure is unaffected; CI re-audits.
- README: note the Koolook Matte nodes need diffusers (already via the LTX /
  Radiance forks) and huggingface_hub for runtime weight downloads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: AI Machine <ai.machine@x.baconcph.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-19 17:03:12 +02:00

469 lines
17 KiB
Python

#!/usr/bin/env python3
"""
Copy runtime-relevant Koolook files into a live ComfyUI custom_nodes
folder so a fix can be tested without a tag-and-publish round-trip.
USER-INITIATED ONLY. This script overwrites a live ComfyUI install.
Agents must NEVER run it automatically - not after a commit, not after
a PR merge or ship-pr, not at session end, not from any "task complete"
cleanup. The maintainer typically has multiple parallel sessions across
worktrees, and an unsolicited sync from one silently destroys what
another is reviewing. See project CLAUDE.md `dev-sync` section for the
full policy. Run only on the explicit user trigger phrase.
The target path is read from the KOLOOK_COMFYUI_DEV_PATH environment
variable (loaded from `.env` at the repo root if present). The variable
is intentionally kept out of the committed tree - see `.env.example`.
`KOLOOK_COMFYUI_DEV_PATH` should point at the eventual Koolook
subdirectory inside `custom_nodes/`, NOT at the `custom_nodes/` parent.
Target ``custom_nodes/koolook/`` — that's where ComfyUI-Manager and the
Comfy Registry install (derived from ``[project].name`` in
``pyproject.toml``), so dev-sync overwrites the Manager install in place.
Targeting ``custom_nodes/ComfyUI-Koolook/`` instead spawns a parallel
install; ``__init__.py``'s duplicate-install guard logs a critical
message and disables the non-winning copy (issue #162).
Example layouts:
macOS: /Volumes/Data/ComfyUI/custom_nodes/koolook
Windows: C:/ComfyUI_portable/ComfyUI/custom_nodes/koolook
Usage:
python scripts/sync_to_dev.py # copy files
python scripts/sync_to_dev.py --dry-run # show what would copy
python scripts/sync_to_dev.py --init # first-run: create the
# target folder if missing
# (parent custom_nodes/ must
# already exist), then sync
After copying Python files, restart ComfyUI manually so custom-node modules
are re-imported. This script only copies files.
Exit codes:
0 success
2 KOLOOK_COMFYUI_DEV_PATH unset, parent missing, or target missing
(without --init)
3 --init refused: parent is not an existing directory or doesn't
resemble a ComfyUI custom_nodes/ folder
This script never reaches outside the repo, never deletes anything in
the source, and only touches paths under the configured target. It does
overwrite files in the target - that's the point.
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import subprocess
import sys
from datetime import datetime
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
def _get_short_sha() -> str | None:
"""Best-effort short commit SHA of the source tree being synced.
Returns ``None`` if git isn't reachable or the call fails - the
summary line just omits the SHA in that case.
"""
try:
r = subprocess.run(
["git", "rev-parse", "--short", "HEAD"],
cwd=str(REPO_ROOT),
capture_output=True,
text=True,
timeout=2,
)
if r.returncode == 0:
sha = r.stdout.strip()
if sha:
return sha
except (OSError, subprocess.TimeoutExpired):
pass
return None
def _get_worktree_name() -> str:
"""Returns the basename of the source tree being synced - useful when
the maintainer is running multiple parallel ComfyUI installs and
needs to know which checkout fed the most recent sync.
For a worktree at ``.../ComfyUI-Koolook/.claude/worktrees/foo`` this
returns ``foo``; for the main repo at ``.../ComfyUI-Koolook`` it
returns ``ComfyUI-Koolook``. Either is informative enough to
disambiguate."""
return REPO_ROOT.name
def build_line() -> str:
"""Composes the two-piece header line consumed by the chat-report
convention defined in project CLAUDE.md:
<short-sha> - <worktree-name>
SHA falls back to ``unknown`` if git is unreachable (we always need
SOMETHING in slot 1 - the line shape is part of the convention).
Worktree name comes from ``REPO_ROOT.name`` and is always present.
Public - consumed by scoped per-module wrappers like
``sync_to_dev_audio.py`` so every dev-sync variant emits the same
chat-report header.
"""
sha = _get_short_sha() or "unknown"
return f"{sha} - {_get_worktree_name()}"
# Backwards-compatible alias (the function was private until the audio
# wrapper landed). Drop after the next release cycle once we're sure no
# downstream caller imports the underscore name.
_build_line = build_line
def write_build_info(target: Path, scope: str | None) -> None:
"""Drop a tiny JSON next to the sidebar JS so the in-browser footer
can render `dev <sha> * <time>` (and an italic <scope> on a second
line) - same identifier the chat report quotes, but visible in the
running ComfyUI itself. Absent on registry installs (the file is
only written by this dev script), so the footer stays empty there.
Best-effort: missing git -> omit `commit` field; no `--scope` -> omit
`scope`; the timestamp alone is still useful for the maintainer to
eyeball "did my last sync land in this browser tab?\""""
info: dict[str, str] = {
"synced_at": datetime.now().strftime("%Y-%m-%d %H:%M"),
"worktree": _get_worktree_name(),
}
sha = _get_short_sha()
if sha:
info["commit"] = sha
if scope:
info["scope"] = scope
out = target / "web" / "_dev_build.json"
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(json.dumps(info, indent=2) + "\n", encoding="utf-8")
# Files / dirs ComfyUI loads at runtime, plus the package metadata ComfyUI
# Manager reads when showing the installed custom-node version. Anything
# outside this list (CI, docs, .claude/, .github/, .cursor/, CHANGELOG, LICENSE,
# README, fork manifest YAML, etc.) does not affect what ComfyUI executes or
# how the dev install is identified, and is intentionally skipped.
RUNTIME_PATHS: tuple[str, ...] = (
"pyproject.toml",
"__init__.py",
"config.json",
"k_ai_pipeline.py",
"k_clean_latent_slice.py",
"k_easy_image_batch.py",
"k_easy_pattern.py",
"k_easy_resize.py",
"k_easy_track.py",
"k_easy_utility.py",
"k_easy_wan22_prompt.py",
"k_loop_status.py",
"k_ltx_av_bind_schedule.py",
"k_ltx_guide_reference_strength.py",
"k_ltx_keyframe_soften_schedule.py",
"k_ltx_reference_bind_schedule.py",
"k_publish_contract.py",
"k_video_combine.py",
"k_video_load.py",
"koolook_install_guard.py",
"koolook_routes.py",
"koolook_setup_runner.py",
"koolook_setups.py",
"koolook_versioning.py",
"forks",
"matte",
"video_formats",
"web",
)
# When copying directories, exclude these subpaths - they are dev-only
# metadata that ComfyUI doesn't need and which can churn unnecessarily.
DIR_EXCLUDES: tuple[str, ...] = (
"__pycache__",
"UPSTREAM_PIN.yaml",
"THIRD_PARTY.md",
"forks_manifest.yaml",
"README.md",
)
# Paths (POSIX, relative to the sync TARGET root) that live ONLY in the live
# install — gitignored, downloaded at runtime — and must survive a sync. The
# repo has no copy of these, so a blanket ``rmtree`` of the dest subtree would
# delete them and force a multi-GB re-download on the next node load.
# ``matte/checkpoints/`` holds the SVD base model + VideoMaMa UNet (~10GB).
# Matched by EXACT relative path (not by bare name at any depth), so an
# unrelated ``checkpoints/`` elsewhere in the tree is still mirrored normally.
PRESERVE_IN_DEST: frozenset[str] = frozenset({"matte/checkpoints"})
def load_dotenv(env_path: Path) -> None:
"""Minimal `.env` loader. No dependency on python-dotenv."""
if not env_path.exists():
return
for line in env_path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
# Don't overwrite anything already in the environment.
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
def find_dotenv() -> Path | None:
"""Locate the `.env` to load.
Worktree root first, then the **main repo root** when running from a git
worktree (where `.git` is a file pointing at
`<main>/.git/worktrees/<name>`). The committed `.env` lives only in the
main checkout, so without this fallback `dev-sync` is unusable from a
worktree. Mirrors `scripts/make_card.py` and the documented behavior in
the dev-sync docs.
"""
direct = REPO_ROOT / ".env"
if direct.exists():
return direct
git_marker = REPO_ROOT / ".git"
if not git_marker.is_file():
return None
try:
content = git_marker.read_text(encoding="utf-8").strip()
except OSError:
return None
if not content.startswith("gitdir:"):
return None
gitdir = Path(content.split(":", 1)[1].strip())
if "worktrees" not in gitdir.parts:
return None
idx = gitdir.parts.index("worktrees")
main_repo_root = Path(*gitdir.parts[:idx]).parent
candidate = main_repo_root / ".env"
return candidate if candidate.exists() else None
def _ignore(_dir: str, names: list[str]) -> list[str]:
return [n for n in names if n in DIR_EXCLUDES]
def _is_preserved(rel: str) -> bool:
"""True if ``rel`` (POSIX, relative to the target root) is exactly a
preserved path."""
return rel in PRESERVE_IN_DEST
def _contains_preserved(rel: str) -> bool:
"""True if a preserved path lives strictly BELOW ``rel`` (so we descend into
``rel`` instead of deleting it wholesale)."""
prefix = rel + "/"
return any(p.startswith(prefix) for p in PRESERVE_IN_DEST)
def _clean_dest(dst: Path, base: Path) -> None:
"""Recursively remove ``dst``'s contents so the copy mirrors the repo, but
keep any ``PRESERVE_IN_DEST`` path (matched by its POSIX path relative to
``base``). Replaces a blanket ``shutil.rmtree(dst)`` that would delete the
gitignored, runtime-downloaded model weights and force a ~10GB re-download.
"""
for child in dst.iterdir():
rel = child.relative_to(base).as_posix()
if _is_preserved(rel):
continue # keep runtime-downloaded data (model checkpoints)
# Symlinks first: a symlinked dir reports is_dir()==True, so recursing /
# rmtree'ing it could reach outside the tree. Just drop the link.
if child.is_symlink():
child.unlink()
elif child.is_dir():
if _contains_preserved(rel):
_clean_dest(child, base) # descend to spare a nested preserved subtree
else:
shutil.rmtree(child)
else:
child.unlink()
def sync(
target: Path,
dry_run: bool,
verbose: bool,
paths: tuple[str, ...] = RUNTIME_PATHS,
) -> int:
"""Copy each entry in ``paths`` (relative to the repo root) to the
matching subpath under ``target``. Directories are recursively
copied with ``DIR_EXCLUDES`` filtered out and the previous dest
subtree removed first; files are overwritten in place.
The optional ``paths`` argument lets scoped wrappers (e.g.
``sync_to_dev_audio.py``) reuse this function with a smaller set -
just the subtree their automation module touches. The default is
the full ``RUNTIME_PATHS`` (every file ComfyUI loads at runtime).
"""
copied = 0
for rel in paths:
src = REPO_ROOT / rel
if not src.exists():
continue
dst = target / rel
if dry_run:
if verbose:
print(f"would copy: {rel}")
copied += 1
continue
if src.is_dir():
# Clean the dest for a fresh mirror, but keep PRESERVE_IN_DEST
# subtrees (runtime-downloaded model checkpoints the repo doesn't
# carry). A blanket rmtree here would nuke matte/checkpoints/ and
# trigger a ~10GB re-download on the next load.
# A top-level symlinked dest (e.g. target/matte -> elsewhere) must
# never be cleaned/copied THROUGH: iterdir()/copytree would follow it
# and mutate files outside the target. Drop the link and own the path
# as a real dir. (is_symlink is checked before exists(), which follows
# symlinks.)
if dst.is_symlink():
dst.unlink()
dst.mkdir(parents=True, exist_ok=True)
elif dst.exists():
_clean_dest(dst, target)
else:
dst.mkdir(parents=True, exist_ok=True)
shutil.copytree(src, dst, ignore=_ignore, dirs_exist_ok=True)
else:
dst.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(src, dst)
if verbose:
print(f"copied: {rel}")
copied += 1
return copied
def _looks_like_custom_nodes(parent: Path) -> bool:
"""Heuristic: parent is named `custom_nodes` OR sits inside a
directory named `ComfyUI`. A bit conservative - saves the user from
a `KOLOOK_COMFYUI_DEV_PATH` typo that would otherwise create a fresh
`ComfyUI-Koolook/` somewhere unexpected on disk."""
if parent.name == "custom_nodes":
return True
grandparent = parent.parent
return grandparent.name.lower() == "comfyui" and parent.is_dir()
def ensure_target(target: Path, init: bool) -> int | None:
"""Validate the target. Returns an exit code (2 or 3) on error,
or None on success (with target now guaranteed to exist as a dir)."""
if target.exists():
if not target.is_dir():
print(f"target is not a directory: {target}", file=sys.stderr)
return 2
return None
# Target is missing.
if not init:
print(
f"target does not exist: {target}\n"
f"first time on this machine? re-run with --init to create it.",
file=sys.stderr,
)
return 2
parent = target.parent
if not parent.exists() or not parent.is_dir():
print(
f"--init refused: parent does not exist: {parent}\n"
f"check KOLOOK_COMFYUI_DEV_PATH - the *parent* (typically a "
f"ComfyUI custom_nodes/ folder) must already be in place.",
file=sys.stderr,
)
return 3
if not _looks_like_custom_nodes(parent):
print(
f"--init refused: parent doesn't look like a ComfyUI "
f"custom_nodes/ folder: {parent}\n"
f"expected the parent to be named 'custom_nodes' or to sit "
f"inside a 'ComfyUI' directory. If this really is your "
f"ComfyUI install, create the target manually with "
f"`mkdir -p \"{target}\"` and re-run without --init.",
file=sys.stderr,
)
return 3
target.mkdir(parents=False, exist_ok=False)
print(f"created target: {target}")
return None
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--dry-run",
action="store_true",
help="Print what would be copied without touching the target.",
)
parser.add_argument(
"--init",
action="store_true",
help=(
"Create the target directory if it doesn't exist. The parent "
"(typically a ComfyUI custom_nodes/ folder) must already be "
"in place. Use on first-run only."
),
)
parser.add_argument(
"--verbose",
"-v",
action="store_true",
help=(
"Print each file/dir as it's copied. Default output is just "
"the one-line build summary."
),
)
parser.add_argument(
"--scope",
type=str,
default=None,
help=(
"Short (<=10 word) description of what this build is about - "
"the same scope summary that goes in the chat report's second "
"line. Persisted in `web/_dev_build.json` and rendered in the "
"Kforge Labs sidebar footer (italic, second line, below the "
"`dev <sha> * <time>` identifier) so the maintainer can "
"correlate live ComfyUI state with chat history when juggling "
"multiple parallel worktree sessions. Optional - when absent, "
"the footer renders just identifier + timestamp."
),
)
args = parser.parse_args()
env_path = find_dotenv()
if env_path is not None:
load_dotenv(env_path)
target_str = os.environ.get("KOLOOK_COMFYUI_DEV_PATH")
if not target_str:
print(
"KOLOOK_COMFYUI_DEV_PATH not set. Add it to .env "
"(see .env.example).",
file=sys.stderr,
)
return 2
target = Path(target_str).expanduser()
err = ensure_target(target, init=args.init)
if err is not None:
return err
n = sync(target, dry_run=args.dry_run, verbose=args.verbose)
verb = "would sync" if args.dry_run else "synced"
# Two-line summary - see project CLAUDE.md `dev-sync` section for the
# chat-report convention that consumes this output. Header first so
# the maintainer's eye lands on the build identifier before the
# mechanical sync details.
print(build_line())
print(f"{verb} {n} entries -> {target}")
if not args.dry_run:
write_build_info(target, args.scope)
return 0
if __name__ == "__main__":
sys.exit(main())