* refactor(loop-audio): card reads only multilines + Koolook Director (#177) The audio-lipsync card is now bounded to two source families: the five tracked Text Multiline nodes and the LTX Director (Koolook v1.3.2) node's own widgets + input wiring. SAMPLER block (BasicScheduler / KSamplerSelect / RandomNoise / CFGGuider scrapes), FORK STATE block (_dev_build.json + git status), and INERT warning are gone — they were either widget scrapes outside the two source families or duplicates of the Director's own state. New card layout (top to bottom): HEADER run NNN — {name} + date + workflow filename KNOB STATE relay_overrides (the per-render knob) BASE · NOTES overlay - info verbatim, blank lines preserved BASE · LOCKED epsilon · Audio src · Working folder (path-wrapped) BASE · SCENE Segments (N) parent, indented `1) 0 to 5 seconds` child list, flat Prompt/Audio/Keyframe coverage POST-RENDER overlay - feedback + parsed motion/sync/sharp Audio src now derived structurally from a 4-state machine (audio_vae link × use_custom_audio × audioSegments count) that mirrors ltx_director.py:610-622 — never from substring matches on prompt text. Per-segment Audio coverage checks audioSegments[] overlap, not the maintainer's [Audio]: prompt formatting. Also fixed: upstream-LTXDirector widget index bug (saved workflow keeps legacy order even after Koolook fork reordered the schema; DIRECTOR_WIDX matches the serialised order); loop_audio.py's .env discovery now falls back to the main repo when run from a worktree (same pattern as make_card.py); notes.md drops the widget-scraped Mechanical interp section; log.md columns realigned (Audio src + Segments replace Phase 1 / Phase 2 / Custom audio). Three new docs alongside the module: reading-graph.html visual flow (dark theme, color-coded bands) reading-graph.schema.yaml machine-readable mirror of the same graph CHEATSHEET.md refreshed against the new design All 231 tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(lint): split semicolon statements + drop unused symbols in make_card_audio Ruff (project's Lint & Smoke gate) flagged 10 issues in the refactored card renderer: - F401 unused imports — `typing.Optional` at the top of the file and `loop_audio.REPO_ROOT` in the standalone CLI's import block. - F841 unused locals — `dur_f` and `dur_s` were left over from the dropped "Duration" row; segment time ranges convey the same info now. Kept in the state dict for notes.md; not read in the renderer. - E702 six semicolon-joined statements in the HEADER and POST-RENDER sections (`draw.text(...); y += N`). Split into separate lines. Rendering is unchanged — verified by re-running the standalone CLI against run-002. All 249 tests still pass; ruff now clean. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * fix(loop-audio): resolve PR #185 multi-agent review findings Anchored by the verified multi-agent review on PR #185 — every fix traces to a confirmed finding (false-positive rate was 0/11). HIGH: - derive_audio_state now distinguishes "(no director)" from "off (no VAE)". Pre-fix, is_input_wired(None, ...) returned None, the truthiness check collapsed director-missing into VAE-unwired, and the card / log silently agreed on the wrong story. Added an early return when director_node is None. Also documented that vae_wired can be None (input socket absent in older schemas) and that we intentionally collapse it with False at the unwired branch — after the missing-director case has been split off. - extract_multilines now iterates needles longest-first. Pre-fix the per-node `break` was order-dependent on config insertion: a future short substring (e.g. "relay") in the same tracked_multilines list would have silently shadowed "relay_overrides". Sorting by length descending removes that footgun without breaking the current 5-needle layout. - parse_timeline now coerces per-segment numeric fields (start / length / trimStart) to int via int(float(...)) with try/except → 0 fallback. The upstream Director itself does this defensively at ltx_director.py:232-234; without coercion a string-valued field silently breaks video_segment_has_audio's `<` comparisons (lexical) and crashes the renderer at `start / fps`. Also drops non-dict entries from segments / audioSegments arrays so a malformed JSON can't slip past json.loads + isinstance checks. - Duplicated wrap_path + video_segment_has_audio bodies in make_card_audio.py are gone. Renderer now imports them at module level from loop_audio so the two scripts agree byte-for-byte on path-wrapping rules and audio-overlap geometry. MEDIUM: - DIRECTOR_WIDX now carries a MIRROR: comment pointing at the YAML schema's `widget_order:` block (the cross-link the review asked for as the minimum drift-prevention fix). - duration_frames / duration_seconds removed from the state dict and from render_audio_card's docstring. The renderer dropped the Duration row earlier in the PR; notes.md reads them straight from director_node via director_widget, so keeping them in state was documenting a contract the renderer didn't honour. YAML schema now splits them out under a notes_only section to match. - Log-row score formatting now uses an explicit `is not None` check (`_score()` helper) instead of `or '?'` — 0 was a legitimate score the maintainer could type that pre-fix collapsed to '?' because 0 is falsy. Matches the card's existing `_s()` helper for parity. - Card already surfaces the missing-Director state distinctly thanks to derive_audio_state returning "(no director)" — the BASE · LOCKED Audio src row shows that label loudly when applicable. No additional row needed. LOW: - Renderer falls back to "1) frames 0-120 (no fps)" when frame_rate is None instead of computing "0 to 0 seconds" silently. Test coverage: - New tests/scripts/test_loop_audio.py — 41 tests covering all five audio_src states, multiline ordering edge cases, parse_timeline coercion + malformed inputs, video_segment_has_audio overlap boundaries, parse_feedback (with explicit zero-score regression test), is_input_wired None vs False semantics, wrap_path separator-only wrapping, pick_existing_path filesystem fallback, autogen_label slug derivation. Docs: - reading-graph.html lists five audio states (was four). - reading-graph.schema.yaml audio_state_machine adds "(no director)" state + director_present input; state_dict moves duration_frames / duration_seconds into a notes_only section; invariants block grows with the missing-director split, type-coercion guarantee, longest- needle-wins guarantee, and zero-score preservation. - CHEATSHEET.md Audio src table grows from 4 to 5 rows. Validation: - 290 tests pass (was 249 — 41 new + 0 regressions). - ruff clean on scripts/loop_audio.py, scripts/make_card_audio.py, tests/scripts/test_loop_audio.py. - Standalone CLI re-renders run-002 card identically (same workflow, same pixels). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
855 lines
31 KiB
Python
855 lines
31 KiB
Python
#!/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("'"))
|
|
|
|
|
|
def find_dotenv() -> Optional[Path]:
|
|
"""Find .env starting at this worktree; fall back to the main repo
|
|
root via git's common-dir when running from a worktree. Mirrors
|
|
scripts/make_card.py so both scripts behave the same when invoked
|
|
from a fresh worktree where the maintainer hasn't copied the .env
|
|
over yet."""
|
|
direct = REPO_ROOT / ".env"
|
|
if direct.exists():
|
|
return direct
|
|
git_marker = 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
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# 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.
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
DIRECTOR_TYPE = "LTXDirector__koolook_v1_3_2"
|
|
|
|
# Koolook Director widget order — verified empirically against saved
|
|
# workflow JSON. The Comfy frontend serialises widgets in their original
|
|
# (upstream) order even after the Koolook fork reordered them in the
|
|
# schema; the only new widget — `relay_overrides` — is appended at the
|
|
# end. So the saved widgets_values matches the legacy LTXDirector order
|
|
# plus one extra slot.
|
|
#
|
|
# MIRROR: this dict is also documented in
|
|
# docs/automations/LTX-2.3/audio-lipsync/reading-graph.schema.yaml under
|
|
# `source_families.director_node.widget_order` (and indirectly under
|
|
# `state_dict.director_derived`). Update both when this dict changes,
|
|
# or the YAML drifts silently.
|
|
DIRECTOR_WIDX = {
|
|
"global_prompt": 0,
|
|
"duration_frames": 1,
|
|
"duration_seconds": 2,
|
|
"timeline_data": 3,
|
|
"local_prompts": 4,
|
|
"segment_lengths": 5,
|
|
"epsilon": 6,
|
|
"guide_strength": 7,
|
|
"use_custom_audio": 8,
|
|
"frame_rate": 9,
|
|
"display_mode": 10,
|
|
"custom_width": 11,
|
|
"custom_height": 12,
|
|
"resize_method": 13,
|
|
"divisible_by": 14,
|
|
"img_compression": 15,
|
|
"relay_overrides": 16,
|
|
}
|
|
|
|
|
|
def extract_multilines(
|
|
nodes: list[dict], tracked_titles: list[str]
|
|
) -> dict[str, list[str]]:
|
|
"""Collect every body matching each needle. The canvas legitimately
|
|
ships duplicates for some needles — Working_Folder_PATH has both a
|
|
project-mount and a local-mirror copy, for example — so we keep
|
|
all hits keyed by needle and let the caller pick which to use."""
|
|
out: dict[str, list[str]] = {n.lower(): [] for n in tracked_titles}
|
|
# Longest-needle-wins for the per-node `break`. The current five
|
|
# needles don't collide, but as the canvas grows a shorter substring
|
|
# (e.g. "relay") in the same config could shadow a longer one
|
|
# ("relay_overrides") just because of declaration order. Sorting
|
|
# longest-first removes that footgun.
|
|
needles_by_length = sorted(out.keys(), key=len, reverse=True)
|
|
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_by_length:
|
|
if needle in title:
|
|
out[needle].append(body)
|
|
break
|
|
return out
|
|
|
|
|
|
def first_multiline(ml: dict[str, list[str]], needle: str) -> str:
|
|
return (ml.get(needle) or [""])[0]
|
|
|
|
|
|
def pick_existing_path(candidates: list[str]) -> str:
|
|
"""Pick the first candidate that resolves to a real directory on
|
|
this machine. Falls back to the first non-empty candidate so the
|
|
path still appears on the card even if its drive isn't mounted
|
|
here (worktree-on-N, render-target-on-W, etc.)."""
|
|
for c in candidates:
|
|
cleaned = c.strip().strip('"\'').rstrip("/\\")
|
|
if cleaned and Path(cleaned).is_dir():
|
|
return cleaned
|
|
for c in candidates:
|
|
cleaned = c.strip().strip('"\'').rstrip("/\\")
|
|
if cleaned:
|
|
return cleaned
|
|
return ""
|
|
|
|
|
|
def wrap_path(path: str, max_chars: int = 40) -> list[str]:
|
|
"""Wrap a filesystem path onto multiple lines, breaking only on
|
|
`/` or `\\` separators so directory names stay whole. Used by the
|
|
card renderer so long mount paths don't bleed past the card edge."""
|
|
if not path:
|
|
return [""]
|
|
raw = path.replace("/", "\\")
|
|
parts = [p for p in raw.split("\\") if p]
|
|
if not parts:
|
|
return [path]
|
|
lines: list[str] = []
|
|
cur = ""
|
|
for i, p in enumerate(parts):
|
|
token = p + ("\\" if i < len(parts) - 1 else "")
|
|
if cur and len(cur) + len(token) > max_chars:
|
|
lines.append(cur)
|
|
cur = token
|
|
else:
|
|
cur = cur + token
|
|
if cur:
|
|
lines.append(cur)
|
|
return lines
|
|
|
|
|
|
def extract_director(nodes: list[dict]) -> Optional[dict]:
|
|
"""Return the Koolook Director node dict (or None). The full node
|
|
is returned so callers can read both widgets_values AND inputs[]
|
|
socket wiring — the audio_vae link is what decides whether audio
|
|
gets generated at all (see derive_audio_state)."""
|
|
for n in nodes:
|
|
if n.get("type") == DIRECTOR_TYPE:
|
|
return n
|
|
return None
|
|
|
|
|
|
def director_widget(node: Optional[dict], key: str) -> Any:
|
|
if not node:
|
|
return None
|
|
wv = node.get("widgets_values") or []
|
|
idx = DIRECTOR_WIDX.get(key)
|
|
if idx is None or idx >= len(wv):
|
|
return None
|
|
return wv[idx]
|
|
|
|
|
|
def is_input_wired(node: Optional[dict], input_name: str) -> Optional[bool]:
|
|
"""True iff a named input socket on the Director has a non-null
|
|
link. None when the node or input isn't found."""
|
|
if not node:
|
|
return None
|
|
for inp in node.get("inputs") or []:
|
|
if inp.get("name") == input_name:
|
|
return inp.get("link") is not None
|
|
return None
|
|
|
|
|
|
def _coerce_segment_numeric(seg: dict) -> dict:
|
|
"""Coerce a timeline segment's numeric fields (start, length,
|
|
trimStart) to int. The upstream Director itself does this
|
|
defensively at ltx_director.py:232-234 — the saved JSON sometimes
|
|
contains string values for these fields, and downstream
|
|
arithmetic (`start / fps`, `<` comparisons) breaks silently
|
|
without coercion. Failures collapse to 0 so a malformed segment
|
|
can't crash the loop."""
|
|
out = dict(seg)
|
|
for k in ("start", "length", "trimStart"):
|
|
if k in out:
|
|
try:
|
|
out[k] = int(float(out[k]))
|
|
except (TypeError, ValueError):
|
|
out[k] = 0
|
|
return out
|
|
|
|
|
|
def parse_timeline(director_node: Optional[dict]) -> dict[str, list]:
|
|
"""Parse the timeline_data JSON widget into a dict of segments +
|
|
audioSegments. Both default to empty lists on any error so the
|
|
rest of the pipeline can iterate without further guards.
|
|
|
|
Per-segment numeric fields are coerced via _coerce_segment_numeric
|
|
so downstream arithmetic stays type-safe regardless of what shape
|
|
the Comfy frontend saved into the JSON."""
|
|
raw = director_widget(director_node, "timeline_data") or ""
|
|
if not raw:
|
|
return {"segments": [], "audioSegments": []}
|
|
try:
|
|
tl = json.loads(raw)
|
|
except json.JSONDecodeError:
|
|
return {"segments": [], "audioSegments": []}
|
|
if not isinstance(tl, dict):
|
|
return {"segments": [], "audioSegments": []}
|
|
return {
|
|
"segments": [
|
|
_coerce_segment_numeric(s)
|
|
for s in (tl.get("segments") or [])
|
|
if isinstance(s, dict)
|
|
],
|
|
"audioSegments": [
|
|
_coerce_segment_numeric(s)
|
|
for s in (tl.get("audioSegments") or [])
|
|
if isinstance(s, dict)
|
|
],
|
|
}
|
|
|
|
|
|
def derive_audio_state(
|
|
director_node: Optional[dict], timeline: dict[str, list]
|
|
) -> str:
|
|
"""Reduce the three structural audio signals — audio_vae wiring,
|
|
use_custom_audio toggle, audioSegments count — to one label that
|
|
mirrors how the Director would actually behave at runtime
|
|
(see forks/.../ltx_director.py: audio_vae None gates everything;
|
|
then use_custom_audio chooses between the encoded path and the
|
|
empty/model-gen path).
|
|
|
|
Five distinct outcomes — "(no director)" is split off so a
|
|
missing-director workflow doesn't get the same label as a
|
|
director-present-but-VAE-unwired one, which silently collapsed
|
|
pre-fix because is_input_wired(None, …) also returns None."""
|
|
if director_node is None:
|
|
return "(no director)"
|
|
vae_wired = is_input_wired(director_node, "audio_vae")
|
|
use_custom = director_widget(director_node, "use_custom_audio")
|
|
audio_segs = timeline.get("audioSegments") or []
|
|
# vae_wired can be None (input socket missing from this node's
|
|
# schema, e.g. an older workflow before audio_vae existed) OR
|
|
# False (socket present but unwired). Both functionally mean
|
|
# "no audio latent produced", so collapse them here — but only
|
|
# AFTER the director-missing case has been split off above.
|
|
if not vae_wired:
|
|
return "off (no VAE)"
|
|
if use_custom is True:
|
|
return "custom" if audio_segs else "custom (empty)"
|
|
return "model-gen"
|
|
|
|
|
|
def video_segment_has_audio(video_seg: dict, audio_segs: list[dict]) -> bool:
|
|
"""Does an audioSegments[] entry overlap this video segment's time
|
|
range? Matches how _build_combined_audio aligns the audio waveform
|
|
onto the global timeline."""
|
|
v_start = video_seg.get("start", 0)
|
|
v_end = v_start + video_seg.get("length", 0)
|
|
for a in audio_segs:
|
|
a_start = a.get("start", 0)
|
|
a_end = a_start + a.get("length", 0)
|
|
if a_start < v_end and a_end > v_start:
|
|
return True
|
|
return False
|
|
|
|
|
|
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(
|
|
name: str,
|
|
director_node: Optional[dict],
|
|
relay_overrides_raw: str,
|
|
) -> str:
|
|
slug = _LABEL_BAD.sub("-", name.lower()).strip("-") or "unnamed"
|
|
director_tag = "koolook" if director_node else "missing"
|
|
use_custom = director_widget(director_node, "use_custom_audio")
|
|
audio_tag = "audio-on" if use_custom 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_node: Optional[dict]
|
|
) -> str:
|
|
body = raw.strip() if raw and raw.strip() else "(empty — upstream defaults)"
|
|
missing_note = ""
|
|
if director_node is None:
|
|
missing_note = (
|
|
"\n\n# WARNING: no LTXDirector__koolook_v1_3_2 node found in "
|
|
"the workflow — the relay_overrides value above isn't being "
|
|
"consumed by anything. Add the Koolook Director to the canvas.\n"
|
|
)
|
|
return f"{body}\n{missing_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_node: Optional[dict],
|
|
timeline: dict[str, list],
|
|
audio_src: str,
|
|
info_body: str,
|
|
feedback_lines: list[str],
|
|
scores: dict[str, Optional[int]],
|
|
) -> str:
|
|
"""Notes pulled exclusively from the two source families the card
|
|
is allowed to read: the OVERLAY-* multilines and the Koolook
|
|
Director node. No widget-scraping of BasicScheduler / KSamplerSelect /
|
|
RandomNoise / CFGGuider — those don't define what this loop sweeps."""
|
|
info_indented = (
|
|
"\n".join(" " + line for line in info_body.splitlines())
|
|
if info_body.strip()
|
|
else " (none)"
|
|
)
|
|
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()
|
|
)
|
|
|
|
if director_node is None:
|
|
director_kind = (
|
|
"**missing** — no `LTXDirector__koolook_v1_3_2` node on the "
|
|
"canvas; this render didn't run through the Koolook fork."
|
|
)
|
|
else:
|
|
director_kind = "Koolook variant (`LTXDirector__koolook_v1_3_2`)"
|
|
|
|
epsilon = director_widget(director_node, "epsilon")
|
|
dur_f = director_widget(director_node, "duration_frames")
|
|
dur_s = director_widget(director_node, "duration_seconds")
|
|
fps = director_widget(director_node, "frame_rate")
|
|
segments = timeline.get("segments") or []
|
|
audio_segs = timeline.get("audioSegments") or []
|
|
|
|
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"## Director node — structural state\n\n"
|
|
f"- Variant: {director_kind}\n"
|
|
f"- Audio src: {audio_src}\n"
|
|
f"- ε (epsilon): {epsilon!r}\n"
|
|
f"- Duration: {dur_f} frames @ {fps} fps ({dur_s} sec)\n"
|
|
f"- Segments: {len(segments)} video / "
|
|
f"{len(audio_segs)} audio\n"
|
|
)
|
|
|
|
|
|
def render_log_row(
|
|
nnn: int,
|
|
director_node: Optional[dict],
|
|
relay_overrides_raw: str,
|
|
audio_src: str,
|
|
timeline: dict[str, list],
|
|
scores: dict[str, Optional[int]],
|
|
feedback_lines: list[str],
|
|
) -> str:
|
|
"""Rolling-table row aligned with the card's data-source rule —
|
|
multilines + Koolook Director only. Scheduler/sampler columns are
|
|
deliberately absent (they aren't what this loop sweeps)."""
|
|
director_cell = DIRECTOR_TYPE if director_node else "(missing)"
|
|
relay_cell = (
|
|
f"`{relay_overrides_raw.strip()}`"
|
|
if relay_overrides_raw.strip()
|
|
else "(empty → defaults)"
|
|
)
|
|
segments = timeline.get("segments") or []
|
|
seg_cell = str(len(segments))
|
|
# `or '?'` would map a legitimate 0/5 to '?' because 0 is falsy;
|
|
# explicit None-check preserves the score the maintainer typed.
|
|
# Mirrors render_audio_card._s() for card/log consistency.
|
|
def _score(v: Optional[int]) -> str:
|
|
return str(v) if v is not None else "?"
|
|
score_cell = (
|
|
f"M{_score(scores['motion'])}·S{_score(scores['sync'])}·"
|
|
f"Sh{_score(scores['sharp'])}"
|
|
)
|
|
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_src} | {seg_cell} "
|
|
f"| {score_cell} | {notes_cell} |\n"
|
|
)
|
|
|
|
|
|
# --------------------------------------------------------------------------
|
|
# Driver.
|
|
# --------------------------------------------------------------------------
|
|
|
|
|
|
def _build_state_for_card(
|
|
nnn: int, label: str, wf_path: Path,
|
|
multilines: dict[str, list[str]],
|
|
director_node: Optional[dict],
|
|
timeline: dict[str, list],
|
|
audio_src: str,
|
|
scores: dict[str, Optional[int]], feedback_lines: list[str],
|
|
) -> dict[str, Any]:
|
|
"""Card state — strict subset matching the agreed source families:
|
|
five tracked multilines + the Koolook Director node's own values.
|
|
No git, no _dev_build.json, no scheduler scrape.
|
|
|
|
Director's duration_frames / duration_seconds widgets are NOT
|
|
included — the card dropped the Duration row in favour of
|
|
per-segment time ranges, and notes.md reads them straight from
|
|
`director_node` via `director_widget`. Keeping them out of state
|
|
prevents the consumed-but-unused asymmetry the review caught."""
|
|
return {
|
|
"run_number": nnn,
|
|
"run_label": label,
|
|
"date": date.today().isoformat(),
|
|
"workflow_name": wf_path.name,
|
|
"name": first_multiline(multilines, "name").strip() or "(unnamed)",
|
|
"relay_overrides_raw": first_multiline(multilines, "relay_overrides"),
|
|
"info_body": first_multiline(multilines, "overlay - info").rstrip(),
|
|
"feedback_lines": feedback_lines,
|
|
"scores": scores,
|
|
"work_folder": pick_existing_path(
|
|
multilines.get("working_folder") or []
|
|
),
|
|
"director_node": director_node,
|
|
"director_variant": DIRECTOR_TYPE if director_node else "(missing)",
|
|
"audio_src": audio_src,
|
|
"epsilon": director_widget(director_node, "epsilon"),
|
|
"frame_rate": director_widget(director_node, "frame_rate"),
|
|
"segments": timeline.get("segments") or [],
|
|
"audio_segments": timeline.get("audioSegments") or [],
|
|
}
|
|
|
|
|
|
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)
|
|
env_file = find_dotenv()
|
|
if env_file is not None:
|
|
load_dotenv(env_file)
|
|
|
|
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_node = extract_director(nodes)
|
|
timeline = parse_timeline(director_node)
|
|
audio_src = derive_audio_state(director_node, timeline)
|
|
scores, feedback_lines = parse_feedback(
|
|
first_multiline(multilines, "overlay - feedback")
|
|
)
|
|
|
|
name = first_multiline(multilines, "name").strip()
|
|
relay_overrides_raw = first_multiline(multilines, "relay_overrides")
|
|
info_body = first_multiline(multilines, "overlay - info").rstrip()
|
|
label = args.label or autogen_label(
|
|
name, director_node, 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_TYPE if director_node else '(missing)'} "
|
|
f"audio={audio_src}"
|
|
)
|
|
|
|
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_node),
|
|
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_node, timeline, audio_src,
|
|
info_body, feedback_lines, scores,
|
|
),
|
|
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_node, timeline, audio_src,
|
|
scores, feedback_lines,
|
|
)
|
|
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_node, relay_overrides_raw, audio_src,
|
|
timeline, 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())
|