Files
malkuthroandClaude Opus 4.7 ff79b0f418 feat(video): add Easy_VideoCombine (#154)
* 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 in 943be67 removed (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 since 946e170).
   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>
2026-05-17 14:53:18 +02:00

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())