* fix(web): generation-agnostic Comfy draft-quota guard "Failed to save workflow draft" toasts returned on ComfyUI frontend 1.44+ because draft storage moved to new localStorage key names (per-workspace V1 keys, then a V2 index+payload scheme) that the old exact-name guard embedded in ltx_director.js never matched - and the 1.44 V2 layer latches a session-wide storage-unavailable flag after one failed save+evict cycle, re-toasting on every edit until reload. Move the guard to its own global extension, web/koolook_draft_guard.js: - match draft keys by prefix (Comfy.Workflow.Draft) across all known generations, with unknown future keys as last-resort evictions - rescue quota failures inside localStorage.setItem (evict the oldest draft across generations, retry) before Comfy's own catch sees the error, so the save succeeds and the latch never trips - boot prune: drop the suffixed-V1 dead weight the frontend's V1->V2 migration never deletes, scope corrupt-key cleanup to the offending key, drop oversized entries, enforce a total draft budget - ship the guard with dev-sync-audio so a scoped sync cannot strand a dev install guard-less Behavior is covered by tests/js/test_draft_guard.mjs (12 scenarios, wired into CI next to the existing node smoke test); static integration contracts live in tests/sidebar/test_draft_guard.py. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(web): address deep-review findings on the draft guard - latch the "still full" hint toast once per session so a genuinely full origin cannot re-toast on repeated failing autosave attempts; regression assertion added to the exhaustion scenario - document the bounded-eviction termination contract and the post-rethrow storage-unavailable latch at the wrapper, plus the budget's V1-blob-only (1.5M) -> all-generations (2M) scope widening (also now stated in the CHANGELOG entry) - version-robust wording for the suffixed-V1 cleanup rationale: the shipped 1.44 migration leaves the keys behind (verified against the installed 1.44.19 dist - no removeItem exists there), later frontends clean up on successful migration, and an interrupted migration can leak the keys on any version - comment the intentional best-effort swallow in the toast helper Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
236 lines
8.2 KiB
Python
236 lines
8.2 KiB
Python
#!/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
|
|
- web/koolook_draft_guard.js global Comfy draft-quota guard
|
|
(formerly embedded in the Director
|
|
extension; shipped so a scoped sync
|
|
never strands a guard-less install)
|
|
|
|
It also ships ``koolook_install_guard.py`` + ``koolook_versioning.py`` — the
|
|
loader gates ``__init__.py`` imports at load — so a scoped sync never leaves
|
|
the loader pointing at a module the target lacks (#198 / #183).
|
|
|
|
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"
|
|
|
|
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 Python files, restart ComfyUI manually so custom nodes are
|
|
re-imported. This scoped sync only copies files.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import os
|
|
import shutil
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
# Reuse the dev-sync infrastructure for the .env loader, target validation,
|
|
# and chat-report shape. This scoped wrapper differs only in which paths get
|
|
# copied, and it never tries to manage the running ComfyUI process.
|
|
_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",
|
|
# __init__.py imports these at load, before any node group, and a missing
|
|
# gate is catastrophic (not per-group-guarded): the install guard's
|
|
# absolute-import fallback raises uncaught, and the koolook_versioning
|
|
# context probe mislabels its ImportError as a non-Comfy context and
|
|
# registers nothing. Ship them with __init__.py so a scoped sync never
|
|
# leaves the loader pointing at a module the target lacks (#198 / #183).
|
|
"koolook_install_guard.py",
|
|
"koolook_versioning.py",
|
|
"forks/whatdreamscost_koolook",
|
|
"web/whatdreamscost_koolook",
|
|
# The Comfy draft-quota guard used to be embedded in the Director web
|
|
# extension above; it is global now. Ship it with every scoped audio
|
|
# sync so replacing web/whatdreamscost_koolook/ can never leave a dev
|
|
# install without the guard.
|
|
"web/koolook_draft_guard.js",
|
|
)
|
|
|
|
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 _find_dotenv() -> Path | None:
|
|
direct = _dev.REPO_ROOT / ".env"
|
|
if direct.exists():
|
|
return direct
|
|
git_marker = _dev.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 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."
|
|
),
|
|
)
|
|
args = parser.parse_args()
|
|
|
|
env_path = _find_dotenv()
|
|
if env_path is not None:
|
|
_dev.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()
|
|
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)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|