Download ComfyUI/custom_nodes/koolook/scripts/make_card.py from vidfom/aimusic: direct link, hf CLI and curl.
- Browser
- Download file 32.8 kB
-
https://huggingface.co/vidfom/aimusic/resolve/main/ComfyUI/custom_nodes/koolook/scripts/make_card.py
- Command line
-
hf download hf://vidfom/aimusic/ComfyUI/custom_nodes/koolook/scripts/make_card.py
-
curl -L -o make_card.py https://huggingface.co/vidfom/aimusic/resolve/main/ComfyUI/custom_nodes/koolook/scripts/make_card.py
32.8 kB
| """Render an experiment-tracking card PNG from a ComfyUI workflow JSON. | |
| Usage: | |
| python scripts/make_card.py <workflow.json> [output.png] | |
| Reads the LTX Director run/base context from the workflow JSON, paints a | |
| self-contained card matching the look of | |
| docs/investigations/experiments/combined-card.html, and writes it to PNG. | |
| PNG can then be loaded into a ComfyUI workflow via a LoadImage node and | |
| composited alongside the rendered video, or dropped straight into an NLE. | |
| Tested against LTX_Director_4k_v0[1-3].json. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| import os | |
| import re | |
| import sys | |
| import time | |
| from pathlib import Path | |
| from PIL import Image, ImageDraw, ImageFont | |
| def _load_dotenv(env_path: Path) -> None: | |
| """Minimal `.env` loader. Mirrors scripts/sync_to_dev.py — no python-dotenv dep.""" | |
| 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.split("=", 1) | |
| os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'")) | |
| def _find_dotenv() -> Path | None: | |
| """Find .env in the current worktree first, then fall back to the main repo | |
| root (resolved via git's common-dir) when running from a worktree. | |
| Returns the first existing .env found, or None.""" | |
| repo_root = Path(__file__).resolve().parent.parent | |
| direct = repo_root / ".env" | |
| if direct.exists(): | |
| return direct | |
| # Try the main repo root via git's common-dir. | |
| git_marker = repo_root / ".git" | |
| common_dir = None | |
| if git_marker.is_file(): | |
| # Worktree marker file contains: "gitdir: <path-to>/<main>/.git/worktrees/<name>" | |
| try: | |
| content = git_marker.read_text(encoding="utf-8").strip() | |
| if content.startswith("gitdir:"): | |
| gitdir = Path(content.split(":", 1)[1].strip()) | |
| # walk up from .git/worktrees/<name>/ to find <main repo>/ | |
| if "worktrees" in gitdir.parts: | |
| idx = gitdir.parts.index("worktrees") | |
| common_dir = Path(*gitdir.parts[:idx]) # ends at <main>/.git | |
| main_root = common_dir.parent | |
| candidate = main_root / ".env" | |
| if candidate.exists(): | |
| return candidate | |
| except Exception: | |
| pass | |
| return None | |
| # Resolve repo root and load .env once on import — same pattern as sync_to_dev.py. | |
| _REPO_ROOT = Path(__file__).resolve().parent.parent | |
| _env_file = _find_dotenv() | |
| if _env_file is not None: | |
| _load_dotenv(_env_file) | |
| # ---------- palette + geometry (kept in sync with combined-card.html) ---------- | |
| W = 540 # slim vertical card — sits beside a video | |
| PAD_X = 28 | |
| PAD_TOP = 28 | |
| PAD_BOTTOM = 28 | |
| # Palette mirrored from docs/designs/snapshot-dialogs.html | |
| BG_OUTER = (14, 14, 14) # --bg | |
| BG_CARD = (21, 21, 21) # --panel | |
| BG_SECTION = (26, 26, 31) # --panel-soft (each section gets this) | |
| BORDER = (48, 47, 47) # --border | |
| BORDER_STR = (79, 79, 84) # --border-strong | |
| TEXT = (249, 250, 251) # --text | |
| MUTED = (143, 149, 156) # --muted | |
| DIM = (200, 204, 209) # close to --text but slightly down | |
| HEADER_GREY = (201, 204, 209) # snapshot dialog H2 colour | |
| ACCENT_RUN = (255, 184, 77) # --amber (PHASE 1 / changeable) | |
| ACCENT_BASE = (109, 180, 255) # --sky (frozen base) | |
| ACCENT_OUT = (123, 207, 128) # --green (post-render) | |
| NOTE_BG = (12, 12, 12) | |
| RADIUS = 14 | |
| SECTION_RADIUS = 8 | |
| # ---------- font loading ---------- | |
| WIN_FONTS = Path(r"C:/Windows/Fonts") | |
| def load_font(filenames: list[str], size: int) -> ImageFont.FreeTypeFont: | |
| for fn in filenames: | |
| for p in (WIN_FONTS / fn, Path("/Library/Fonts") / fn, Path("/usr/share/fonts") / fn): | |
| if p.exists(): | |
| return ImageFont.truetype(str(p), size) | |
| return ImageFont.load_default() | |
| F_TITLE = load_font(["segoeuib.ttf", "Arial Bold.ttf"], 28) | |
| F_SUB = load_font(["segoeui.ttf", "Arial.ttf"], 17) | |
| F_H2 = load_font(["segoeuib.ttf", "Arial Bold.ttf"], 16) | |
| F_TAG = load_font(["segoeuib.ttf", "Arial Bold.ttf"], 12) # FEEDBACK / OUTCOME sub-labels | |
| F_SECTION = load_font(["segoeuib.ttf", "Arial Bold.ttf"], 17) # main section titles (PHASE 1 etc.) | |
| F_MONO = load_font(["consola.ttf", "Menlo.ttc", "DejaVuSansMono.ttf"], 19) | |
| F_NOTE = load_font(["segoeuii.ttf", "Arial Italic.ttf"], 18) | |
| # ---------- workflow extraction ---------- | |
| VIDEO_EXTS = (".mp4", ".mov", ".mkv", ".webm", ".avi") | |
| LOG_NAME = "iterations.md" | |
| AI_SUBDIR = "_AI" # AI-managed artifacts live here, separate from user content | |
| CARD_NAME = "card.png" | |
| # Files containing this marker (case-insensitive) in their basename are | |
| # post-loop outputs (the card already composited with the video) — skip | |
| # them in auto-discovery so the script never picks its own output as input. | |
| SKIP_MARKER = "loop" | |
| def _is_loop_output(p: Path) -> bool: | |
| return SKIP_MARKER in p.stem.lower() | |
| LOG_HEADER = ( | |
| "# LTX Director — iterations log\n\n" | |
| "Append-only. Newest at bottom. Generated by `scripts/make_card.py`.\n\n" | |
| "| # | When | Run | Format | Denoise | JSON | Video | Δ from base | Feedback |\n" | |
| "| - | ---- | --- | ------ | ------- | ---- | ----- | ----------- | -------- |\n" | |
| ) | |
| def log_iteration(folder: Path, json_path: Path, data: dict) -> None: | |
| """Append one row to <folder>/_AI/iterations.md. Dedupe by (json filename + json mtime). | |
| Markdown table — newest at bottom. Creates the file + the _AI subdir on first call.""" | |
| ai_dir = folder / AI_SUBDIR | |
| ai_dir.mkdir(exist_ok=True) | |
| log_path = ai_dir / LOG_NAME | |
| json_mtime = json_path.stat().st_mtime | |
| json_mtime_iso = time.strftime("%Y-%m-%d %H:%M", time.localtime(json_mtime)) | |
| fingerprint = f"{json_path.name}@{int(json_mtime)}" | |
| existing = "" | |
| last_fp = "" | |
| next_idx = 1 | |
| if log_path.exists(): | |
| existing = log_path.read_text(encoding="utf-8") | |
| rows = [ln for ln in existing.splitlines() | |
| if ln.startswith("| ") and not ln.startswith("| -") | |
| and not ln.startswith("| #")] | |
| next_idx = len(rows) + 1 | |
| if rows: | |
| # last row's hidden HTML-comment fingerprint, if present | |
| tail_comment_re = re.compile(r"<!--fp:([^>]+)-->") | |
| for ln in reversed(rows): | |
| m = tail_comment_re.search(ln) | |
| if m: | |
| last_fp = m.group(1) | |
| break | |
| if last_fp == fingerprint: | |
| # Same JSON, same mtime — already logged. Skip. | |
| return | |
| def trunc(s, n): | |
| s = (s or "").replace("\n", " ").strip() | |
| return (s[: n - 1] + "…") if len(s) > n else s | |
| row = ( | |
| f"| {next_idx} " | |
| f"| {json_mtime_iso} " | |
| f"| {data.get('run_label', '?')} " | |
| f"| {data.get('format', '?')} " | |
| f"| {data.get('denoise', '?')} " | |
| f"| `{json_path.name}` " | |
| f"| `{data.get('render_output', '—')}` " | |
| f"| {trunc(data.get('note', ''), 40)} " | |
| f"| {trunc(data.get('feedback', ''), 40)} " | |
| f"|<!--fp:{fingerprint}-->\n" | |
| ) | |
| if not existing: | |
| log_path.write_text(LOG_HEADER + row, encoding="utf-8") | |
| else: | |
| with log_path.open("a", encoding="utf-8") as f: | |
| f.write(row) | |
| def load_workflow(path: Path) -> dict: | |
| """Load a ComfyUI workflow JSON. Accepts two formats: | |
| 1. raw editor JSON (top-level 'nodes' + 'links' keys), or | |
| 2. metadata bundle ({"CreationTime": ..., "prompt": ..., "workflow": {...}}) | |
| — the format ComfyUI writes alongside saved outputs. | |
| """ | |
| raw = json.loads(path.read_text(encoding="utf-8")) | |
| if "nodes" in raw: | |
| return raw | |
| if "workflow" in raw and isinstance(raw["workflow"], dict) and "nodes" in raw["workflow"]: | |
| return raw["workflow"] | |
| raise SystemExit(f"Unrecognised JSON format in {path} — no 'nodes' key found.") | |
| def find_newest_video(folder: Path): | |
| """Return (Path, mtime) for the newest video file in folder, or (None, None). | |
| Skips files marked as loop outputs (see SKIP_MARKER).""" | |
| candidates = [p for p in folder.iterdir() | |
| if p.is_file() | |
| and p.suffix.lower() in VIDEO_EXTS | |
| and not _is_loop_output(p)] | |
| if not candidates: | |
| return None, None | |
| best = max(candidates, key=lambda p: p.stat().st_mtime) | |
| return best, best.stat().st_mtime | |
| def find_comfyui_log_path(): | |
| """Resolve the ComfyUI log path. Priority: | |
| 1. `KOLOOK_COMFYUI_LOG` env var (explicit override). | |
| 2. Inferred from `KOLOOK_COMFYUI_DEV_PATH` — the dev path points at | |
| `<comfyui-root>/custom_nodes/<koolook>`, so the log sits at | |
| `<comfyui-root>/user/comfyui.log`. | |
| Returns Path or None.""" | |
| override = os.environ.get("KOLOOK_COMFYUI_LOG", "").strip() | |
| if override: | |
| p = Path(override) | |
| return p if p.exists() else None | |
| dev = os.environ.get("KOLOOK_COMFYUI_DEV_PATH", "").strip() | |
| if not dev: | |
| return None | |
| candidate = Path(dev).parent.parent / "user" / "comfyui.log" | |
| return candidate if candidate.exists() else None | |
| _LOG_DUR_PATTERNS = [ | |
| # "Prompt executed in 18.21 seconds" | |
| (re.compile(r"Prompt executed in ([\d.]+) seconds"), | |
| lambda m: float(m.group(1))), | |
| # "Prompt executed in 00:10:33" | |
| (re.compile(r"Prompt executed in (\d+):(\d+):(\d+)"), | |
| lambda m: int(m.group(1)) * 3600 + int(m.group(2)) * 60 + int(m.group(3))), | |
| ] | |
| def render_time_from_log(log_path: Path): | |
| """Return the duration of the most recent render that actually wrote | |
| frames. Locates the latest `saving images: 100%` line in the Comfy log | |
| and pairs it with the next `Prompt executed in X` line — that's the | |
| completed EXR-producing render. Skips trivial follow-up prompts that | |
| happen between the render finishing and the user invoking /make-card. | |
| Returns duration in seconds, or None.""" | |
| if not log_path or not log_path.exists(): | |
| return None | |
| text = log_path.read_text(encoding="utf-8", errors="replace") | |
| saves = list(re.finditer(r"saving images: 100%", text)) | |
| if not saves: | |
| return None | |
| after = saves[-1].end() | |
| candidates = [] | |
| for pat, to_seconds in _LOG_DUR_PATTERNS: | |
| m = pat.search(text, after) | |
| if m: | |
| candidates.append((m.start(), to_seconds(m))) | |
| if not candidates: | |
| return None | |
| # Take the first 'Prompt executed in X' line after the save (smallest start). | |
| candidates.sort(key=lambda x: x[0]) | |
| return candidates[0][1] | |
| def exr_sequence_duration(folder: Path): | |
| """Return (duration_seconds, version_dir_name) for the most recent EXR | |
| sequence in the working folder. Treats render time as the wall-clock | |
| span between the first and last frame in the version subdirectory of | |
| the newest EXR — accurate even when the JSON is saved after rendering. | |
| Returns (None, None) if no EXR sequence is found.""" | |
| all_exrs = list(folder.rglob("*.exr")) | |
| if not all_exrs: | |
| return None, None | |
| newest = max(all_exrs, key=lambda p: p.stat().st_mtime) | |
| seq_dir = newest.parent | |
| seq_mtimes = sorted(p.stat().st_mtime for p in seq_dir.glob("*.exr")) | |
| if len(seq_mtimes) < 2: | |
| return None, seq_dir.name | |
| return seq_mtimes[-1] - seq_mtimes[0], seq_dir.name | |
| def fmt_duration(seconds: float) -> str: | |
| s = int(round(seconds)) | |
| if s < 60: | |
| return f"{s}s" | |
| m, s = divmod(s, 60) | |
| if m < 60: | |
| return f"{m}m {s:02d}s" | |
| h, m = divmod(m, 60) | |
| return f"{h}h {m:02d}m" | |
| def find(wf, t): | |
| return [n for n in wf["nodes"] if n.get("type") == t] | |
| def widgets(n): return n.get("widgets_values", []) or [] | |
| def resolve_input(wf, node, name): | |
| """Return the upstream node feeding a named input, or None.""" | |
| links = {link[0]: link for link in wf["links"]} | |
| by_id = {n["id"]: n for n in wf["nodes"]} | |
| for inp in node.get("inputs", []) or []: | |
| if inp.get("name") == name and inp.get("link") is not None: | |
| link = links.get(inp["link"]) | |
| if link: | |
| return by_id.get(link[1]) | |
| return None | |
| def follow_setnode(wf, getnode): | |
| """GetNode -> the value-producing source via SetNode of the same variable.""" | |
| var = widgets(getnode)[0] if widgets(getnode) else None | |
| if not var: | |
| return None | |
| for n in wf["nodes"]: | |
| if n.get("type") == "SetNode" and widgets(n)[:1] == [var]: | |
| # SetNode's incoming link is the actual value source | |
| links = {link[0]: link for link in wf["links"]} | |
| by_id = {n["id"]: n for n in wf["nodes"]} | |
| for inp in n.get("inputs", []) or []: | |
| if inp.get("link") is not None: | |
| link = links.get(inp["link"]) | |
| if link: | |
| src = by_id.get(link[1]) | |
| # If it's a switch, resolve the selected value | |
| if src and src.get("type") == "easy anythingIndexSwitch": | |
| idx_node = resolve_input(wf, src, "index") | |
| idx = widgets(idx_node)[0] if idx_node and widgets(idx_node) else 0 | |
| val_node = resolve_input(wf, src, f"value{idx}") | |
| return val_node | |
| return src | |
| return None | |
| def res_label(w, h): | |
| return {(3744,2112):"4K", (3840,2160):"4K", (2560,1440):"2K", | |
| (1920,1080):"HD", (1280,720):"HD"}.get((w,h), f"{w}x{h}") | |
| def extract(wf): | |
| """Pull every field we need to draw the card.""" | |
| d = find(wf, "LTXDirector")[0] | |
| dw = widgets(d) | |
| # LTXDirector widget order (verified in v01-v03): | |
| # 0 global_prompt, 1 dur_frames, 2 dur_sec, 3 timeline_data, 4 local_prompts, | |
| # 5 segment_lengths, 6 epsilon, 7 guide_strength, 8 use_custom_audio, | |
| # 9 frame_rate, 10 display_mode, 11 custom_width, 12 custom_height, | |
| # 13 resize_method, 14 divisible_by, 15 img_compression | |
| epsilon = dw[6] | |
| seg_lengths_str = dw[5] | |
| fps = dw[9] | |
| use_custom_aud = dw[8] | |
| dur_frames = dw[1] | |
| dur_seconds = dw[2] | |
| # custom_width / height may be widget value OR fed by GetNode chain | |
| cw = resolve_input(wf, d, "custom_width") | |
| ch = resolve_input(wf, d, "custom_height") | |
| if cw and cw.get("type") == "GetNode": | |
| cw = follow_setnode(wf, cw) | |
| if ch and ch.get("type") == "GetNode": | |
| ch = follow_setnode(wf, ch) | |
| width = widgets(cw)[0] if cw and widgets(cw) else dw[11] | |
| height = widgets(ch)[0] if ch and widgets(ch) else dw[12] | |
| # parse timeline_data segments | |
| try: | |
| tl = json.loads(dw[3]) if dw[3] else {} | |
| segments = tl.get("segments", []) | |
| except Exception: | |
| segments = [] | |
| seg_lengths = [int(x.strip()) for x in seg_lengths_str.split(",") if x.strip()] | |
| # phase 1 chain — first BasicScheduler with denoise == 1.0, else first one | |
| schedulers = find(wf, "BasicScheduler") | |
| p1 = next((s for s in schedulers if widgets(s)[2] == 1), schedulers[0] if schedulers else None) | |
| sched_name, steps, denoise = (widgets(p1) if p1 else ("?", "?", "?")) | |
| # Director Guide scale_by per phase — pick the one fed by LTXDirector (phase 1) | |
| guides = find(wf, "LTXDirectorGuide") | |
| p1_guide = None | |
| for g in guides: | |
| latent_src = resolve_input(wf, g, "latent") | |
| if latent_src and latent_src.get("type") == "LTXDirector": | |
| p1_guide = g | |
| break | |
| if p1_guide is None and guides: | |
| p1_guide = guides[0] | |
| mult = widgets(p1_guide)[0] if p1_guide else "?" | |
| # Pull three named Text Multiline nodes from the workflow: | |
| # - "OVERLAY - INFO" (or any title containing INFO) -> base notes (Δ from baseline) | |
| # - "OVERLAY - FEEDBACK" / "FEEDBACK" / "OBSERVATIONS" -> video feedback + scores | |
| # - "Working_Folder_PATH" / "OUT_working_folder" -> authoritative output folder | |
| note, feedback, work_folder = "", "", "" | |
| for n in wf["nodes"]: | |
| if n.get("type") != "Text Multiline": | |
| continue | |
| title = (n.get("title") or "").upper() | |
| ws = widgets(n) | |
| if not (ws and isinstance(ws[0], str)): | |
| continue | |
| text = ws[0] | |
| if not note and ("INFO" in title or ("OVERLAY" in title and "FEEDBACK" not in title)): | |
| for marker in ("BASE (notes):", "BASE:"): | |
| if marker in text: | |
| note = text.split(marker, 1)[1].strip() | |
| break | |
| if not feedback and ("FEEDBACK" in title or "OBSERVATION" in title or "VIDEO" in title): | |
| feedback = text.strip() | |
| if not work_folder and ("WORKING_FOLDER" in title or "WORK_FOLDER" in title | |
| or "OUT_WORKING" in title or "WORKING-FOLDER" in title): | |
| work_folder = text.strip().strip("\"' ").rstrip("\\/") | |
| # Parse scores out of the feedback text. Pattern: "motion: 4/5" / "sync 3" / "sharpness: 5/5". | |
| scores = {"motion": None, "sync": None, "sharp": None} | |
| feedback_lines = [] | |
| score_re = re.compile( | |
| r"^\s*(motion|sync|sharp(?:ness)?|sharpness)\s*[:=]?\s*(\d+)\s*(?:/\s*\d+)?\s*$", | |
| re.IGNORECASE, | |
| ) | |
| for line in feedback.splitlines(): | |
| m = score_re.match(line) | |
| if m: | |
| key = m.group(1).lower() | |
| if key.startswith("sharp"): | |
| key = "sharp" | |
| scores[key] = int(m.group(2)) | |
| elif line.strip(): | |
| feedback_lines.append(line.rstrip()) | |
| feedback = "\n".join(feedback_lines).strip() | |
| # model stack | |
| ckpt = (widgets(find(wf, "CheckpointLoaderSimple")[0]) if find(wf, "CheckpointLoaderSimple") else [""])[0] | |
| lora_nodes = find(wf, "LoraLoaderModelOnly") | |
| lora = "" | |
| if lora_nodes: | |
| lw = widgets(lora_nodes[0]) | |
| lora_name = lw[0].replace("\\", "/").split("/")[-1].replace(".safetensors", "") | |
| lora_strength = lw[1] if len(lw) > 1 else "" | |
| lora = f"{lora_name} @ {lora_strength}" | |
| clip_nodes = find(wf, "DualCLIPLoader") | |
| clip = "" | |
| if clip_nodes: | |
| cw_ = widgets(clip_nodes[0]) | |
| clip = cw_[0].replace("\\", "/").split("/")[-1].replace(".safetensors", "") | |
| vaes = find(wf, "VAELoaderKJ") | |
| video_vae = audio_vae = "" | |
| for v in vaes: | |
| n = widgets(v)[0].replace(".safetensors", "") | |
| if "video" in n: | |
| video_vae = n | |
| elif "audio" in n: | |
| audio_vae = n | |
| # seed | |
| rn = find(wf, "RandomNoise") | |
| seed = widgets(rn[0])[0] if rn else "?" | |
| return { | |
| "format": res_label(width, height), | |
| "resolution": f"{width} × {height}", | |
| "mult": mult, | |
| "scheduler": sched_name, | |
| "steps": steps, | |
| "denoise": denoise, | |
| "note": note, | |
| "feedback": feedback, | |
| "scores": scores, | |
| "work_folder_from_wf": work_folder, | |
| "epsilon": epsilon, | |
| "seed": seed, | |
| "fps": fps, | |
| "dur_frames": dur_frames, | |
| "dur_seconds": dur_seconds, | |
| "segments": segments, | |
| "seg_lengths": seg_lengths, | |
| "use_custom_audio": use_custom_aud, | |
| "ckpt": Path(ckpt).name.replace(".safetensors", ""), | |
| "lora": lora, | |
| "clip": Path(clip).name, | |
| "video_vae": video_vae, | |
| "audio_vae": audio_vae, | |
| } | |
| # ---------- drawing helpers ---------- | |
| def draw_kv_row(draw, x, y, key, val, key_w): | |
| draw.text((x, y), key, font=F_MONO, fill=MUTED) | |
| draw.text((x + key_w, y), str(val), font=F_MONO, fill=TEXT) | |
| return y + 26 | |
| def draw_h2(draw, x, y, label, color): | |
| draw.text((x, y), label.upper(), font=F_H2, fill=color) | |
| return y + 30 | |
| def wrap_text(text, max_chars): | |
| """Naive word-wrap to fit max_chars per line.""" | |
| out = [] | |
| for raw_line in text.split("\n"): | |
| if not raw_line.strip(): | |
| out.append("") | |
| continue | |
| words, cur = raw_line.split(), "" | |
| for w in words: | |
| if len(cur) + len(w) + 1 <= max_chars: | |
| cur = (cur + " " + w).strip() | |
| else: | |
| if cur: | |
| out.append(cur) | |
| cur = w | |
| if cur: | |
| out.append(cur) | |
| return out | |
| def draw_text_box(draw, x, y, width, label, content, accent, max_lines=3): | |
| """Draw a left-accented note box with a header label + wrapped content lines.""" | |
| char_per_line = 42 | |
| lines = wrap_text(content, char_per_line)[:max_lines] | |
| box_h = 26 + 22 * max(1, len(lines)) + 14 | |
| draw.rounded_rectangle( | |
| [x - 12, y, x + width, y + box_h], | |
| radius=4, fill=NOTE_BG, | |
| ) | |
| draw.rectangle([x - 12, y, x - 8, y + box_h], fill=accent) | |
| draw.text((x + 4, y + 8), label, font=F_H2, fill=accent) | |
| line_y = y + 34 | |
| for line in lines: | |
| draw.text((x + 4, line_y), line, font=F_NOTE, fill=DIM) | |
| line_y += 22 | |
| return y + box_h + 4 | |
| def draw_section(draw, x, y, width, accent, label, body_lines_height, | |
| bg=BG_SECTION, border=BORDER): | |
| """Draw a section panel header + return (content_x, content_y, content_w, end_y). | |
| Body content is drawn by the caller starting at (content_x, content_y).""" | |
| HEADER_H = 36 | |
| section_h = HEADER_H + body_lines_height + 14 | |
| draw.rounded_rectangle( | |
| [x, y, x + width, y + section_h], | |
| radius=SECTION_RADIUS, fill=bg, outline=border, width=1, | |
| ) | |
| # header label — bigger, no side bar, accent colour | |
| draw.text((x + 14, y + 10), label.upper(), font=F_SECTION, fill=accent) | |
| return x + 14, y + HEADER_H, width - 28, y + section_h + 10 | |
| def section_body_rows(num_rows, extra=0): | |
| """Pixel height needed for N kv rows plus optional extra padding.""" | |
| return num_rows * 26 + extra | |
| def render(data: dict, out_path: Path) -> None: | |
| # Oversized canvas; crop after laying out. | |
| canvas_h = 2000 | |
| img = Image.new("RGB", (W, canvas_h), BG_OUTER) | |
| draw = ImageDraw.Draw(img) | |
| inset = 18 | |
| # outer card | |
| draw.rounded_rectangle( | |
| [inset, inset, W - inset, canvas_h - inset], | |
| radius=RADIUS, fill=BG_CARD, outline=BORDER, width=1, | |
| ) | |
| x = inset + PAD_X | |
| y = inset + PAD_TOP | |
| inner_w = W - 2 * inset - 2 * PAD_X | |
| # ---------- HEADER ---------- | |
| import datetime | |
| today = datetime.date.today().isoformat() | |
| ver = data.get("run_label", "?") | |
| stage_label = "single-stage" if data.get("denoise", 1) == 1 else "two-stage" | |
| res_label = data.get("format", "") | |
| title_line = f"Run {ver or '?'} — {stage_label} {res_label}".strip() | |
| sub_line = f"{today} · {data.get('json_name', '')}" | |
| draw.text((x, y), title_line, font=F_TITLE, fill=TEXT) | |
| y += 38 | |
| draw.text((x, y), sub_line, font=F_SUB, fill=MUTED) | |
| y += 30 | |
| # hairline divider under header | |
| draw.line([(x, y), (x + inner_w, y)], fill=BORDER, width=1) | |
| y += 18 | |
| key_w = 122 | |
| # ---------- PHASE 1 ---------- | |
| body_h = section_body_rows(5) | |
| cx, cy, cw, end_y = draw_section(draw, x, y, inner_w, ACCENT_RUN, "Phase 1", body_h) | |
| cy = draw_kv_row(draw, cx, cy, "Format", f"{data['format']} · {data['resolution']}", key_w) | |
| cy = draw_kv_row(draw, cx, cy, "Mult", data["mult"], key_w) | |
| cy = draw_kv_row(draw, cx, cy, "Scheduler", data["scheduler"], key_w) | |
| cy = draw_kv_row(draw, cx, cy, "Steps", data["steps"], key_w) | |
| cy = draw_kv_row(draw, cx, cy, "Denoise", data["denoise"], key_w) | |
| y = end_y | |
| # ---------- BASE NOTES ---------- | |
| note_text = data["note"] or "(no changes)" | |
| note_lines = wrap_text(note_text, 42)[:4] | |
| body_h = max(28, 24 * len(note_lines)) + 4 | |
| cx, cy, cw, end_y = draw_section(draw, x, y, inner_w, ACCENT_RUN, | |
| "Base · notes (Δ this run)", body_h) | |
| for line in note_lines: | |
| draw.text((cx, cy), line, font=F_NOTE, fill=DIM) | |
| cy += 24 | |
| y = end_y | |
| # ---------- BASE · LOCKED ---------- | |
| body_h = section_body_rows(5) | |
| cx, cy, cw, end_y = draw_section(draw, x, y, inner_w, ACCENT_BASE, "Base · locked", body_h) | |
| cy = draw_kv_row(draw, cx, cy, "Sampler", "euler", key_w) | |
| cy = draw_kv_row(draw, cx, cy, "CFG", "1.0", key_w) | |
| cy = draw_kv_row(draw, cx, cy, "Seed", f"{data['seed']} (fixed)", key_w) | |
| cy = draw_kv_row(draw, cx, cy, "epsilon", data["epsilon"], key_w) | |
| cy = draw_kv_row(draw, cx, cy, "Audio src", "model-gen" | |
| if not data["use_custom_audio"] else "custom", key_w) | |
| y = end_y | |
| # ---------- BASE · SCENE ---------- | |
| seg_rows = min(len(data["segments"]), 6) | |
| body_h = section_body_rows(2 + seg_rows, extra=4) | |
| cx, cy, cw, end_y = draw_section(draw, x, y, inner_w, ACCENT_BASE, "Base · scene", body_h) | |
| cy = draw_kv_row(draw, cx, cy, "Duration", | |
| f"{data['dur_frames']} f · {data['dur_seconds']} s @ {data['fps']} fps", key_w) | |
| cy = draw_kv_row(draw, cx, cy, "Segments", | |
| f"{len(data['segments'])} × {data['seg_lengths'][0] if data['seg_lengths'] else '?'} f", key_w) | |
| for i, seg in enumerate(data["segments"][:seg_rows]): | |
| start_s = seg.get("start", 0) / data["fps"] | |
| end_s = (seg.get("start", 0) + seg.get("length", 0)) / data["fps"] | |
| has_prompt = "[x]" if seg.get("prompt") else "[ ]" | |
| has_audio = "[x]" if "Audio:" in (seg.get("prompt") or "") else "[ ]" | |
| has_kf = "[x]" if seg.get("imageFile") else "[ ]" | |
| all_set = (has_prompt == has_audio == has_kf == "[x]") | |
| fill_col = ACCENT_OUT if all_set else TEXT | |
| draw.text((cx, cy), f"{i+1})", font=F_MONO, fill=ACCENT_BASE) | |
| draw.text((cx + 32, cy), f"{start_s:.0f}-{end_s:.0f}s", font=F_MONO, fill=DIM) | |
| draw.text((cx + 110, cy), f"P{has_prompt} A{has_audio} K{has_kf}", | |
| font=F_MONO, fill=fill_col) | |
| cy += 26 | |
| y = end_y | |
| # ---------- POST-RENDER ---------- | |
| feedback_text = data["feedback"] or "(add observations)" | |
| feedback_lines = wrap_text(feedback_text, 42)[:5] | |
| body_h = ( | |
| section_body_rows(2) # render time + output | |
| + 8 # spacer | |
| + 20 # FEEDBACK sublabel | |
| + max(24, 22 * len(feedback_lines)) | |
| + 14 # spacer before outcome | |
| + 20 # OUTCOME sublabel | |
| + 26 # outcome scores row | |
| ) | |
| cx, cy, cw, end_y = draw_section(draw, x, y, inner_w, ACCENT_OUT, "Post-render", body_h) | |
| cy = draw_kv_row(draw, cx, cy, "Render time", data.get("render_duration", "?"), key_w) | |
| cy = draw_kv_row(draw, cx, cy, "Output", data.get("render_output", "?")[:36], key_w) | |
| cy += 8 | |
| # FEEDBACK sublabel + body | |
| draw.text((cx, cy), "FEEDBACK", font=F_TAG, fill=ACCENT_OUT) | |
| cy += 20 | |
| for line in feedback_lines: | |
| draw.text((cx, cy), line, font=F_NOTE, fill=DIM) | |
| cy += 22 | |
| cy += 6 | |
| # OUTCOME sublabel + scores (auto-filled from FEEDBACK parsing) | |
| draw.text((cx, cy), "OUTCOME", font=F_TAG, fill=ACCENT_OUT) | |
| cy += 20 | |
| s = data.get("scores", {}) or {} | |
| def _s(v): return f"{v}/5" if v is not None else "?/5" | |
| draw.text((cx, cy), | |
| f"Motion {_s(s.get('motion'))} Sync {_s(s.get('sync'))} Sharp {_s(s.get('sharp'))}", | |
| font=F_MONO, fill=TEXT) | |
| y = end_y | |
| # ---------- final crop ---------- | |
| final_h = y + PAD_BOTTOM + inset | |
| final = Image.new("RGB", (W, final_h), BG_OUTER) | |
| draw2 = ImageDraw.Draw(final) | |
| draw2.rounded_rectangle( | |
| [inset, inset, W - inset, final_h - inset], | |
| radius=RADIUS, fill=BG_CARD, outline=BORDER, width=1, | |
| ) | |
| content = img.crop((inset + 1, inset + 1, W - inset - 1, final_h - inset - 1)) | |
| final.paste(content, (inset + 1, inset + 1)) | |
| final.save(out_path) | |
| print(f"wrote {out_path} ({W}×{final_h})") | |
| # ---------- CLI ---------- | |
| def _autodiscover_json() -> Path: | |
| """Find the newest *.json in $KOLOOK_AUTOMATIONS_WORK_DIR. Fails clearly.""" | |
| folder = os.environ.get("KOLOOK_AUTOMATIONS_WORK_DIR", "").strip() | |
| if not folder: | |
| raise SystemExit( | |
| "KOLOOK_AUTOMATIONS_WORK_DIR not set. Add it to `.env` (see `.env.example`) " | |
| "or pass the JSON path explicitly: `make_card.py <workflow.json>`." | |
| ) | |
| p = Path(folder) | |
| if not p.is_dir(): | |
| raise SystemExit(f"KOLOOK_AUTOMATIONS_WORK_DIR does not exist on disk: {p}") | |
| candidates = sorted( | |
| (j for j in p.glob("*.json") if not _is_loop_output(j)), | |
| key=lambda x: x.stat().st_mtime, | |
| reverse=True, | |
| ) | |
| if not candidates: | |
| raise SystemExit(f"No *.json files found in {p}. Export a workflow from ComfyUI first.") | |
| return candidates[0] | |
| def main(): | |
| if len(sys.argv) >= 2: | |
| wf_path = Path(sys.argv[1]) | |
| else: | |
| wf_path = _autodiscover_json() | |
| print(f"auto-discovered: {wf_path}") | |
| wf = load_workflow(wf_path) | |
| data = extract(wf) | |
| data["json_name"] = wf_path.name | |
| data["json_stem"] = wf_path.stem | |
| # Output folder priority (first existing wins): | |
| # 1. Working_Folder_PATH Text Multiline inside the workflow JSON. | |
| # 2. KOLOOK_AUTOMATIONS_WORK_DIR env var (from .env). | |
| # 3. The JSON's own parent directory. | |
| candidates = [] | |
| wf_folder = data.get("work_folder_from_wf") or "" | |
| if wf_folder: | |
| candidates.append(("Working_Folder_PATH node", Path(wf_folder))) | |
| env_folder = os.environ.get("KOLOOK_AUTOMATIONS_WORK_DIR", "").strip() | |
| if env_folder: | |
| candidates.append(("KOLOOK_AUTOMATIONS_WORK_DIR", Path(env_folder))) | |
| candidates.append(("JSON parent directory", wf_path.parent)) | |
| folder = None | |
| for label, p in candidates: | |
| try: | |
| p = p.resolve() | |
| except Exception: | |
| continue | |
| if p.is_dir(): | |
| folder = p | |
| break | |
| if folder is None: | |
| # Last-resort fallback — use the unresolved JSON parent. | |
| folder = wf_path.parent | |
| # Output path: stable filename in the _AI subfolder of the working folder. | |
| # Keeps managed artifacts (card.png + iterations.md) separated from user | |
| # content (JSONs, MP4s) so the maintainer can wipe one without the other. | |
| if len(sys.argv) > 2: | |
| out_path = Path(sys.argv[2]) | |
| else: | |
| ai_dir = folder / AI_SUBDIR | |
| ai_dir.mkdir(exist_ok=True) | |
| out_path = ai_dir / CARD_NAME | |
| video, vmtime = find_newest_video(folder) | |
| # Render duration priority: | |
| # 1. ComfyUI's own `Prompt executed in X` log line — authoritative | |
| # wall-time for the model run. Skips trivial follow-up prompts. | |
| # 2. EXR sequence first-to-last-frame span. | |
| # 3. JSON-vs-MP4 mtime delta (last resort). | |
| # Disk mtimes alone are unreliable: ComfyUI writes JSON + MP4 together | |
| # at end-of-render, and the maintainer often assembles MP4s separately. | |
| json_mtime = wf_path.stat().st_mtime | |
| log_path = find_comfyui_log_path() | |
| log_duration = render_time_from_log(log_path) if log_path else None | |
| if log_duration: | |
| data["render_duration"] = fmt_duration(log_duration) + " (Comfy log)" | |
| data["render_log_path"] = str(log_path) | |
| else: | |
| exr_span, exr_version = exr_sequence_duration(folder) | |
| if exr_span is not None and exr_span > 0: | |
| suffix = f" ({exr_version})" if exr_version else "" | |
| data["render_duration"] = fmt_duration(exr_span) + suffix | |
| elif video is not None: | |
| # Fall back to MP4 mtime delta only if no EXRs are present. | |
| same_stem = video.stem.startswith(wf_path.stem) or wf_path.stem.startswith(video.stem) | |
| delta = vmtime - json_mtime | |
| if same_stem: | |
| data["render_duration"] = "—" | |
| elif delta >= 0: | |
| data["render_duration"] = fmt_duration(delta) | |
| else: | |
| data["render_duration"] = "pending" | |
| else: | |
| data["render_duration"] = "pending" | |
| data["render_output"] = video.name if video is not None else "(no video yet)" | |
| # run_label: prefer v01/v04 style, else fall back to ComfyUI sequence number | |
| stem = wf_path.stem | |
| m = re.search(r"(?:^|[_\-])v(\d{1,4})(?:[_\-]|$)", stem) | |
| if m: | |
| data["run_label"] = f"v{m.group(1)}" | |
| else: | |
| m = re.search(r"_(\d{4,6})(?:_|$)", stem) | |
| data["run_label"] = f"#{int(m.group(1))}" if m else "?" | |
| render(data, out_path) | |
| log_iteration(folder, wf_path, data) | |
| if __name__ == "__main__": | |
| main() | |