| """Contact sheet: the whole chain as one readable image. |
| |
| An 8-hop chain is 114 seconds of video. Finding the hop that broke means |
| scrubbing, and scrubbing is how a defect that is obvious in a still gets missed |
| -- the 7.1-sigma cut at f1098 sat in a plan that passed every automated check |
| and in a video nobody watched frame by frame. |
| |
| This renders one row per hop: the hop's first and last delivered frame side by |
| side, with its beat, its directives and what actually happened to it (seed, |
| steps, cache hit, tone correction). One glance says which hop broke and what it |
| was told to do. |
| |
| The same builder serves `dry_run`, where there are no frames yet: rows arrive |
| with `first`/`last` as None and the row becomes a text panel, so a plan can be |
| read end to end before a single sampler step. |
| |
| Nothing here is allowed to lose a render. Pillow is a hard ComfyUI dependency |
| and a system font is nearly always present, but a sheet that raised would throw |
| away a finished chain -- so every failure path returns the 1x1 placeholder and |
| prints a note instead. |
| """ |
| from __future__ import annotations |
|
|
| import os |
|
|
| import torch |
|
|
| TAG = "HTCSheet" |
|
|
| |
| |
| SHEET_W = 1280 |
| THUMB_H = 168 |
| PAD = 14 |
| ROW_GAP = 2 |
|
|
| BG = (14, 14, 14) |
| ROW_BG = (24, 24, 24) |
| ROW_BG_ALT = (30, 30, 30) |
| FG = (232, 232, 232) |
| DIM = (150, 150, 150) |
| ACCENT = (232, 255, 71) |
| WARN = (255, 138, 92) |
|
|
| _FONT_CANDIDATES = ( |
| |
| "C:\\Windows\\Fonts\\segoeui.ttf", |
| "C:\\Windows\\Fonts\\arial.ttf", |
| "C:\\Windows\\Fonts\\tahoma.ttf", |
| |
| "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", |
| "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", |
| |
| "/System/Library/Fonts/Helvetica.ttc", |
| "/Library/Fonts/Arial.ttf", |
| ) |
|
|
| _font_cache = {} |
|
|
|
|
| def _font(size, bold=False): |
| """A truetype face at `size`, falling back all the way to Pillow's default.""" |
| key = (size, bold) |
| if key in _font_cache: |
| return _font_cache[key] |
| from PIL import ImageFont |
|
|
| cands = list(_FONT_CANDIDATES) |
| if bold: |
| cands = [c.replace("segoeui.ttf", "segoeuib.ttf") |
| .replace("arial.ttf", "arialbd.ttf") |
| .replace("DejaVuSans.ttf", "DejaVuSans-Bold.ttf") |
| .replace("LiberationSans-Regular.ttf", "LiberationSans-Bold.ttf") |
| for c in cands] + cands |
| f = None |
| for path in cands: |
| try: |
| if os.path.exists(path): |
| f = ImageFont.truetype(path, size) |
| break |
| except Exception: |
| continue |
| if f is None: |
| |
| try: |
| import matplotlib |
| p = os.path.join(os.path.dirname(matplotlib.__file__), |
| "mpl-data", "fonts", "ttf", |
| "DejaVuSans-Bold.ttf" if bold else "DejaVuSans.ttf") |
| if os.path.exists(p): |
| f = ImageFont.truetype(p, size) |
| except Exception: |
| pass |
| if f is None: |
| try: |
| f = ImageFont.load_default(size=size) |
| except Exception: |
| f = ImageFont.load_default() |
| _font_cache[key] = f |
| return f |
|
|
|
|
| def placeholder(width=16, height=16): |
| """The black IMAGE returned when no sheet was built. |
| |
| Even dimensions, minimum 2, are a hard requirement rather than a default. |
| This used to be 1x1, which is inert to `SaveImage` but detonates any video |
| encoder downstream: libx264 in yuv420p subsamples chroma by 2 and cannot |
| open a context whose width or height is odd, so a dry run wired to |
| `SaveVideo` -- exactly what the Starter ships -- died in `avcodec_open2` |
| before a frame was written, with a traceback naming ComfyUI's video node |
| and nothing of ours. A placeholder whose whole job is to keep a graph alive |
| must survive the nodes the graph actually contains. |
| """ |
| w = max(2, int(width) & ~1) |
| h = max(2, int(height) & ~1) |
| return torch.zeros((1, h, w, 3), dtype=torch.float32) |
|
|
|
|
| def _wrap(draw, text, font, max_w): |
| """Greedy word wrap to `max_w` pixels. -> list of lines.""" |
| words = str(text or "").split() |
| if not words: |
| return [] |
| lines, cur = [], words[0] |
| for w in words[1:]: |
| trial = cur + " " + w |
| if draw.textlength(trial, font=font) <= max_w: |
| cur = trial |
| else: |
| lines.append(cur) |
| cur = w |
| lines.append(cur) |
| return lines |
|
|
|
|
| def _to_pil(frame): |
| """A [H,W,3] float 0..1 tensor -> PIL RGB image.""" |
| from PIL import Image |
| import numpy as np |
| a = frame.detach().float().clamp(0, 1).cpu().numpy() |
| return Image.fromarray((a * 255.0 + 0.5).astype(np.uint8), mode="RGB") |
|
|
|
|
| def _thumb(frame, box_w, box_h): |
| from PIL import Image |
| im = _to_pil(frame) |
| im.thumbnail((box_w, box_h), Image.LANCZOS) |
| return im |
|
|
|
|
| def build(rows, title="", width=SHEET_W): |
| """Render the sheet. -> IMAGE tensor [1,H,W,3] float 0..1. |
| |
| `rows` is a list of dicts: |
| hop 1-based index (int) |
| first,last [H,W,3] float tensors, or None for a text-only row |
| beat the authored beat |
| directives dict of axis -> value |
| meta list of short strings shown dim under the beat |
| note optional string shown in the warning colour |
| """ |
| try: |
| return _build(rows, title, width) |
| except Exception as e: |
| print("[%s] contact sheet skipped (%s: %s)" % (TAG, type(e).__name__, e), |
| flush=True) |
| return placeholder() |
|
|
|
|
| def _build(rows, title, width): |
| from PIL import Image, ImageDraw |
|
|
| if not rows: |
| return placeholder() |
|
|
| f_title = _font(21, bold=True) |
| f_hop = _font(19, bold=True) |
| f_body = _font(15) |
| f_small = _font(13) |
|
|
| |
| have_frames = any(r.get("first") is not None for r in rows) |
| if have_frames: |
| src = next(r["first"] for r in rows if r.get("first") is not None) |
| ar = float(src.shape[1]) / float(src.shape[0]) |
| tw = int(round(THUMB_H * ar)) |
| |
| max_tw = int((width * 0.60 - PAD * 3) / 2) |
| if tw > max_tw: |
| tw = max_tw |
| thumbs_w = tw * 2 + PAD |
| else: |
| tw = 0 |
| thumbs_w = 0 |
|
|
| text_x = PAD + (thumbs_w + PAD if thumbs_w else 0) |
| text_w = width - text_x - PAD |
|
|
| |
| probe = ImageDraw.Draw(Image.new("RGB", (8, 8))) |
| laid = [] |
| for r in rows: |
| beat_lines = _wrap(probe, r.get("beat") or "(continues)", f_body, text_w) |
| note_lines = _wrap(probe, r.get("note") or "", f_small, text_w) if r.get("note") else [] |
| meta = " / ".join(str(m) for m in (r.get("meta") or []) if m) |
| meta_lines = _wrap(probe, meta, f_small, text_w) if meta else [] |
| h_text = (26 |
| + 19 * max(1, len(beat_lines)) |
| + (6 + 17 * len(note_lines) if note_lines else 0) |
| + (6 + 17 * len(meta_lines) if meta_lines else 0)) |
| h = max(THUMB_H if thumbs_w else 0, h_text) + PAD * 2 |
| laid.append((r, beat_lines, note_lines, meta_lines, h)) |
|
|
| head_h = (PAD * 2 + 26) if title else 0 |
| total_h = head_h + sum(h for _, _, _, _, h in laid) + ROW_GAP * max(0, len(laid) - 1) + PAD |
|
|
| img = Image.new("RGB", (int(width), int(total_h)), BG) |
| d = ImageDraw.Draw(img) |
|
|
| y = 0 |
| if title: |
| d.text((PAD, PAD), title, font=f_title, fill=ACCENT) |
| y = head_h |
|
|
| for idx, (r, beat_lines, note_lines, meta_lines, h) in enumerate(laid): |
| d.rectangle([0, y, width, y + h - 1], |
| fill=ROW_BG if idx % 2 == 0 else ROW_BG_ALT) |
| if thumbs_w: |
| for k, key in enumerate(("first", "last")): |
| fr = r.get(key) |
| bx = PAD + k * (tw + PAD) |
| if fr is None: |
| d.rectangle([bx, y + PAD, bx + tw, y + PAD + THUMB_H], |
| outline=(60, 60, 60)) |
| continue |
| th = _thumb(fr, tw, THUMB_H) |
| img.paste(th, (bx, y + PAD)) |
| d.text((bx + 4, y + PAD + THUMB_H - 17), key, |
| font=f_small, fill=(210, 210, 210), |
| stroke_width=2, stroke_fill=(0, 0, 0)) |
|
|
| ty = y + PAD |
| dirs = r.get("directives") or {} |
| dtxt = " ".join("%s=%s" % (k, v) for k, v in dirs.items()) or "no directives" |
| label = "HOP %s" % r.get("hop") |
| d.text((text_x, ty), label, font=f_hop, fill=ACCENT) |
| hw = d.textlength(label, font=f_hop) |
| d.text((text_x + hw + 10, ty + 3), dtxt, font=f_small, fill=DIM) |
| ty += 26 |
| for ln in (beat_lines or ["(continues)"]): |
| d.text((text_x, ty), ln, font=f_body, fill=FG) |
| ty += 19 |
| if note_lines: |
| ty += 6 |
| for ln in note_lines: |
| d.text((text_x, ty), ln, font=f_small, fill=WARN) |
| ty += 17 |
| if meta_lines: |
| ty += 6 |
| for ln in meta_lines: |
| d.text((text_x, ty), ln, font=f_small, fill=DIM) |
| ty += 17 |
|
|
| y += h + ROW_GAP |
|
|
| import numpy as np |
| a = np.asarray(img, dtype=np.float32) / 255.0 |
| return torch.from_numpy(a).unsqueeze(0) |
|
|
|
|
| def small(frame, h=THUMB_H): |
| """Downscale one [H,W,3] frame to `h` pixels tall, for storing in a row. |
| |
| The sheet only ever shows thumbnails, and a full 1280x736 float frame is |
| 11 MB -- two per hop across eight hops is 180 MB held for the length of the |
| render for no reason. Shrinking at collection time keeps it under a MB. |
| """ |
| if frame is None: |
| return None |
| try: |
| import torch.nn.functional as F |
| f = frame.detach().float().unsqueeze(0).permute(0, 3, 1, 2) |
| H, W = int(f.shape[2]), int(f.shape[3]) |
| if H <= h: |
| return frame.detach().float().clone() |
| w = max(1, int(round(W * (float(h) / float(H))))) |
| out = F.interpolate(f, size=(int(h), w), mode="area") |
| return out.permute(0, 2, 3, 1)[0].contiguous() |
| except Exception: |
| return frame.detach().float().clone() |
|
|