vidfom's picture
Upload folder using huggingface_hub (part 8)
0c85e62 verified
Raw
History Blame Contribute Delete
59.1 kB
#!/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>/
├── runNNN_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
├── metadata.json structured run/setup/director/repo metadata
├── 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 hashlib
import json
import os
import re
import shutil
import subprocess
import sys
import tomllib
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-sig"))
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"
LEGACY_DIRECTOR_TYPES = ("LTXDirector__koolook_v1_3_2",)
UPSTREAM_DIRECTOR_TYPE = "LTXDirector"
DIRECTOR_TYPES = (DIRECTOR_TYPE, *LEGACY_DIRECTOR_TYPES, UPSTREAM_DIRECTOR_TYPE)
KOOLOOK_UPSTREAM_PIN = (
REPO_ROOT
/ "forks"
/ "whatdreamscost_koolook"
/ "versions"
/ "v1_3_9"
/ "UPSTREAM_PIN.yaml"
)
# 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 _normalize_title(s: str) -> str:
return " ".join(s.replace("_", " ").lower().split())
def _normalize_capture_key(s: str) -> str:
return s.strip().lower()
def _normalize_alias_map(
tracked_titles: list[str] | dict[str, Any],
) -> dict[str, list[str]]:
if isinstance(tracked_titles, dict):
out: dict[str, list[str]] = {}
for key, aliases in tracked_titles.items():
if isinstance(aliases, str):
alias_list = [aliases]
else:
alias_list = list(aliases or [])
out[_normalize_capture_key(str(key))] = [
_normalize_title(str(alias))
for alias in alias_list
if str(alias).strip()
]
return out
return {
_normalize_capture_key(str(needle)): [_normalize_title(str(needle))]
for needle in tracked_titles
}
def normalize_tracked_multilines(
tracked_titles: list[str] | dict[str, Any],
) -> dict[str, list[str]]:
"""Return semantic capture keys mapped to title aliases.
Older configs used a flat list where key == title substring. Newer
configs use {"semantic_key": ["preferred title", "fallback title"]}.
"""
return _normalize_alias_map(tracked_titles)
def extract_multilines(
nodes: list[dict], tracked_titles: list[str] | dict[str, Any]
) -> dict[str, list[str]]:
"""Collect bodies by semantic key.
The canvas legitimately ships duplicates for some keys — working-folder
paths can have a project-mount and a local-mirror copy, for example — so
we keep all hits and let the caller pick which to use. Alias lists are
priority ordered; if two aliases for the same semantic key match
different nodes, preferred aliases appear first in the captured list.
"""
tracked = normalize_tracked_multilines(tracked_titles)
captured: dict[str, list[tuple[int, int, str]]] = {
key: [] for key in tracked
}
candidates: list[tuple[int, int, str, str]] = []
for key, aliases in tracked.items():
for priority, alias in enumerate(aliases):
# Sort longest-first within the same priority so a future short
# alias cannot shadow a more specific one.
candidates.append((priority, -len(alias), key, alias))
candidates.sort()
for order, n in enumerate(nodes):
if n.get("type") != "Text Multiline":
continue
title = _normalize_title(n.get("title") or "")
body = (n.get("widgets_values") or [""])[0] or ""
for priority, _, key, alias in candidates:
if alias in title:
captured[key].append((priority, order, body))
break
return {
key: [body for _, _, body in sorted(values)]
for key, values in captured.items()
}
SETUP_VALUE_NODE_TYPES = {
"Text Multiline",
"PrimitiveBoolean",
"PrimitiveFloat",
"PrimitiveInt",
"PrimitiveString",
}
def node_widget_value(n: dict) -> str:
values = n.get("widgets_values")
if values is None:
return ""
if isinstance(values, list):
if not values:
return ""
value = values[0]
else:
value = values
if isinstance(value, (dict, list)):
return json.dumps(value, ensure_ascii=False, sort_keys=True)
return str(value)
def extract_setup_variables(
nodes: list[dict], tracked_titles: list[str] | dict[str, Any]
) -> dict[str, list[str]]:
"""Collect setup-card variable values from source nodes.
This deliberately ignores GetNode/SetNode relay plumbing. For v02,
version/run are PrimitiveInt nodes while the other setup values are
Text Multiline nodes.
"""
tracked = _normalize_alias_map(tracked_titles)
captured: dict[str, list[tuple[int, int, str]]] = {
key: [] for key in tracked
}
candidates: list[tuple[int, int, str, str]] = []
for key, aliases in tracked.items():
for priority, alias in enumerate(aliases):
candidates.append((priority, -len(alias), key, alias))
candidates.sort()
for order, n in enumerate(nodes):
if n.get("type") not in SETUP_VALUE_NODE_TYPES:
continue
title = _normalize_title(n.get("title") or "")
value = node_widget_value(n)
for priority, _, key, alias in candidates:
if alias == title:
captured[key].append((priority, order, value))
break
return {
key: [value for _, _, value in sorted(values)]
for key, values in captured.items()
}
def first_multiline(ml: dict[str, list[str]], needle: str) -> str:
return (ml.get(needle) or [""])[0]
def _parse_int(value: str) -> Optional[int]:
try:
return int(str(value).strip())
except (TypeError, ValueError):
return None
def widget_value_by_name(node: dict, name: str, default: Any = "") -> Any:
values = node.get("widgets_values")
if isinstance(values, dict):
value = values.get(name)
return default if value is None else value
if not isinstance(values, list):
return default
index = 0
for inp in node.get("inputs") or []:
if not isinstance(inp, dict) or "widget" not in inp:
continue
widget = inp.get("widget") or {}
widget_name = widget.get("name") if isinstance(widget, dict) else None
if widget_name == name and index < len(values):
value = values[index]
return default if value is None else value
index += 1
return default
def output_suffix_from_workflow(nodes: list[dict]) -> str:
"""Best-effort QuickTime suffix from the active VideoCombine format.
The OUT settings group builds the name stem from the base name plus
format suffix; the combine node then appends the version. If the
format is unclear, h264 is the current setup default.
"""
for n in nodes:
if n.get("type") != "Easy_VideoCombine":
continue
fmt = str(widget_value_by_name(n, "format") or "")
values = n.get("widgets_values")
if not fmt and isinstance(values, list) and len(values) > 3:
fmt = str(values[3] or "")
fmt_l = fmt.lower()
if "prores" in fmt_l:
return "ProRes"
if "h264" in fmt_l or "mp4" in fmt_l:
return "h264"
return "h264"
def expected_output_tracking(
nodes: list[dict],
multilines: dict[str, list[str]],
setup_variables: dict[str, list[str]],
) -> dict[str, str]:
"""Expected external render target for the current setup.
This deliberately uses the current setup variables rather than
``easy showAnything`` readouts, which may still contain the previous
ComfyUI render's filenames.
"""
folder = pick_existing_path(multilines.get("working_folder") or [])
base_name = first_multiline(multilines, "name").strip()
version = _parse_int(first_multiline(setup_variables, "version"))
suffix = output_suffix_from_workflow(nodes)
stem_parts = [part for part in (base_name, suffix) if part]
stem = "_".join(stem_parts)
version_tag = f"v{version:03d}" if version is not None else ""
if stem and version_tag:
stem = f"{stem}_{version_tag}"
return {
"folder": folder,
"name": stem,
"version_tag": version_tag,
"format_suffix": suffix,
}
def delivery_card_filename(
output_tracking: dict[str, str],
run_number: Optional[int] = None,
) -> Optional[str]:
name = (output_tracking.get("name") or "").strip()
if not name:
return None
if run_number is not None:
return f"{name}_run{run_number:03d}_card.png"
return f"{name}_card.png"
def delivery_card_path(
output_tracking: dict[str, str],
run_number: Optional[int] = None,
) -> Optional[Path]:
folder = (output_tracking.get("folder") or "").strip()
filename = delivery_card_filename(output_tracking, run_number)
if not folder or not filename:
return None
return Path(folder) / "cards" / filename
def copy_delivery_card(
card_path: Path,
output_tracking: dict[str, str],
run_number: Optional[int] = None,
*,
overwrite: bool = False,
) -> str:
delivery_path = delivery_card_path(output_tracking, run_number)
if delivery_path is None:
return "(not configured)"
try:
delivery_path.parent.mkdir(parents=True, exist_ok=True)
if delivery_path.exists() and not overwrite:
return f"exists (left in place: {delivery_path})"
shutil.copy2(card_path, delivery_path)
except OSError as exc:
return f"failed ({exc})"
return str(delivery_path)
def scrub_path_for_metadata(value: str) -> str:
"""Store a portable fingerprint instead of full workstation paths."""
raw = str(value or "").strip()
if not raw:
return ""
if re.match(r"^[A-Za-z]:[/\\]", raw) or raw.startswith(("/", "\\\\")):
digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:12]
tail = Path(raw.replace("\\", "/")).name or "(folder)"
return f"{tail} [path-sha256:{digest}]"
return raw
def _scrub_path_fragments(value: str) -> str:
def repl(match: re.Match[str]) -> str:
return scrub_path_for_metadata(match.group(0))
value = re.sub(
r"(?<![A-Za-z])(?:[A-Za-z]:[/\\]{1,2}|\\\\)[^\"'`,\]\s<>]+",
repl,
value,
)
return re.sub(r"<PROJECTS>/[^\"'`,\]\s<>]+", "<PROJECTS>/[path-redacted]", value)
def sanitize_workflow_for_archive(value: Any) -> Any:
"""Redact absolute workstation paths before committing workflow snapshots."""
if isinstance(value, dict):
return {
key: sanitize_workflow_for_archive(item)
for key, item in value.items()
}
if isinstance(value, list):
return [sanitize_workflow_for_archive(item) for item in value]
if isinstance(value, str):
stripped = value.strip()
if re.match(r"^[A-Za-z]:[/\\]", stripped) or stripped.startswith(("/", "\\\\")):
redacted = scrub_path_for_metadata(stripped)
if value != stripped:
return value.replace(stripped, redacted)
return redacted
return _scrub_path_fragments(value)
return value
def write_archived_workflow(wf: dict[str, Any], path: Path) -> None:
sanitized = sanitize_workflow_for_archive(wf)
path.write_text(
json.dumps(sanitized, ensure_ascii=False, separators=(",", ":")) + "\n",
encoding="utf-8",
)
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 _source_node_for_link(
link_id: Optional[int],
nodes_by_id: dict[int, dict],
links_by_id: dict[int, list],
*,
seen: Optional[set[int]] = None,
) -> Optional[dict]:
if link_id is None:
return None
if seen is None:
seen = set()
if link_id in seen:
return None
seen.add(link_id)
link = links_by_id.get(link_id)
if not link or len(link) < 3:
return None
origin = nodes_by_id.get(int(link[1]))
if not origin:
return None
if origin.get("type") == "Reroute":
inputs = origin.get("inputs") or []
if inputs:
return _source_node_for_link(
inputs[0].get("link"), nodes_by_id, links_by_id, seen=seen
)
return origin
def extract_director(nodes: list[dict], links: Optional[list] = None) -> Optional[dict]:
"""Return the active Director node dict (or None).
Prefer the Director that feeds an ``LTXDirectorGuide.guide_data`` input.
A/B comparison canvases can keep both upstream and Koolook Directors on
the graph; canvas presence alone is not enough. If no active guide wiring
is visible, fall back to the old Koolook-first selection.
"""
if links:
nodes_by_id = {int(n["id"]): n for n in nodes if "id" in n}
links_by_id = {int(link[0]): link for link in links if link}
for guide in (n for n in nodes if n.get("type") == "LTXDirectorGuide"):
for inp in guide.get("inputs") or []:
if inp.get("name") != "guide_data":
continue
source = _source_node_for_link(
inp.get("link"), nodes_by_id, links_by_id
)
if source and source.get("type") in DIRECTOR_TYPES:
return source
for dtype in DIRECTOR_TYPES:
for n in nodes:
if n.get("type") == dtype:
return n
return None
def director_type(node: Optional[dict]) -> str:
return node.get("type") if node else "(missing)"
def director_flavor(node: Optional[dict]) -> str:
dtype = director_type(node)
if dtype in (DIRECTOR_TYPE, *LEGACY_DIRECTOR_TYPES):
return "Koolook"
if dtype == UPSTREAM_DIRECTOR_TYPE:
return "Original upstream"
return "(missing)"
def detect_upstream_whatdreamscost_version() -> str:
"""Return the installed upstream WhatDreamsCost version, if available."""
dev = os.environ.get("KOLOOK_COMFYUI_DEV_PATH", "")
if not dev:
return ""
custom_nodes = Path(dev).expanduser().parent
pyproject = custom_nodes / "WhatDreamsCost-ComfyUI" / "pyproject.toml"
if not pyproject.is_file():
return ""
try:
data = tomllib.loads(pyproject.read_text(encoding="utf-8-sig"))
except (OSError, tomllib.TOMLDecodeError):
return ""
version = data.get("project", {}).get("version", "")
return str(version).strip()
def detect_koolook_whatdreamscost_pin() -> str:
"""Return the Koolook fork's pinned upstream version tag."""
if not KOOLOOK_UPSTREAM_PIN.is_file():
return ""
try:
for line in KOOLOOK_UPSTREAM_PIN.read_text(encoding="utf-8").splitlines():
match = re.match(r'\s*source_ref:\s*"?(v[0-9]+(?:\.[0-9]+)+)', line)
if match:
return match.group(1)
except OSError:
return ""
version_dir = KOOLOOK_UPSTREAM_PIN.parent.name
if version_dir.startswith("v"):
return version_dir.replace("_", ".")
return ""
def director_pin_tag(
node: Optional[dict],
upstream_whatdreamscost_version: str = "",
) -> str:
dtype = director_type(node)
if dtype in (DIRECTOR_TYPE, *LEGACY_DIRECTOR_TYPES):
return detect_koolook_whatdreamscost_pin() or "(unknown Koolook pin)"
if dtype == UPSTREAM_DIRECTOR_TYPE:
if upstream_whatdreamscost_version:
return f"v{upstream_whatdreamscost_version}"
return "(unknown upstream pin)"
return "(missing)"
def is_koolook_director(node: Optional[dict]) -> bool:
return director_type(node) in (DIRECTOR_TYPE, *LEGACY_DIRECTOR_TYPES)
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 isinstance(wv, dict):
named = widget_value_by_name(node, key, default=None)
if named is not None:
return named
if director_type(node) in DIRECTOR_TYPES and isinstance(wv, list):
if idx is not None and idx < len(wv):
return wv[idx]
return None
named = widget_value_by_name(node, key, default=None)
if named is not None:
return named
if isinstance(wv, list) and idx is not None and idx < len(wv):
return wv[idx]
if idx is None or idx >= len(wv):
return None
return wv[idx]
def display_numeric_widget(value: Any, label: str = "unknown") -> str:
"""Render numeric Director widgets without accepting stale booleans."""
if isinstance(value, bool) or value is None:
return f"(unknown {label})"
if isinstance(value, (int, float)):
return str(value)
text = str(value).strip()
if not text:
return f"(unknown {label})"
try:
float(text)
except ValueError:
return f"(unknown {label})"
return text
def metadata_numeric_widget(value: Any) -> Any:
"""Store numeric Director widgets without bool-as-number coercion."""
if isinstance(value, bool) or value is None:
return None
if isinstance(value, (int, float)):
return value
text = str(value).strip()
if not text:
return None
try:
parsed = float(text)
except ValueError:
return None
return int(parsed) if parsed.is_integer() else parsed
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 linked_input_value(
nodes: list[dict],
links: list,
target_node: Optional[dict],
input_name: str,
) -> str:
"""Return the value feeding a linked input socket, not a nearby note.
This keeps card/log state aligned with execution: a multiline
``RELAY_OVERRIDES`` note only counts when its value is actually wired into
the active Director input. Reroutes and simple display/pass-through nodes
are followed one hop chain at a time so tidy canvas plumbing still works.
"""
if not target_node:
return ""
input_link = None
for inp in target_node.get("inputs") or []:
if inp.get("name") == input_name:
input_link = inp.get("link")
break
if input_link is None:
return ""
nodes_by_id = {n.get("id"): n for n in nodes}
links_by_id = {link[0]: link for link in links if isinstance(link, list) and link}
current_link = input_link
seen: set[int] = set()
while current_link is not None and current_link not in seen:
seen.add(current_link)
link = links_by_id.get(current_link)
if not link or len(link) < 3:
return ""
source = nodes_by_id.get(link[1])
if not source:
return ""
source_type = source.get("type")
if source_type in {"Reroute", "easy showAnything"}:
source_inputs = source.get("inputs") or []
current_link = source_inputs[0].get("link") if source_inputs else None
continue
return node_widget_value(source)
return ""
def active_relay_overrides(
nodes: list[dict],
links: list,
director_node: Optional[dict],
) -> str:
if not is_koolook_director(director_node):
return ""
return linked_input_value(nodes, links, director_node, "relay_overrides")
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):
print(
f"WARNING: timeline segment {k}={out[k]!r} is not numeric; "
"using 0 for loop/card extraction.",
file=sys.stderr,
)
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 director presence plus 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
def segment_prompt_mode(segments: list[dict]) -> str:
"""Classify whether timeline segments share one prompt or vary.
This is a structural prompt check only. It normalizes whitespace but
does not inspect prompt meaning, so it stays inside the card's source
rule: Director timeline JSON in, small capture label out.
"""
if not segments:
return "none"
prompts = [
" ".join(str(seg.get("prompt") or "").split())
for seg in segments
]
if any(not prompt for prompt in prompts):
return "missing"
if len(prompts) == 1:
return "single"
return "same" if len(set(prompts)) == 1 else "per-segment"
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-sig"))
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)))
log_path = runs_dir / "log.md"
if log_path.is_file():
try:
for line in log_path.read_text(encoding="utf-8").splitlines():
m = re.match(r"\|\s*(\d{3,})\s*\|", line)
if m:
nums.append(int(m.group(1)))
except OSError:
pass
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"
dtype = director_type(director_node)
if dtype == UPSTREAM_DIRECTOR_TYPE:
director_tag = "upstream"
elif is_koolook_director(director_node):
director_tag = "koolook"
else:
director_tag = "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 supported LTXDirector node found in "
"the workflow — the relay_overrides value above isn't being "
"consumed by anything. Add the Koolook Director to make it active.\n"
)
elif not is_koolook_director(director_node):
missing_note = (
"\n\n# NOTE: this value is INERT for this render — the Director "
f"node in the workflow is upstream `{director_type(director_node)}`, "
"which has no `relay_overrides` input. Swap to "
"`LTXDirector__koolook` to make this active.\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 card_metadata(
nnn: int,
label: str,
wf_path: Path,
multilines: dict[str, list[str]],
setup_variables: dict[str, list[str]],
output_tracking: dict[str, str],
director_node: Optional[dict],
timeline: dict[str, list],
audio_src: str,
build: dict[str, str],
fork_status: str,
upstream_whatdreamscost_version: str,
) -> dict[str, Any]:
segments = timeline.get("segments") or []
audio_segments = timeline.get("audioSegments") or []
setup_input_path = first_multiline(setup_variables, "input_path_exr")
output_folder = output_tracking.get("folder", "")
return {
"schema": "koolook.audio_loop.card_metadata.v1",
"run": {
"capture_number": f"{nnn:03d}",
"label": label,
"date": date.today().isoformat(),
"workflow": f"run{nnn:03d}_workflow.json",
"source_workflow": wf_path.name,
"archived_workflow": f"run{nnn:03d}_workflow.json",
"setup_name": wf_path.stem,
},
"setup": {
"base_name": first_multiline(multilines, "name").strip(),
"working_folder": scrub_path_for_metadata(output_folder),
"input_path_exr": scrub_path_for_metadata(setup_input_path),
"global_version": first_multiline(setup_variables, "version"),
"global_run_offset": first_multiline(setup_variables, "run_offset"),
"relay_overrides": first_multiline(multilines, "relay_overrides").strip(),
},
"output": {
"folder": scrub_path_for_metadata(output_folder),
"name": output_tracking.get("name", ""),
"version_tag": output_tracking.get("version_tag", ""),
"format_suffix": output_tracking.get("format_suffix", ""),
},
"director": {
"type": director_type(director_node),
"flavor": director_flavor(director_node),
"pin_tag": director_pin_tag(
director_node, upstream_whatdreamscost_version
),
"upstream_whatdreamscost_version": upstream_whatdreamscost_version,
"audio_src": audio_src,
"epsilon": director_widget(director_node, "epsilon"),
"duration_frames": director_widget(director_node, "duration_frames"),
"duration_seconds": director_widget(director_node, "duration_seconds"),
"frame_rate": metadata_numeric_widget(
director_widget(director_node, "frame_rate")
),
"segment_prompt_mode": segment_prompt_mode(segments),
"video_segments": len(segments),
"audio_segments": len(audio_segments),
},
"repo": repo_metadata_from_build(build, fork_status),
}
def repo_metadata_from_build(build: dict[str, str], fork_status: str) -> dict[str, str]:
return {
"main_sha": short_sha(),
"sync_script": "sync_to_dev_audio.py",
"last_dev_sync_audio": build.get("commit", ""),
"last_dev_sync_at": build.get("synced_at", ""),
"sync_scope_tag": build.get("scope", ""),
"sync_worktree": build.get("worktree", ""),
"fork_dir_status": fork_status,
}
def parse_dev_sync_audio_line(value: str) -> tuple[str, str]:
"""Split 'sha (date)' patch_state text into schema fields."""
raw = str(value or "").strip()
match = re.match(r"(.+?)\s+\(([^()]*)\)\s*$", raw)
if not match:
return raw, ""
return match.group(1).strip(), match.group(2).strip()
def _notes_value(value: str) -> str:
cleaned = value.strip()
if not cleaned:
return "`(missing)`"
if "\n" not in cleaned:
return f"`{cleaned}`"
indented = "\n".join(f" {line}" for line in cleaned.splitlines())
return f"\n{indented}"
def render_setup_variables_md(
nnn: int,
wf_path: Path,
multilines: dict[str, list[str]],
setup_variables: dict[str, list[str]],
director_node: Optional[dict],
timeline: dict[str, list],
output_tracking: dict[str, str],
*,
commit_sha: str = "",
) -> str:
segments = timeline.get("segments") or []
audio_segs = timeline.get("audioSegments") or []
dur_f = director_widget(director_node, "duration_frames")
dur_s = director_widget(director_node, "duration_seconds")
fps = display_numeric_widget(
director_widget(director_node, "frame_rate"), "fps"
)
epsilon = director_widget(director_node, "epsilon")
relay_overrides = first_multiline(multilines, "relay_overrides")
if relay_overrides.strip() and not is_koolook_director(director_node):
relay_overrides = (
f"{relay_overrides} (inert: active Director is upstream LTXDirector)"
)
upstream_whatdreamscost_version = detect_upstream_whatdreamscost_version()
rows = [
("Capture run number", f"{nnn:03d} (from runs folder/log)"),
("Setup name", wf_path.stem),
("Image segments", f"{len(segments)} video / {len(audio_segs)} audio"),
("Prompts / similarity", segment_prompt_mode(segments)),
("Commit No.", commit_sha or short_sha()),
("LTX-Director (flavour)", director_flavor(director_node)),
(
"Director pin tag",
director_pin_tag(director_node, upstream_whatdreamscost_version),
),
("Audio src", derive_audio_state(director_node, timeline)),
("epsilon", "" if epsilon is None else str(epsilon)),
("Duration", f"{dur_f} frames @ {fps} fps ({dur_s} sec)"),
("RELAY_OVERRIDES", relay_overrides),
(
"GLOBAL [ path ] - working folder",
scrub_path_for_metadata(first_multiline(multilines, "working_folder")),
),
("GLOBAL [ base name ]", first_multiline(multilines, "name")),
(
"INPUT Path [ EXR ]",
scrub_path_for_metadata(first_multiline(setup_variables, "input_path_exr")),
),
("GLOBAL [ version ]", first_multiline(setup_variables, "version")),
("GLOBAL [ run offset ]", first_multiline(setup_variables, "run_offset")),
("Output folder", scrub_path_for_metadata(output_tracking.get("folder", ""))),
("Output name", output_tracking.get("name", "")),
("OVERLAY - INFO", "(captured verbatim above)"),
("OVERLAY - FEEDBACK", "(captured verbatim above)"),
]
body = "\n".join(
f"- {label}: {_notes_value(value)}" for label, value in rows
)
return f"## SETUP variables (captured)\n\n{body}\n\n"
def render_notes_md(
nnn: int,
wf_path: Path,
multilines: dict[str, list[str]],
setup_variables: dict[str, list[str]],
director_node: Optional[dict],
timeline: dict[str, list],
audio_src: str,
info_body: str,
feedback_lines: list[str],
scores: dict[str, Optional[int]],
output_tracking: dict[str, str],
*,
commit_sha: str = "",
) -> str:
"""Notes pulled exclusively from the two source families the card
is allowed to read: the OVERLAY-* multilines and the active
Director node. No widget-scraping of BasicScheduler / KSamplerSelect /
RandomNoise / CFGGuider — those don't define what this loop sweeps."""
info_indented = (
"\n".join(
f" {line}" if line else ""
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` node on the "
"canvas; this render didn't run through the Koolook fork."
)
elif not is_koolook_director(director_node):
director_kind = (
f"**upstream** `{director_type(director_node)}` "
"(Koolook variant NOT wired — relay_overrides + per-segment "
"sigma are INERT this render)"
)
else:
director_kind = (
f"{director_flavor(director_node)} (`{director_type(director_node)}`)"
)
epsilon = director_widget(director_node, "epsilon")
dur_f = director_widget(director_node, "duration_frames")
dur_s = director_widget(director_node, "duration_seconds")
fps = display_numeric_widget(
director_widget(director_node, "frame_rate"), "fps"
)
segments = timeline.get("segments") or []
audio_segs = timeline.get("audioSegments") or []
prompt_mode = segment_prompt_mode(segments)
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"{render_setup_variables_md(nnn, wf_path, multilines, setup_variables, director_node, timeline, output_tracking, commit_sha=commit_sha)}"
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"
f"- Segment prompt mode: {prompt_mode}\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 + active Director only. Scheduler/sampler columns are
deliberately absent (they aren't what this loop sweeps)."""
director_cell = director_type(director_node)
relay_cell = (
f"`{relay_overrides_raw.strip()}`"
if relay_overrides_raw.strip()
else "(empty → defaults)"
)
segments = timeline.get("segments") or []
audio_segments = timeline.get("audioSegments") or []
seg_cell = f"{len(segments)}v/{len(audio_segments)}a"
# `or '?'` would map a legitimate 0/5 to '?' because 0 is falsy;
# explicit None-check preserves the score the maintainer typed.
# Parallels render_audio_card._s(): the log uses bare digits, while
# the card uses N/5 labels.
def _score(v: Optional[int]) -> str:
return str(v) if v is not None else "?"
score_cell = (
f"M{_score(scores.get('motion'))}·S{_score(scores.get('sync'))}·"
f"Sh{_score(scores.get('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]],
output_tracking: dict[str, str],
metadata: dict[str, Any],
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 active 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": f"run{nnn:03d}_workflow.json",
"source_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 []
),
"output_folder": output_tracking.get("folder", ""),
"output_name": output_tracking.get("name", ""),
"metadata": metadata,
"director_node": director_node,
"director_variant": director_type(director_node),
"director_flavor": director_flavor(director_node),
"director_pin_tag": (metadata.get("director") or {}).get("pin_tag", ""),
"audio_src": audio_src,
"epsilon": director_widget(director_node, "epsilon"),
"frame_rate": metadata_numeric_widget(
director_widget(director_node, "frame_rate")
),
"segments": timeline.get("segments") or [],
"audio_segments": timeline.get("audioSegments") or [],
"segment_prompt_mode": segment_prompt_mode(
timeline.get("segments") 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-sig") as f:
wf = json.load(f)
nodes = wf.get("nodes") or []
multilines = extract_multilines(nodes, cfg["tracked_multilines"])
setup_variables = extract_setup_variables(
nodes, cfg.get("tracked_setup_variables", {})
)
output_tracking = expected_output_tracking(nodes, multilines, setup_variables)
director_node = extract_director(nodes, wf.get("links") or [])
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 = active_relay_overrides(
nodes, wf.get("links") or [], director_node
)
active_multilines = dict(multilines)
active_multilines["relay_overrides"] = [relay_overrides_raw]
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}"
build = read_dev_build_json()
fork_status = fork_dir_status(cfg["fork_to_track"])
upstream_whatdreamscost_version = detect_upstream_whatdreamscost_version()
metadata = card_metadata(
nnn, label, wf_path, active_multilines, setup_variables, output_tracking,
director_node, timeline, audio_src, build, fork_status,
upstream_whatdreamscost_version,
)
# 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(director_node)} "
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)
write_archived_workflow(wf, run_dir / f"run{nnn:03d}_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(build, fork_status),
encoding="utf-8",
)
(run_dir / "metadata.json").write_text(
json.dumps(metadata, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
(run_dir / "notes.md").write_text(
render_notes_md(
nnn, wf_path, active_multilines, setup_variables,
director_node, timeline, audio_src,
info_body, feedback_lines, scores, output_tracking,
),
encoding="utf-8",
)
card_status = "skipped"
delivery_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,
active_multilines, output_tracking, metadata, director_node, timeline,
audio_src, scores, feedback_lines,
)
card_path = render_audio_card(state, run_dir / "card.png")
card_status = "rendered"
delivery_status = copy_delivery_card(card_path, output_tracking, nnn)
except ImportError as exc:
card_status = f"skipped ({exc})"
except OSError as exc:
card_status = f"failed ({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}")
print(f" card delivery: {delivery_status}")
if not args.no_log:
print(f" logged: {log_path.relative_to(REPO_ROOT)}")
return 0
if __name__ == "__main__":
sys.exit(main())