"""Render an experiment-tracking card PNG from a ComfyUI workflow JSON. Usage: python scripts/make_card.py [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: /
/.git/worktrees/" 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// to find
/ if "worktrees" in gitdir.parts: idx = gitdir.parts.index("worktrees") common_dir = Path(*gitdir.parts[:idx]) # ends at
/.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 /_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"") 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"|\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 `/custom_nodes/`, so the log sits at `/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 `." ) 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()