* feat(video): add Easy_VideoCombine with absolute-path support Adds a Koolook subclass of VHS VideoCombine that bypasses ComfyUI's output-directory sandbox when filename_prefix is an absolute path — the VFX-pipeline missing piece for writing renders into a project tree (e.g. E:/projects/shot01/v003) instead of ComfyUI/output/. Implementation: - k_video_combine.py: ~60 LoC subclass + a pure _resolve_abs_target helper. Scoped-patches folder_paths.get_save_image_path for the duration of one combine_video() call so upstream's encoder, audio mux, batch manager, and progress bar run unchanged. No VHS code copied. Self-skips registration when VHS isn't importable (clean one-line stderr notice). - Discriminator: os.path.isabs(filename_prefix). No new input pin; pattern borrowed from spacepxl/ComfyUI-HQ-Image-Save's SaveEXR. - Optional create_path_if_missing BOOLEAN (default False) so typos surface as FileNotFoundError instead of spawning stray dirs. License audit (2026-05-16): - VHS upstream: GPL-3.0 (verified via raw LICENSE fetch). - HQ-Image-Save pattern source: MIT, copyright 2023 spacepxl. - Koolook: already GPL-3.0; both upstreams are compatible. - No source code from either upstream is incorporated. Verification: 18 pytest cases green (12 existing + 6 new covering relative passthrough, absolute resolution, create-on/off branches, trailing-separator normalization, and empty-basename rejection). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(starter-preset): pre-favourite Easy_VideoCombine Adds the new node to the shipped starter_preset.json picks so fresh installs surface it in the Kforge Labs sidebar's Nodes section without the user having to discover it via Comfy's stock search first. Pairs naturally with the existing LoadEXR / SaveEXRFrames picks from HQ-Image-Save. Existing installs with non-empty picks are unaffected (the seeder skips them by design); they can either Load -> "starter" in the Snapshot dialog or hit "+" once in the Nodes section to pull it in. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(dev-sync): include k_video_combine.py in RUNTIME_PATHS Without this entry the new Easy_VideoCombine module is never copied into the dev ComfyUI install, so the node fails to register on restart with a silent ImportError. Caught when the maintainer restarted ComfyUI and couldn't find the node in the search palette. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(starter-preset): include all Koolook nodes in default picks Fresh installs should land on a populated Nodes (favorites) panel that immediately surfaces what Koolook provides — otherwise new users see only third-party picks and have no entry point into the Koolook-native workflow. Adds the 8 currently-registered Koolook node IDs to the shipped starter preset alongside the existing third-party essentials: EasyAIPipeline, EasyResize_Koolook, EasyWan22Prompt, Easy_Pattern, Easy_VideoCombine, Easy_hdr_VAE_decode, Easy_hdr_VAE_encode, KoolookLoadCameraPosesAbsolute, easy_ImageBatch Also adds Easy_VideoCombine to the README's Included Nodes table. Existing installs with non-empty picks are unaffected by the seeder (by design). To pull the Koolook defaults into an existing install, Quick Save first, then Snapshot -> Load -> "starter". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(video): reach VHS_VideoCombine via nodes.NODE_CLASS_MAPPINGS The previous `from videohelpersuite.nodes import VideoCombine` fails inside ComfyUI's loader context because VHS uses relative imports throughout (`from .logger ...`, `from .utils ...`) that only resolve when VHS is loaded through ComfyUI's importlib spec — not through a plain absolute import path. The error surfaced as No module named 'utils.install_util'; 'utils' is not a package at Koolook load time, which caused the node to self-skip with an empty NODE_CLASS_MAPPINGS — invisible in ComfyUI's search palette. Fix: look up VHS_VideoCombine through `nodes.NODE_CLASS_MAPPINGS`, which is the documented cross-pack interface. ComfyUI's loader (custom_nodes/.../__init__.py exec_module → merge into global) runs to completion for each pack before moving to the next, and VHS sorts before Koolook alphabetically in every layout we've seen, so by the time Koolook's __init__.py runs the global registry already has VHS_VideoCombine in it. Verified against ComfyUI's nodes.py load_custom_node() at load_custom_node() lines 2196 (exec_module) and 2225-2228 (merge), which guarantee this ordering. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(video): add output_directory input for split path/name editing Overloading filename_prefix for absolute paths means losing the "just type a name" affordance — once you put the path there, there's nowhere to specify just the file root. Across many renders into the same project tree this becomes tedious: every iteration retypes the whole path. Adds an optional `output_directory` STRING input. When set: - filename_prefix is treated as just the filename root; any path components are stripped (so accidental typos like "sub/clip" don't double-up under the directory). - Absolute output_directory writes directly to that location. - Relative output_directory joins under ComfyUI's output/. When output_directory is empty, the original overloaded filename_prefix behavior is preserved unchanged (back-compat for the one-field flow). Implementation: - New pure helper `_compose_prefix(filename_prefix, output_directory)` combines the two fields into one effective prefix. The existing isabs discrimination then handles sandboxed vs absolute writes uniformly downstream. - 6 new tests cover all combinations: empty/abs/relative directory, path stripping from filename_prefix, trailing-separator trim, empty-name fallback. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(video): coerce frontend 'undefined' STRING values to empty The ComfyUI frontend serializes a STRING widget with empty default as the literal string "undefined" when left untouched (a known long-running quirk). Without defensive normalization, an untouched output_directory was treated as a real directory name and prepended to the user's path: filename_prefix = "E:/renders/clip_v003" (typed by user) output_directory = "undefined" (frontend default, not typed) _compose_prefix(...) -> "undefined/clip_v003" (relative) _resolve_abs_target -> None (not absolute) super().combine_video -> writes to <output>/undefined/clip_v003_* The QuickTime ended up at <ComfyUI>/output/undefined/<name>.mov with a spurious `undefined/` subfolder, instead of at the absolute path the user actually specified. Fix: a `_normalize_text_input()` helper coerces the literal strings "undefined" / "null" / "None" (case-insensitive, post-strip) to "". Applied to both filename_prefix and output_directory at the top of combine_video() before any path composition. 4 new tests cover the sentinels and a regression test for the exact path-composition behavior that produced the bug. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(video-fe): wire format-specific widgets for Easy_VideoCombine VHS's frontend extension (web/js/VHS.core.js:2030) gates its format-widget machinery on `nodeData?.name == "VHS_VideoCombine"` — strict-equality on the registered node ID. Our subclass uses a different ID (`Easy_VideoCombine`), so none of VHS's per-format widget rendering ran. The user saw: [VideoHelperSuite] - WARNING - Missing input for p has been set to hq on every execution (profile defaulting to "hq" for ProRes), and had no way to pick anything other than the default — because the profile dropdown was never rendered. Fix: a minimal frontend extension at web/easy_video_combine.js (~80 LoC) that replicates `addFormatWidgets` for our node name. Hooks the format dropdown's callback to add/remove per-format widgets the same way upstream does. Self-contained — does not import from VHS's module (their helpers aren't exported as ES imports). Verifies via the actual ComfyUI install after dev-sync; static preview servers can't exercise it (depends on LiteGraph's registered_node_types at runtime). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(video): strip 'undefined' from anywhere + one-shot debug log The previous normalization only caught the EXACT literal "undefined" / "null" / "None" at field-level. Adds two stronger defenses for the case where a sentinel still slips through: 1. _normalize_text_input now also strips surrounding quotes (paste- from-Explorer artifact) and handles embedded newlines by taking the first non-empty non-sentinel line (defends against multi-line Text Multiline inputs with trailing blank or junk lines). 2. New _strip_sentinel_components helper drops any path component that is exactly a sentinel string from the composed effective_ prefix — belt-and-suspenders for any path that somehow carries `\undefined\` as a literal segment by the time it reaches us. Also adds a one-shot print() of fp_raw, od_raw, normalized values, and effective_prefix at the top of combine_video. The user is seeing an undefined/ folder under their custom path despite the field-level normalization, and pure code tracing doesn't reproduce it — the log will surface what kwargs actually contains at run time so we can fix the real cause if defensive stripping doesn't already handle it. To be removed in a follow-up once root cause is known. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * chore(video): trim node docstring + tooltips, remove debug print - Class docstring collapsed from ~20 lines to a single sentence (the frontend was showing it as the node description on right-click, cluttering the UI). - Widget tooltips trimmed to one short sentence each — same intent, no walls of text. - Debug print() added in943be67removed (defensive strip handled the undefined/ bug; no further diagnosis needed). - Local fp_raw / od_raw variables (only used by the debug print) collapsed back into direct call sites. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * feat(video): suppress metadata PNG + silent intermediate by default Upstream VHS writes 3 files per render when audio is connected: - <name>_NNNNN.png (first-frame sidecar with workflow metadata) - <name>_NNNNN.mov (silent intermediate) - <name>_NNNNN-audio.mov (final audio-muxed video) Both extras are controlled by hidden VHS_MetadataImage and VHS_KeepIntermediate flags in extra_pnginfo['workflow']['extra'], both default-on upstream. Result: a single 500MB render lands as three files (one needed, two clutter), nearly 2x the disk footprint and a confusing folder. Adds two optional BOOLEAN inputs (both default False on Koolook): - save_metadata_png - keep_silent_intermediate combine_video injects them into extra_pnginfo before delegating to super(), so the existing upstream flags do the actual work — no post-hoc file deletion, no fork divergence from upstream's encoder path. The extra_pnginfo dict is copied (not mutated in place) so other nodes in the workflow that reference the same dict are unaffected. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * diag(video): log frame-count + audio length, restore PNG default-on Two changes to investigate a doubled-length output report: 1. Default save_metadata_png back to True (was False since946e170). The PNG sidecar carries the embedded workflow JSON — it's the only audit trail when a render comes out wrong. Removing it by default left no way to diagnose retroactively. Users who don't want the sidecar can still toggle it off explicitly; ones who do want auditability (most VFX pipelines) get it automatically. 2. One-shot diagnostic print() at the top of combine_video logging: - input image batch size + shape - audio sample count + sample rate -> seconds - frame_rate -> expected video seconds - pingpong + loop_count This pins down whether a doubled-length output is the image stream arriving already-doubled (workflow issue: ImageConcatMulti in batch mode, an upstream pingpong, etc.) vs combine_video itself doing something with the frames. To be removed in a follow-up once the current report is diagnosed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(video): restore Easy_VideoCombine widgets by name * feat(video): polish Easy_VideoCombine review workflow * test(video): fix Easy_VideoCombine pytest coverage * docs: require repo-local Python venv * fix(video): handle existing silent mux intermediates --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
431 lines
17 KiB
Python
431 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.
|
|
Example layouts:
|
|
macOS: /Volumes/Data/ComfyUI/custom_nodes/ComfyUI-Koolook
|
|
Windows: C:/ComfyUI_portable/ComfyUI/custom_nodes/ComfyUI-Koolook
|
|
|
|
Usage:
|
|
python scripts/sync_to_dev.py # copy + auto-restart ComfyUI
|
|
python scripts/sync_to_dev.py --dry-run # show what would copy
|
|
python scripts/sync_to_dev.py --no-restart # copy only; don't restart
|
|
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, the script POSTs to ComfyUI-Manager's reboot endpoint
|
|
(`http://127.0.0.1:8188/manager/reboot` by default) so the live server
|
|
re-execs and picks up the new Python files. This is on by default
|
|
because the only reason to invoke this script is to see new code —
|
|
file-only sync without a restart leaves Python `.py` changes invisible
|
|
(custom-node modules load once at server start). Use `--no-restart` to
|
|
opt out, e.g. when staging files for a session you don't want to disturb.
|
|
Restart failures (Manager not installed, ComfyUI not running, etc.)
|
|
print a diagnostic but do NOT fail the sync.
|
|
|
|
Exit codes:
|
|
0 success (sync completed; restart is best-effort and won't change this)
|
|
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
|
|
import urllib.error
|
|
import urllib.request
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from urllib.parse import urlparse
|
|
|
|
# Default ComfyUI-Manager reboot endpoint. ComfyUI-Manager registers this
|
|
# as a GET route; hitting it makes the server `os.execv` itself, so the
|
|
# same terminal/process picks the new node code up without a manual kill
|
|
# + relaunch cycle. Override via `--restart-url` or skip via
|
|
# `--no-restart` if your install runs on a different port / has no
|
|
# Manager / you want to stage files without disturbing the live session.
|
|
DEFAULT_RESTART_URL = "http://127.0.0.1:8188/manager/reboot"
|
|
|
|
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."""
|
|
sha = _get_short_sha() or "unknown"
|
|
return f"{sha} - {_get_worktree_name()}"
|
|
|
|
|
|
def trigger_restart(url: str, timeout: float = 2.0) -> tuple[bool, str]:
|
|
"""Best-effort POST/GET to ComfyUI-Manager's reboot endpoint.
|
|
|
|
Returns ``(success, human_message)``. Never raises — the sync
|
|
itself succeeded by the time we call this, and a failed restart
|
|
should never mask that with a non-zero exit. The maintainer can
|
|
always restart manually if the auto-restart didn't take.
|
|
|
|
Failure-mode mapping:
|
|
- Connection refused → ComfyUI not running; nothing to do.
|
|
- HTTP 404 → Manager not installed at this URL.
|
|
- HTTP 2xx → restart triggered cleanly.
|
|
- Connection reset / timeout → the server died mid-response (the
|
|
restart's `os.execv` raced our read); the request still landed,
|
|
so we treat this as success.
|
|
"""
|
|
# Restrict to http/https. `urlopen` would otherwise accept `file://`,
|
|
# `ftp://`, etc. — a typo in `--restart-url` could surprise the
|
|
# maintainer by opening a local file via the same code path that
|
|
# normally talks to ComfyUI-Manager. Static analyzers (Bandit B310)
|
|
# also flag the broader urlopen surface; this guard makes the
|
|
# `# nosec` annotation below truthful rather than dismissive.
|
|
scheme = urlparse(url).scheme.lower()
|
|
if scheme not in ("http", "https"):
|
|
return False, (
|
|
f"restart skipped: --restart-url must be http(s); got "
|
|
f"scheme '{scheme}' in {url}"
|
|
)
|
|
|
|
try:
|
|
# ComfyUI-Manager registers /manager/reboot as GET, but POST
|
|
# also lands harmlessly — using GET keeps it simple.
|
|
urllib.request.urlopen(url, timeout=timeout) # nosec B310 - scheme validated above
|
|
return True, "restart triggered"
|
|
except urllib.error.HTTPError as e:
|
|
if e.code == 404:
|
|
return False, (
|
|
f"restart skipped: ComfyUI-Manager not detected at {url} "
|
|
f"(404). Install Manager or pass --no-restart."
|
|
)
|
|
return False, f"restart skipped: endpoint returned HTTP {e.code}"
|
|
except urllib.error.URLError as e:
|
|
reason = e.reason
|
|
if isinstance(reason, ConnectionRefusedError):
|
|
return False, f"restart skipped: ComfyUI not running at {url}"
|
|
# Read timeout / connection reset usually means the server
|
|
# received the request and rebooted before completing the
|
|
# response — desired outcome.
|
|
if isinstance(reason, (TimeoutError, ConnectionResetError)) or "timed out" in str(reason).lower():
|
|
return True, "restart triggered (server stopped responding during reboot — normal)"
|
|
return False, f"restart skipped: unreachable ({reason})"
|
|
except (ConnectionResetError, OSError):
|
|
# Same race as above: server began rebooting before we finished
|
|
# reading. The request landed; that's what matters.
|
|
return True, "restart triggered (connection reset during reboot)"
|
|
|
|
|
|
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 actually loads at runtime. Anything outside this
|
|
# list (CI, docs, .claude/, .github/, .cursor/, CHANGELOG, LICENSE,
|
|
# pyproject.toml, README, fork manifest YAML, etc.) does not affect what
|
|
# ComfyUI executes and is intentionally skipped.
|
|
RUNTIME_PATHS: tuple[str, ...] = (
|
|
"__init__.py",
|
|
"config.json",
|
|
"k_ai_pipeline.py",
|
|
"k_easy_image_batch.py",
|
|
"k_easy_pattern.py",
|
|
"k_easy_resize.py",
|
|
"k_easy_track.py",
|
|
"k_easy_wan22_prompt.py",
|
|
"k_video_combine.py",
|
|
"koolook_routes.py",
|
|
"forks",
|
|
"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",
|
|
)
|
|
|
|
|
|
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 _ignore(_dir: str, names: list[str]) -> list[str]:
|
|
return [n for n in names if n in DIR_EXCLUDES]
|
|
|
|
|
|
def sync(target: Path, dry_run: bool, verbose: bool) -> int:
|
|
copied = 0
|
|
for rel in RUNTIME_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():
|
|
if dst.exists():
|
|
shutil.rmtree(dst)
|
|
shutil.copytree(src, dst, ignore=_ignore)
|
|
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."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--no-restart",
|
|
action="store_true",
|
|
help=(
|
|
"Skip the auto-restart of the live ComfyUI server. By default "
|
|
"the script POSTs to ComfyUI-Manager's reboot endpoint after a "
|
|
"successful sync so the new Python files are actually loaded "
|
|
"(custom-node `.py` files load once at server start; without a "
|
|
"restart the maintainer's changes won't be visible). Use this "
|
|
"flag when you want to stage files without disturbing a live "
|
|
"session (rare — `dev-sync` is normally invoked precisely "
|
|
"because you want to see new code)."
|
|
),
|
|
)
|
|
parser.add_argument(
|
|
"--restart-url",
|
|
type=str,
|
|
default=DEFAULT_RESTART_URL,
|
|
help=(
|
|
f"Override the restart endpoint. Default: {DEFAULT_RESTART_URL}. "
|
|
f"Adjust if your ComfyUI listens on a non-standard host/port. "
|
|
f"Ignored when --no-restart is set or --dry-run is used."
|
|
),
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
load_dotenv(REPO_ROOT / ".env")
|
|
|
|
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)
|
|
if not args.no_restart:
|
|
ok, msg = trigger_restart(args.restart_url)
|
|
print(msg)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|