#!/usr/bin/env python3 """ Scoped variant of ``dev-sync`` for the LTX 2.3 audio-lipsync automation. Copies only what that automation's iteration loop touches: - forks/whatdreamscost_koolook/ the Koolook fork of LTXDirector + its modified prompt_relay + the verbatim-vendored patches.py - __init__.py root loader (registers the fork's node mappings; needs to be re-synced when the fork's NODE_CLASS_MAPPINGS set changes) - web/whatdreamscost_koolook/ Director timeline-editor extension Everything else in the live install — ``forks/radiance_koolook/``, the root ``k_*.py`` nodes, unrelated ``web/`` assets, ``video_formats/`` — is left untouched. USER-INITIATED ONLY. Same rule as plain ``dev-sync`` (see project ``CLAUDE.md`` → ``dev-sync`` section). Never run automatically: * after a commit * after a PR merge or ``/ship-pr`` * at session end / wrap-up * on hook completion or "task complete" cleanup * from any agent skill that doesn't explicitly require it Run only on the explicit user trigger phrase ``dev-sync-audio`` (or ``copy audio fork``, ``sync audio``, etc.). The maintainer typically has multiple parallel sessions across worktrees; an unsolicited sync from one silently destroys what another is reviewing. The target path comes from ``KOLOOK_COMFYUI_DEV_PATH`` in ``.env`` — identical to plain ``dev-sync``. No new env var. Usage: python scripts/sync_to_dev_audio.py python scripts/sync_to_dev_audio.py --dry-run python scripts/sync_to_dev_audio.py --scope "vstr=10 trial" python scripts/sync_to_dev_audio.py --no-restart Exit codes mirror ``sync_to_dev.py`` exactly: 0 success 2 KOLOOK_COMFYUI_DEV_PATH unset or target missing without --init 3 --init refused After copying, the script triggers a ComfyUI-Manager reboot so the new Python files are actually loaded (custom-node modules load once at server start; a file-only sync leaves ``.py`` changes invisible). Use ``--no-restart`` to opt out. """ from __future__ import annotations import argparse import os import shutil import sys from pathlib import Path # Reuse the dev-sync infrastructure verbatim — same ``.env`` loader, # same target validation, same restart, same chat-report shape — so the # only thing that differs between full and scoped syncs is *which paths # get copied*. Per-module wrappers stay small and the safety guarantees # (target validation, parent-directory sanity check, restart fallbacks) # only need to be audited in one place. _SCRIPT_DIR = Path(__file__).resolve().parent if str(_SCRIPT_DIR) not in sys.path: sys.path.insert(0, str(_SCRIPT_DIR)) import sync_to_dev as _dev # noqa: E402 AUDIO_PATHS: tuple[str, ...] = ( "__init__.py", "forks/whatdreamscost_koolook", "web/whatdreamscost_koolook", ) STALE_AUDIO_PATHS: tuple[str, ...] = ( "web/whatdreamscost_koolook_v1_3_2", ) def target_is_repo_root(target: Path) -> bool: """Return True when the configured dev target is this source repo.""" try: return target.resolve() == _dev.REPO_ROOT.resolve() except OSError: return False def remove_stale_paths(target: Path, *, dry_run: bool, verbose: bool) -> int: """Remove old audio-sync paths that were renamed. The v1.3.9 upgrade moved the Director web extension from a versioned folder to a stable one. Existing dev installs can still have the old folder on disk, causing ComfyUI to load two timeline extensions for legacy workflows. Keep this scoped to explicit known paths. """ removed = 0 for rel in STALE_AUDIO_PATHS: stale = target / rel if not stale.exists() and not stale.is_symlink(): continue if dry_run: if verbose: print(f"would remove stale: {rel}") removed += 1 continue if stale.is_symlink() or stale.is_file(): stale.unlink() elif stale.is_dir(): shutil.rmtree(stale) else: stale.unlink() if verbose: print(f"removed stale: {rel}") removed += 1 return removed 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.", ) parser.add_argument( "--scope", type=str, default="audio-lipsync fork edit", help=( "Short (≤10 word) description for the chat report's second line " "and the in-browser footer in `web/_dev_build.json`. Defaults to " "'audio-lipsync fork edit' so the build identifier always names " "this module even when the maintainer doesn't pass a scope." ), ) parser.add_argument( "--no-restart", action="store_true", help=( "Skip the auto-restart of the live ComfyUI server. Without a " "restart, the new fork code stays loaded as the previous version " "(custom-node .py files load once at server start)." ), ) parser.add_argument( "--restart-url", type=str, default=_dev.DEFAULT_RESTART_URL, help=( f"Override the restart endpoint. Default: {_dev.DEFAULT_RESTART_URL}. " f"Ignored when --no-restart is set or --dry-run is used." ), ) args = parser.parse_args() _dev.load_dotenv(_dev.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() if target_is_repo_root(target): print( "KOLOOK_COMFYUI_DEV_PATH points at this source repo; refusing dev-sync.", file=sys.stderr, ) return 2 err = _dev.ensure_target(target, init=args.init) if err is not None: return err n = _dev.sync( target, dry_run=args.dry_run, verbose=args.verbose, paths=AUDIO_PATHS, ) removed = remove_stale_paths(target, dry_run=args.dry_run, verbose=args.verbose) verb = "would sync" if args.dry_run else "synced" print(_dev.build_line()) stale_note = f"; removed {removed} stale" if removed else "" print(f"{verb} {n} entries{stale_note} -> {target} (dev-sync-audio)") if not args.dry_run: _dev.write_build_info(target, args.scope) if not args.no_restart: _ok, msg = _dev.trigger_restart(args.restart_url) print(msg) return 0 if __name__ == "__main__": sys.exit(main())