Files
malkuthro-ComfyUI-Koolook/scripts/loop_audio.py
T
malkuthroandClaude Opus 4.7 9f063345f7 feat(investigations): LTX23 audio-file lip-sync scaffold (#177) (#178)
* feat(investigations): scaffold #177 LTX23 audio-file lip-sync investigation

Reorganize docs/investigations/ into numbered topic folders, each
self-contained with narrative + upstream pin + patches + per-run snapshots.
Add a generic sync helper that mirrors the dev-sync pattern for any
investigation's third-party node modifications.

Structure (new):
  docs/investigations/
    00_LTX23-base-1-step/README.md             (rename of ltx-director-4k-transitions.md)
    01_LTX23-audio-file-lipsync/
      README.md            -- problem, mechanism, patches, reversion, promotion
      upstream.json        -- repo + version + pinned commit + sync target env + files
      patches/             -- source of truth for modified upstream files
        ltx_director.py    -- adds `relay_overrides` multiline JSON input
        prompt_relay.py    -- per-segment sigma per Prompt-Relay paper formula
      runs/
        LOOP.md            -- iteration protocol
        log.md             -- rolling table (empty header for now)

Sync helper:
  scripts/sync_investigation_patches.py <NN>_<topic> [--dry-run]
  - Reads upstream.json for target env var + file list
  - Backs up the target's current state once per day (<file>.bak.<YYYYMMDD>)
  - Worktree-aware: falls back to MAIN's .env via git rev-parse --git-common-dir
  - Prints a two-line report matching scripts/sync_to_dev.py shape
  - Per-investigation trigger phrase via `trigger:` key (sync-audio for #177)

Env:
  .env.example -- documents new KOLOOK_WHATDREAMSCOST_PATH
  .env         -- local path set

Glossary updates (docs/reference/glossary.md):
  + Investigation folder
  + JSON file folder
  + Loop
  + Run
  + Investigation patches
  + sync_investigation_patches.py

Closes the workflow gap from #177: MAIN is now the source of truth for
investigation patches; the live install is a sync destination only. No
manual file copying between iterations — `sync-audio` does it.

* chore(ruff): exclude docs/investigations from lint

The investigation patches under docs/investigations/*/patches/ are
vendored upstream source (WhatDreamsCost-ComfyUI v1.3.2) carried so
sync_investigation_patches.py can mirror them into the live install
and so iterations can be diff'd against the pinned upstream commit.
Linting them would force divergence from upstream and defeat that
purpose. Mirrors the existing forks/ exclude precedent.

Fixes the CI Lint & Smoke failure on PR #178.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* refactor: per-task automation modules + WhatDreamsCost Koolook fork

Pivot docs/automations/ from "one folder per model" to "one folder per
automation module" so the same diffusion model can host several
independent tasks side by side. Replace the parallel docs/investigations/
tree with a fork of WhatDreamsCost-ComfyUI under forks/.

Automation modules (new layout)
- docs/automations/LTX-2.3/base-1step/         the existing single-stage
                                                automation, moved from
                                                docs/automations/LTX-2.3/
                                                with its README, findings,
                                                and handoff-checklist
                                                rescoped to the task. The
                                                4k-transitions backstory
                                                that produced the module
                                                moves here as
                                                backstory/4k-transitions.md.
                                                The earlier
                                                docs/investigations/experiments/
                                                card PNGs/HTML land in
                                                example-cards/.
- docs/automations/LTX-2.3/audio-lipsync/      the audio-file lip-sync
                                                module previously living
                                                under docs/investigations/01_…/.
                                                README, handoff, findings
                                                stub, backstory/, and
                                                runs/{LOOP,log}.md, all
                                                referencing the new fork.
- docs/automations/README.md + CHEATSHEET.md   updated to describe the
                                                module-per-task pivot and
                                                the new tree shape.
- docs/automations/CONVENTIONS.md               unchanged (cross-cutting
                                                loop contract still holds).

WhatDreamsCost Koolook fork (new)
- forks/whatdreamscost_koolook/versions/v1_3_2/
    UPSTREAM_PIN.yaml                          pinned to upstream e81223a
                                                (v1.3.2), GPL-3.0
                                                verified 2026-05-28.
    ltx_director.py                            MODIFIED — adds the
                                                relay_overrides multiline
                                                JSON widget on LTXDirector.
                                                Moved from
                                                docs/investigations/01_…/patches/.
    prompt_relay.py                            MODIFIED — per-segment σ per
                                                the Prompt-Relay paper
                                                formula σ = (L − w_eff) /
                                                (2 · √ln(1/ε)). Moved from
                                                docs/investigations/01_…/patches/.
    patches.py                                 UNMODIFIED — vendored
                                                verbatim from upstream
                                                because ltx_director.py
                                                imports detect_model_type
                                                and apply_patches from it
                                                (same precedent as
                                                radiance_koolook's
                                                color_helpers.py).
- Registered as LTXDirector__koolook_v1_3_2 ("LTX Director (Koolook v1.3.2)")
  so it coexists with an installed copy of upstream WhatDreamsCost-ComfyUI.
  Wired into the root __init__.py NODE_CLASS_MAPPINGS merge.
- forks/forks_manifest.yaml + forks/THIRD_PARTY.md updated with the new
  entry (attribution, change log, license verified date, exposed node IDs).

Removed
- docs/investigations/                          entire tree retired; its
                                                contents either moved to
                                                automation modules or to
                                                forks/.
- scripts/sync_investigation_patches.py         no longer needed — fork
                                                code lives in the Koolook
                                                tree and is synced via the
                                                standard dev-sync flow.
- KOLOOK_WHATDREAMSCOST_PATH in .env.example    same reason.
- docs/investigations in pyproject.toml's ruff
  extend-exclude                                added in 96bf8ad to silence
                                                lint on vendored patches;
                                                no longer needed now that
                                                vendored code is under
                                                forks/ (already excluded).

Docs
- docs/reference/glossary.md                    Investigation folder,
                                                Investigation patches,
                                                JSON file folder, and the
                                                sync_investigation_patches.py
                                                entries replaced by
                                                Automation module, Backstory,
                                                Working folder, and Card.
                                                Loop and Run rewritten to
                                                point at the new structure.
- CHANGELOG.md [Unreleased]                     describes the restructure +
                                                the new fork.

Verification
- ruff check . → all checks passed
- tools/preflight_release.py --skip manager-meta → 42 files parsed; 11
  unique node IDs collected including LTXDirector; VAE dispatch branches
  all OK; fixture workflows still reference all registered Koolook IDs.
- pytest → 173 passed, 0 failed.

Workflow JSON heads-up
The active audio-lipsync workflow LTX-23-audio_tests_v01.json continues to
reference the upstream LTXDirector ID. To pick up the new fork's behaviour,
swap that node to "LTX Director (Koolook v1.3.2)" (registered ID
LTXDirector__koolook_v1_3_2) — sockets and wiring are preserved. The
relay_overrides widget appears at the bottom of the new node. Documented
in the audio-lipsync README's "Workflow JSON — Koolook node ID" section.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(scripts): add dev-sync-audio — scoped sync for audio-lipsync iteration

`dev-sync-audio` is a scoped wrapper around `dev-sync` for the
[`docs/automations/LTX-2.3/audio-lipsync/`](docs/automations/LTX-2.3/audio-lipsync/)
iteration loop. It copies *only* the paths that module touches — the
`forks/whatdreamscost_koolook/` subtree and the root `__init__.py` —
instead of `rmtree`-ing the whole dest `forks/` and re-copying every
Koolook fork on every render. Same `KOLOOK_COMFYUI_DEV_PATH` target,
same ComfyUI-Manager restart, same two-line chat-report shape.

Changes
- `scripts/sync_to_dev_audio.py` (new). Thin wrapper that reuses
  `sync_to_dev`'s env loader, target validator, restart helper, and
  chat-report builder, supplying its own `AUDIO_PATHS` tuple. Defaults
  the `--scope` tag to `"audio-lipsync fork edit"` so the build
  identifier names this module even when invoked without an explicit
  scope.
- `scripts/sync_to_dev.py` — `sync()` gains an optional `paths` kwarg
  (defaults to the existing `RUNTIME_PATHS`). Backwards-compatible with
  every existing call site.
- `docs/automations/LTX-2.3/audio-lipsync/README.md` and
  `runs/LOOP.md` updated to point at `dev-sync-audio` instead of plain
  `dev-sync` for the iteration step.
- Project `CLAUDE.md` — new "Per-module scoped variants" subsection
  under the existing `dev-sync` section documents the
  `dev-sync-<module>` pattern, the trigger-name → script-name mapping,
  and reiterates that the user-initiated-only rule applies verbatim to
  every `dev-sync-*` variant.

Verification
- ruff check . → all passed
- python scripts/sync_to_dev_audio.py --dry-run -v →
    would copy: __init__.py
    would copy: forks/whatdreamscost_koolook
    e7f3ab4 - beautiful-mahavira-ae6da6
    would sync 2 entries -> <KOLOOK_COMFYUI_DEV_PATH>  (dev-sync-audio)
  (Confirms scope is correct and the existing dev-sync chat-report
  shape is preserved.)

User-initiated only — never automatic. Trigger phrase: `dev-sync-audio`
in chat (also: "copy audio fork", "sync audio fork"). Same rule as
`dev-sync` — see project CLAUDE.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* docs(audio-lipsync): only snapshot runs when explicitly kept

Every render adds a row to runs/log.md (always). The heavier
run-NNN_<label>/ snapshot folders (workflow JSON copy + relay_overrides
+ patch state + notes) are now created only when the maintainer says
"keep", "save run", "save this", or "snapshot" in chat. Without that
trigger, the render is just a log row.

Why: an active knob sweep can do 10+ renders/hour. Saving a folder
per render bloats runs/ fast and most of those folders are noise the
moment the next render lands. Cheap log row everywhere, expensive
snapshot only for runs worth pinning (knob landed, surprising failure,
something heading for findings.md).

Changes
- docs/automations/LTX-2.3/audio-lipsync/runs/LOOP.md
    Step 5 split into "always log" + "snapshot only on request".
    Trigger-detection table updated. Run-labels section reframed
    (NNN matches log.md row number). Added a one-paragraph "why this
    retention policy" rationale.
- docs/automations/LTX-2.3/audio-lipsync/README.md
    Folder-map flags run-NNN_<label>/ as conditional. Iteration loop
    step 5 names the keep / save-run / save-this triggers explicitly.

Not a code change. No script edits — the retention rule lives in the
agent protocol, not in dev-sync-audio (which stays scoped to code
sync, not run management).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(review): harden relay_overrides parser + polish (PR #178 review)

Addresses verified findings from the /review-pr pass on PR #178.

H1 — relay_overrides values are now type-checked
- Allowlist of recognised knobs (`video_strength`, `video_window_scale`,
  `audio_strength`, `audio_window_scale`, `audio_epsilon`) with explicit
  float-coercion. Numeric strings ("10") coerce silently; non-numeric
  values (e.g. "ten") now raise a ValueError naming the offending knob
  and showing a minimal correct example — instead of a TypeError deep
  inside `build_segments` arithmetic, or a SUCCESS render at upstream
  defaults that polluted the A/B iteration log.

H2 — malformed JSON no longer silently falls back to defaults
- `json.JSONDecodeError`, non-dict JSON (arrays, strings, bare numbers,
  null), and non-numeric values for recognised knobs all now raise
  `ValueError` with operator-actionable messages. The only silent
  fallback is the explicit empty/whitespace case — "use upstream
  defaults" is the operator's intent there.
- Typo'd keys (e.g. `viedo_strength`) log a warning naming the unknown
  keys + the recognised set, then drop the typo. Previously they were
  silently retained in the cleaned dict but never queried by
  `build_segments`, indistinguishable from a default render.

Architectural side-effect — shrinks the diff vs vendored upstream
- `_parse_relay_overrides` extracted into a stdlib-only sibling module
  `_relay_overrides.py`. `ltx_director.py` shrinks closer to upstream
  baseline (one import + one alias instead of a 30-line function body)
  and is easier to diff against `e81223a`. The parser is also now
  unit-testable without ComfyUI's `comfy_api` runtime.

L5 — tests added (31 cases, all passing)
- New `tests/forks/__init__.py` + `tests/forks/test_whatdreamscost_v1_3_2_relay_overrides.py`.
- Covers every confirmed failure mode (H1 + H2), the happy path, the
  comment-key convention, the unknown-key warning, and an allowlist-
  consistency assertion that fails loudly if `build_segments` ever
  grows a knob without the parser being updated.
- Test module uses `importlib.util.spec_from_file_location` so it can
  load the parser without walking through the package hierarchy (which
  would trigger the `comfy_api` import inside `ltx_director.py`).

L3 — `_build_line` promoted to public `build_line`
- `scripts/sync_to_dev.py` renames the private helper. The underscore
  alias is kept for one release cycle as a deprecation slot.
- `scripts/sync_to_dev_audio.py` switches to `_dev.build_line()`. No
  behaviour change; chat-report shape is bit-identical to before.

L2 — `source_ref` clarified in pin metadata
- `forks/whatdreamscost_koolook/versions/v1_3_2/UPSTREAM_PIN.yaml` and
  `forks/forks_manifest.yaml` both now spell out that `e81223a` is the
  post-v1.3.2 README-only commit on upstream's `main`, not the v1.3.2
  release tag itself. Code is byte-identical to the tagged release; the
  pin differs only in trailing docs.

L1, L4 are explicitly left alone (L1 is dead upstream cruft, kept for
minimum-diff; L4 is helper duplication tracked for a separate follow-up
issue when a third fork lands).

Verification
- ruff check . → clean
- pytest → 204 passed (173 prior + 31 new parser tests)
- tools/preflight_release.py --skip manager-meta → all PASS (static AST
  extraction still picks up LTXDirector from the fork)
- scripts/sync_to_dev_audio.py --dry-run → two-line chat report still
  emits via the renamed `build_line` helper

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(audio-lipsync): config-driven loop-audio script + module cheat sheet

End-to-end automation for one render cycle of the audio-lipsync
iteration loop. The chat phrase `loop-audio` invokes a single script
that does the whole chain — no manual artifact-shuffling.

Scripts (cross-cutting infrastructure at the repo root, like make_card
and sync_to_dev; they get lifted into the module folder in the
`docs/automations/` → `automations/` move queued for the follow-up PR)

- scripts/loop_audio.py
    Reads its settings from loop_audio.config.json (alongside),
    auto-discovers the most recent workflow JSON in the ComfyUI user
    workflows dir (derived from KOLOOK_COMFYUI_DEV_PATH or overridden
    via KOLOOK_COMFYUI_WORKFLOWS_DIR), extracts the five tracked Text
    Multilines + LTX Director state + sampler chain, captures
    fork-state (HEAD SHA + last dev-sync-audio sync info + fork-dir
    git status), writes the run-NNN_<auto-label>/ snapshot, appends a
    row to runs/log.md, and chains the card renderer in. Flags
    mirror sync_to_dev: --dry-run, --label, --no-log, --no-card,
    --workflow, --config.

- scripts/loop_audio.config.json
    Single source of truth for the customisable settings (job_name,
    module_path, workflow_pattern, comfyui_workflows_subpath,
    skip_filename_substring, tracked_multilines, fork_to_track,
    render_card). Underscore-prefixed keys are inline documentation
    and ignored by the loader. When the convention shifts — new
    multiline title, new fork path, different filename pattern —
    edit the JSON, not the Python.

- scripts/make_card_audio.py
    Audio-lipsync-specific card renderer. Five sections (KNOB STATE,
    FORK STATE, SAMPLER, BASE notes, OUTCOME) plus an INERT warning
    band when the workflow's Director is upstream `LTXDirector` with
    a non-empty relay_overrides. Same palette + font fallback chain
    as scripts/make_card.py so the base-1step and audio-lipsync card
    families read as a set. PIL/Pillow added to the `test` optional
    deps so CI doesn't hit ImportError if any future test touches
    this script.

Docs

- docs/automations/LTX-2.3/audio-lipsync/CHEATSHEET.md
    Module-scoped operating instructions per the cheat-sheet pattern:
    what the module is for, the chat triggers, the required workflow
    shape (5 multilines + the Koolook Director variant), the render
    cycle steps, what lands in each run-NNN_ folder, findings-
    promotion rule, where every customisable setting lives, and a
    card-section legend.

- docs/reference/glossary.md
    `Card` rewritten to describe the per-module renderer fan-out
    (`make_card.py` for base-1step, `make_card_audio.py` for
    audio-lipsync) — cards no longer live at a single stable
    `_AI/card.png` path. New `Loop config (per-module)` entry
    documenting the `<script>.config.json` convention + the
    required-keys list.

- docs/automations/LTX-2.3/audio-lipsync/runs/log.md
    Header rewritten with column set matching the actual log-row
    output (Director / relay_overrides / Custom audio / Phase 1 /
    Phase 2 / Scores / Notes).

First real run captured

- docs/automations/LTX-2.3/audio-lipsync/runs/run-001_bear_3x-fr_w.audio_upstream_audio-off_vstr10.0/
    Baseline reference render — `Bear_3x-FR_w.Audio`, upstream
    `LTXDirector` still wired into the workflow, use_custom_audio
    off. Motion 4/5 · Sync 5/5 · Sharp 4/5 per maintainer feedback.
    The relay_overrides `{"video_strength": 10.0}` is set but inert
    (Director not yet swapped to the Koolook variant); the card
    surfaces this with the amber INERT band at the bottom. Captures:
    workflow.json (185KB), relay_overrides.txt (with INERT note),
    patch_state.txt (3ffb7b7 / 15d5688 / clean), notes.md (feedback
    + scores + mechanical interp), card.png (61KB).

Verification

- ruff check . → clean
- pytest -q → 204 passed, 0 failed
- tools/preflight_release.py --skip manager-meta → all PASS
- scripts/loop_audio.py --dry-run → identifies the latest workflow,
  emits the two-line chat-report header, doesn't touch disk
- scripts/loop_audio.py (real) → wrote the run-001 folder + card +
  log row in a single invocation

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(audio-lipsync card): match base-1step visual style 1:1

The first cut of the audio-lipsync card used solid colored header bands
and block section panels — visually heavier than base-1step's renderer.
Refactored to copy make_card.py's primitives directly:

  - small uppercase accent-colored section labels (no header bars)
  - subtle BG_SECTION panel outlines instead of full-width fills
  - left-accented note boxes (4px coloured bar + dark BG + label inside)
    for Base · notes, Feedback (video), and the conditional Director
    note warning
  - same font fallback chain + sizes as make_card.py (F_SECTION 17pt,
    F_MONO 19pt, F_NOTE italic 18pt)
  - oversized canvas + post-render crop matches make_card.py's pattern
    so cards size to actual content
  - palette imported in lockstep (BG_OUTER/CARD/SECTION, BORDER, the
    three accent triples plus the new ACCENT_FORK violet)

Two tuning fixes spotted during re-render:

  - _wrap_text gained a keep_blank_lines flag so prose blocks can
    collapse the empty separator lines maintainer canvases often have
  - max_lines bumped on the prose blocks: Base · notes 18 (was 6,
    truncated the OVERLAY - INFO body), Feedback 6 (was 5), Director
    note 8 (was 4, truncated mid-sentence)
  - make_card_audio.py's standalone CLI now loads .env via
    loop_audio.load_dotenv before rebuilding state, so re-renders show
    the same FORK STATE lines (MAIN sha + sync sha + scope) that the
    live loop_audio run captures

Visual reference: docs/automations/LTX-2.3/base-1step/example-cards/
run-001-card.png is the layout target. The audio-lipsync card now
reads as a sibling of that family — KNOB STATE in amber, FORK STATE in
violet, SAMPLER + Base · notes in sky, FEEDBACK + OUTCOME in green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(fork): ship LTX Director timeline-editor JS to the Koolook variant

The Koolook variant of LTXDirector was loading as a bare schema — no
image strip, no audio strip, no canvas timeline editor — because the
upstream timeline-editor JS bundle never came across with the fork.
The widget upstream paints onto the node lives entirely in
`js/ltx_director.js`; the Python class only declares the inputs.
Compounding it, the JS's binding check is hard-coded
``if (nodeData.name === "LTXDirector")``, so even if the bundle had
been loaded, it would have skipped our suffixed registry ID
(`LTXDirector__koolook_v1_3_2`).

Fix: vendor the JS into the fork and rebind it.

- web/whatdreamscost_koolook_v1_3_2/ltx_director.js
    Verbatim copy of upstream's
    custom_nodes/WhatDreamsCost-ComfyUI/js/ltx_director.js at the
    pinned commit (e81223a). Two string changes vs upstream:
      * `name: "LTXDirector"` -> `"LTXDirector_Koolook_v1_3_2"`
        so the registered ComfyUI extension doesn't collide with the
        upstream extension (both load in parallel).
      * `if (nodeData.name === "LTXDirector")` ->
        `if (nodeData.name === "LTXDirector__koolook_v1_3_2")`
        so the canvas widget mounts on the Koolook variant only;
        the upstream extension keeps owning the upstream node.
    A short comment block at the top documents both deltas.

- scripts/sync_to_dev_audio.py
    AUDIO_PATHS gains `web/whatdreamscost_koolook_v1_3_2` so
    dev-sync-audio carries the JS into the live install alongside the
    fork's Python files + root __init__.py.

After dev-sync-audio + ComfyUI restart, the Koolook variant mounts the
same timeline editor as upstream: image/audio strips, segment dragging,
the visual preview row, the [Audio] tag handling, the
img_compression/divisible_by widgets. The `relay_overrides` field
declared in the Python schema renders at the bottom of the node
alongside everything else.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 01:56:12 +02:00

638 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
``loop-audio`` — record one iteration of the LTX 2.3 audio-lipsync loop.
End-to-end automation for one render cycle: locate the workflow the
maintainer just saved inside ComfyUI, snapshot the workflow JSON +
relay-overrides + fork state + notes into a numbered ``runs/run-NNN_``
folder under this module, render the audio-lipsync card alongside, and
append a row to ``runs/log.md``. No manual steps.
Settings — *which* module, *which* workflow filename to look for,
*which* multiline titles to extract, *which* fork dir to pin — all live
in :file:`loop_audio.config.json` next to this script. Edit the config,
not the Python, when the convention shifts.
USER-INITIATED ONLY. Same rule as ``dev-sync`` and ``dev-sync-audio``
(see project ``CLAUDE.md``). Trigger phrase: ``loop-audio``.
Discovery chain:
KOLOOK_COMFYUI_DEV_PATH ── walk up two levels ──► <ComfyUI>/
└── <comfyui_workflows_subpath from config> ── glob workflow_pattern ──►
newest mtime (excluding skip_filename_substring) ──► workflow.json
Snapshot folder layout (always created):
<module_path>/runs/run-NNN_<auto-label>/
├── workflow.json copy of the workflow at submission
├── relay_overrides.txt RELAY_OVERRIDES multiline body
├── patch_state.txt MAIN sha + last dev-sync-audio + fork-dir status
├── notes.md feedback + scores + mechanical interp
└── card.png when render_card=true in config
Plus one row appended to <module_path>/runs/log.md.
Usage:
python scripts/loop_audio.py
python scripts/loop_audio.py --dry-run
python scripts/loop_audio.py --label <override>
python scripts/loop_audio.py --no-log
python scripts/loop_audio.py --no-card
python scripts/loop_audio.py --workflow <explicit-path>
python scripts/loop_audio.py --config <explicit-config-path>
Exit codes:
0 snapshot landed (or would have, in --dry-run)
2 KOLOOK_COMFYUI_DEV_PATH unset / ComfyUI workflows dir missing / config missing
3 no workflow matching the configured pattern
4 config JSON malformed
"""
from __future__ import annotations
import argparse
import json
import os
import re
import shutil
import subprocess
import sys
from datetime import date
from pathlib import Path
from typing import Any, Optional
REPO_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_CONFIG_PATH = Path(__file__).resolve().with_suffix(".config.json")
# --------------------------------------------------------------------------
# Config — read once into a small dataclass-ish dict. Underscore-prefixed
# keys are ignored (same convention as the LTXDirector relay_overrides
# widget — keeps inline documentation co-located with the values).
# --------------------------------------------------------------------------
REQUIRED_CONFIG_KEYS = (
"job_name",
"module_path",
"comfyui_workflows_subpath",
"workflow_pattern",
"skip_filename_substring",
"tracked_multilines",
"fork_to_track",
"render_card",
)
def load_config(path: Path) -> dict[str, Any]:
if not path.is_file():
print(
f"Config not found: {path}\n"
f"Each loop script defaults to <script>.config.json beside it; "
f"pass --config to point elsewhere.",
file=sys.stderr,
)
sys.exit(2)
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
print(f"Malformed config {path}: {exc}", file=sys.stderr)
sys.exit(4)
if not isinstance(raw, dict):
print(f"Config root must be an object: {path}", file=sys.stderr)
sys.exit(4)
cfg = {k: v for k, v in raw.items() if not k.startswith("_")}
missing = [k for k in REQUIRED_CONFIG_KEYS if k not in cfg]
if missing:
print(
f"Config {path} is missing required keys: {missing}",
file=sys.stderr,
)
sys.exit(4)
return cfg
# --------------------------------------------------------------------------
# .env loader (minimal — same shape as scripts/sync_to_dev.py's helper).
# --------------------------------------------------------------------------
def load_dotenv(env_path: Path) -> None:
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("=")
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
# --------------------------------------------------------------------------
# Workflow discovery — turn config + env into a concrete JSON path.
# --------------------------------------------------------------------------
def resolve_workflows_dir(cfg: dict[str, Any]) -> Path:
"""``KOLOOK_COMFYUI_WORKFLOWS_DIR`` wins when set (lets the maintainer
point the loop at any workflow library). Otherwise derive
``<ComfyUI>/<comfyui_workflows_subpath from config>`` from
``KOLOOK_COMFYUI_DEV_PATH`` by walking up two levels from the
``custom_nodes/<koolook>`` leaf."""
override = os.environ.get("KOLOOK_COMFYUI_WORKFLOWS_DIR")
if override:
p = Path(override).expanduser()
if not p.is_dir():
print(
f"KOLOOK_COMFYUI_WORKFLOWS_DIR points at a non-dir: {p}",
file=sys.stderr,
)
sys.exit(2)
return p
dev = os.environ.get("KOLOOK_COMFYUI_DEV_PATH")
if not dev:
print(
"Neither KOLOOK_COMFYUI_WORKFLOWS_DIR nor "
"KOLOOK_COMFYUI_DEV_PATH is set in .env. Set one (see .env.example).",
file=sys.stderr,
)
sys.exit(2)
leaf = Path(dev).expanduser()
workflows = leaf.parent.parent / cfg["comfyui_workflows_subpath"]
if not workflows.is_dir():
print(
f"Workflows dir not found: {workflows}\n"
f"(derived from KOLOOK_COMFYUI_DEV_PATH={dev} + "
f"config.comfyui_workflows_subpath={cfg['comfyui_workflows_subpath']})",
file=sys.stderr,
)
sys.exit(2)
return workflows
def find_workflow(workflows_dir: Path, cfg: dict[str, Any]) -> Path:
skip = cfg["skip_filename_substring"].lower()
candidates = [
p for p in workflows_dir.glob(cfg["workflow_pattern"])
if skip not in p.stem.lower()
]
if not candidates:
print(
f"No workflow JSON matching {cfg['workflow_pattern']!r} in "
f"{workflows_dir}",
file=sys.stderr,
)
sys.exit(3)
return max(candidates, key=lambda p: p.stat().st_mtime)
# --------------------------------------------------------------------------
# Workflow content extraction.
# --------------------------------------------------------------------------
def extract_multilines(
nodes: list[dict], tracked_titles: list[str]
) -> dict[str, str]:
out: dict[str, str] = {}
needles = [t.lower() for t in tracked_titles]
for n in nodes:
if n.get("type") != "Text Multiline":
continue
title = (n.get("title") or "").strip().lower()
body = (n.get("widgets_values") or [""])[0] or ""
for needle in needles:
if needle in title and needle not in out:
out[needle] = body
break
return out
def extract_director_state(nodes: list[dict]) -> dict[str, Any]:
for n in nodes:
t = n.get("type", "")
if "LTXDirector" not in t or "Guide" in t:
continue
wv = n.get("widgets_values") or []
return {
"variant": t,
"is_koolook": "koolook" in t.lower(),
"use_custom_audio": wv[8] if len(wv) > 8 else None,
"frame_rate": wv[9] if len(wv) > 9 else None,
"duration_seconds": wv[5] if len(wv) > 5 else None,
"duration_frames": wv[1] if len(wv) > 1 else None,
"custom_width": wv[11] if len(wv) > 11 else None,
"custom_height": wv[12] if len(wv) > 12 else None,
"epsilon": wv[6] if len(wv) > 6 else None,
}
return {"variant": "(no LTXDirector node found)"}
def extract_scheduler_chain(nodes: list[dict]) -> dict[str, Any]:
schedulers, samplers, noises, cfgs = [], [], [], []
for n in nodes:
t = n.get("type", "")
wv = n.get("widgets_values") or []
if t == "BasicScheduler":
schedulers.append(wv)
elif t == "KSamplerSelect":
samplers.append(wv[0] if wv else None)
elif t == "RandomNoise":
noises.append(wv)
elif t == "CFGGuider":
cfgs.append(wv[0] if wv else None)
return {
"schedulers": schedulers,
"samplers": samplers,
"noises": noises,
"cfgs": cfgs,
}
SCORE_PAT = re.compile(
r"^\s*(?P<axis>motion|sync|sharp(?:ness)?)\s*[:=]?\s*"
r"(?P<n>\d+)(?:\s*/\s*5)?\s*$",
re.IGNORECASE,
)
def parse_feedback(body: str) -> tuple[dict[str, Optional[int]], list[str]]:
scores: dict[str, Optional[int]] = {
"motion": None, "sync": None, "sharp": None,
}
text_lines: list[str] = []
for line in body.splitlines():
m = SCORE_PAT.match(line)
if m:
axis = m.group("axis").lower()
axis = "sharp" if axis.startswith("sharp") else axis
scores[axis] = int(m.group("n"))
elif line.strip():
text_lines.append(line.strip())
return scores, text_lines
# --------------------------------------------------------------------------
# Repo / fork state.
# --------------------------------------------------------------------------
def short_sha() -> str:
try:
r = subprocess.run(
["git", "rev-parse", "--short", "HEAD"],
cwd=str(REPO_ROOT),
capture_output=True, text=True, timeout=2,
)
return r.stdout.strip() if r.returncode == 0 else "unknown"
except (OSError, subprocess.TimeoutExpired):
return "unknown"
def fork_dir_status(fork_rel: str) -> str:
try:
r = subprocess.run(
["git", "status", "--short", "--", fork_rel],
cwd=str(REPO_ROOT),
capture_output=True, text=True, timeout=3,
)
if r.returncode != 0:
return f"(git status failed: {r.stderr.strip()[:80]})"
return r.stdout.strip() or "clean (matches HEAD)"
except (OSError, subprocess.TimeoutExpired):
return "(git unreachable)"
def read_dev_build_json() -> dict[str, str]:
dev = os.environ.get("KOLOOK_COMFYUI_DEV_PATH", "")
if not dev:
return {}
p = Path(dev) / "web" / "_dev_build.json"
if not p.is_file():
return {}
try:
return json.loads(p.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return {}
# --------------------------------------------------------------------------
# Run folder naming.
# --------------------------------------------------------------------------
def next_run_number(runs_dir: Path) -> int:
if not runs_dir.is_dir():
return 1
nums: list[int] = []
for child in runs_dir.iterdir():
if not child.is_dir() or not child.name.startswith("run-"):
continue
m = re.match(r"run-(\d+)", child.name)
if m:
nums.append(int(m.group(1)))
return max(nums) + 1 if nums else 1
_LABEL_BAD = re.compile(r"[^a-z0-9._-]+")
def autogen_label(
multilines: dict[str, str],
director: dict[str, Any],
relay_overrides_raw: str,
) -> str:
name = multilines.get("name", "").strip()
slug = _LABEL_BAD.sub("-", name.lower()).strip("-") or "unnamed"
director_tag = "koolook" if director.get("is_koolook") else "upstream"
audio_tag = (
"audio-on" if director.get("use_custom_audio") is True
else "audio-off"
)
knob_tag = ""
if relay_overrides_raw.strip():
try:
opts = json.loads(relay_overrides_raw)
if isinstance(opts, dict) and "video_strength" in opts:
knob_tag = f"_vstr{opts['video_strength']}"
except (json.JSONDecodeError, TypeError):
pass
label = f"{slug}_{director_tag}_{audio_tag}{knob_tag}"
if len(label) > 60:
label = label[:60].rstrip("-_")
return label
# --------------------------------------------------------------------------
# Snapshot artifact writers.
# --------------------------------------------------------------------------
def render_relay_overrides_txt(raw: str, director: dict[str, Any]) -> str:
body = raw.strip() if raw and raw.strip() else "(empty — upstream defaults)"
inert_note = ""
if not director.get("is_koolook") and raw.strip():
inert_note = (
"\n\n# NOTE: this value is INERT for this render — the Director "
"node in the workflow is upstream `LTXDirector`, which has no "
"`relay_overrides` input. Swap to "
"`LTXDirector__koolook_v1_3_2` to make this active.\n"
)
return f"{body}\n{inert_note}"
def render_patch_state(build: dict[str, str], fork_status: str) -> str:
return (
f"# Fork state at submission\n\n"
f"MAIN SHA : {short_sha()}\n"
f"Last dev-sync-audio : {build.get('commit', '(no _dev_build.json)')}"
f" ({build.get('synced_at', '?')})\n"
f"Sync scope tag : {build.get('scope', '(none)')}\n"
f"Sync worktree : {build.get('worktree', '?')}\n"
f"Fork dir status : {fork_status}\n"
)
def render_notes_md(
director: dict[str, Any],
multilines: dict[str, str],
scheduler: dict[str, Any],
scores: dict[str, Optional[int]],
feedback_lines: list[str],
) -> str:
info_body = multilines.get("overlay - info", "(none)")
info_indented = "\n".join(" " + line for line in info_body.splitlines())
feedback_body = "\n".join(feedback_lines) if feedback_lines else "(none)"
scores_line = " · ".join(
f"{k}: {v if v is not None else '?'}/5"
for k, v in scores.items()
)
director_kind = (
"Koolook variant (`LTXDirector__koolook_v1_3_2`)"
if director.get("is_koolook")
else "**upstream** `LTXDirector` (Koolook variant NOT wired — "
"relay_overrides + per-segment σ are INERT this render)"
)
audio_state = (
"**ON** — external audio file driving lip-sync"
if director.get("use_custom_audio") is True
else "off — model-generated audio from `[Audio]:` prompt tag"
if director.get("use_custom_audio") is False
else "(could not read)"
)
return (
f"# Run notes\n\n"
f"## Maintainer feedback (OVERLAY - FEEDBACK, verbatim)\n\n"
f"{feedback_body}\n\n"
f"**Scores:** {scores_line}\n\n"
f"## OVERLAY - INFO (verbatim)\n\n"
f"{info_indented}\n\n"
f"## Mechanical interp\n\n"
f"- Director node: {director_kind}\n"
f"- `use_custom_audio`: {audio_state}\n"
f"- ε (epsilon): {director.get('epsilon')!r}\n"
f"- Duration: {director.get('duration_frames')} frames "
f"@ {director.get('frame_rate')} fps "
f"({director.get('duration_seconds')} sec)\n"
f"- Director custom WxH: "
f"{director.get('custom_width')}x{director.get('custom_height')} "
f"(may be overridden by wired inputs upstream)\n"
f"- Scheduler chain (BasicScheduler widgets): "
f"{scheduler['schedulers']}\n"
f"- Sampler picks: {scheduler['samplers']}\n"
f"- RandomNoise widgets: {scheduler['noises']}\n"
f"- CFGGuider widgets: {scheduler['cfgs']}\n"
)
def render_log_row(
nnn: int,
director: dict[str, Any],
relay_overrides_raw: str,
scheduler: dict[str, Any],
scores: dict[str, Optional[int]],
feedback_lines: list[str],
) -> str:
director_cell = director.get("variant", "?")
relay_cell = (
f"`{relay_overrides_raw.strip()}`"
if relay_overrides_raw.strip()
else "(empty → upstream defaults)"
)
audio_cell = (
"ON" if director.get("use_custom_audio") is True
else "off" if director.get("use_custom_audio") is False
else "?"
)
phase1 = phase2 = "?"
schedulers = scheduler["schedulers"]
if len(schedulers) >= 2:
s1, s2 = schedulers[0], schedulers[1]
if len(s2) > 2 and s2[2] == 1.0 and len(s1) > 2 and s1[2] < 1.0:
s1, s2 = s2, s1
phase1 = f"{s1[0]} {s1[1]}stp d={s1[2]}" if len(s1) >= 3 else str(s1)
phase2 = f"{s2[0]} {s2[1]}stp d={s2[2]}" if len(s2) >= 3 else str(s2)
elif schedulers:
s1 = schedulers[0]
phase1 = f"{s1[0]} {s1[1]}stp d={s1[2]}" if len(s1) >= 3 else str(s1)
score_cell = (
f"M{scores['motion'] or '?'}·S{scores['sync'] or '?'}·"
f"Sh{scores['sharp'] or '?'}"
)
notes_cell = (
" ".join(feedback_lines)[:120] if feedback_lines else "(none)"
).replace("|", "\\|")
return (
f"| {nnn:03d} | {date.today().isoformat()} | `{director_cell}` "
f"| {relay_cell} | {audio_cell} | {phase1} | {phase2} "
f"| {score_cell} | {notes_cell} |\n"
)
# --------------------------------------------------------------------------
# Driver.
# --------------------------------------------------------------------------
def _build_state_for_card(
nnn: int, label: str, wf_path: Path,
multilines: dict[str, str],
director: dict[str, Any], scheduler: dict[str, Any],
scores: dict[str, Optional[int]], feedback_lines: list[str],
fork_rel: str,
) -> dict[str, Any]:
return {
"run_number": nnn,
"run_label": label,
"date": date.today().isoformat(),
"name": multilines.get("name", "(unnamed)"),
"workflow_name": wf_path.name,
"relay_overrides_raw": multilines.get("relay_overrides", ""),
"director": director,
"scheduler": scheduler,
"scores": scores,
"feedback_lines": feedback_lines,
"info_body": multilines.get("overlay - info", ""),
"build": read_dev_build_json(),
"main_sha": short_sha(),
"fork_dir_status": fork_dir_status(fork_rel),
}
def main() -> int:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--config", type=Path, default=DEFAULT_CONFIG_PATH,
help="Loop config JSON (default: alongside this script).")
p.add_argument("--dry-run", action="store_true",
help="Print what would be captured. No files written.")
p.add_argument("--label", default=None,
help="Override the auto-generated run label suffix.")
p.add_argument("--no-log", action="store_true",
help="Skip the log.md row append.")
p.add_argument("--no-card", action="store_true",
help="Skip the card render even if config.render_card is true.")
p.add_argument("--workflow", type=Path, default=None,
help="Explicit workflow JSON path (skip auto-discover).")
args = p.parse_args()
cfg = load_config(args.config)
load_dotenv(REPO_ROOT / ".env")
module_dir = REPO_ROOT / cfg["module_path"]
runs_dir = module_dir / "runs"
log_path = runs_dir / "log.md"
if args.workflow:
wf_path = args.workflow.expanduser().resolve()
if not wf_path.is_file():
print(f"workflow not found: {wf_path}", file=sys.stderr)
return 3
else:
wf_path = find_workflow(resolve_workflows_dir(cfg), cfg)
with wf_path.open(encoding="utf-8") as f:
wf = json.load(f)
nodes = wf.get("nodes") or []
multilines = extract_multilines(nodes, cfg["tracked_multilines"])
director = extract_director_state(nodes)
scheduler = extract_scheduler_chain(nodes)
scores, feedback_lines = parse_feedback(
multilines.get("overlay - feedback", "")
)
relay_overrides_raw = multilines.get("relay_overrides", "")
label = args.label or autogen_label(
multilines, director, relay_overrides_raw
)
nnn = next_run_number(runs_dir)
run_dir = runs_dir / f"run-{nnn:03d}_{label}"
# Two-line chat-report header (matches dev-sync convention).
print(f"{short_sha()} - {REPO_ROOT.name}")
print(
f"loop-{cfg['job_name']} run-{nnn:03d} workflow={wf_path.name} "
f"director={director.get('variant')} "
f"audio={'ON' if director.get('use_custom_audio') is True else 'off'}"
)
if args.dry_run:
print(f" (dry-run) would write: {run_dir.relative_to(REPO_ROOT)}")
print(f" (dry-run) would append row to: {log_path.relative_to(REPO_ROOT)}")
return 0
run_dir.mkdir(parents=True, exist_ok=False)
shutil.copy2(wf_path, run_dir / "workflow.json")
(run_dir / "relay_overrides.txt").write_text(
render_relay_overrides_txt(relay_overrides_raw, director),
encoding="utf-8",
)
(run_dir / "patch_state.txt").write_text(
render_patch_state(read_dev_build_json(), fork_dir_status(cfg["fork_to_track"])),
encoding="utf-8",
)
(run_dir / "notes.md").write_text(
render_notes_md(
director, multilines, scheduler, scores, feedback_lines,
),
encoding="utf-8",
)
card_status = "skipped"
if cfg["render_card"] and not args.no_card:
try:
sys.path.insert(0, str(Path(__file__).resolve().parent))
from make_card_audio import render_audio_card # type: ignore[import-not-found]
state = _build_state_for_card(
nnn, label, wf_path,
multilines, director, scheduler,
scores, feedback_lines,
cfg["fork_to_track"],
)
render_audio_card(state, run_dir / "card.png")
card_status = "rendered"
except ImportError as exc:
card_status = f"skipped ({exc})"
if not args.no_log:
row = render_log_row(
nnn, director, relay_overrides_raw, scheduler, scores, feedback_lines,
)
with log_path.open("a", encoding="utf-8") as f:
f.write(row)
print(f" wrote: {run_dir.relative_to(REPO_ROOT)}")
print(f" card: {card_status}")
if not args.no_log:
print(f" logged: {log_path.relative_to(REPO_ROOT)}")
return 0
if __name__ == "__main__":
sys.exit(main())